Dispatcharr/frontend/src/pages/Guide.jsx
2025-09-21 01:02:32 -05:00

1665 lines
53 KiB
JavaScript

// frontend/src/pages/Guide.js
import React, {
useMemo,
useState,
useEffect,
useRef,
useCallback,
useContext,
} from 'react';
import dayjs from 'dayjs';
import API from '../api';
import useChannelsStore from '../store/channels';
import useLogosStore from '../store/logos';
import logo from '../images/logo.png';
import useVideoStore from '../store/useVideoStore'; // NEW import
import { notifications } from '@mantine/notifications';
import useSettingsStore from '../store/settings';
import {
Title,
Box,
Flex,
Button,
Text,
Paper,
Group,
TextInput,
Select,
ActionIcon,
Tooltip,
Transition,
Modal,
Stack,
} from '@mantine/core';
import { Search, X, Clock, Video, Calendar, Play } from 'lucide-react';
import './guide.css';
import useEPGsStore from '../store/epgs';
import useLocalStorage from '../hooks/useLocalStorage';
import { useElementSize } from '@mantine/hooks';
import { VariableSizeList } from 'react-window';
/** Layout constants */
const CHANNEL_WIDTH = 120; // Width of the channel/logo column
const PROGRAM_HEIGHT = 90; // Height of each channel row
const EXPANDED_PROGRAM_HEIGHT = 180; // Height for expanded program rows
const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider
const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
const GuideVirtualizedContext = React.createContext({
contentWidth: CHANNEL_WIDTH,
nowPosition: -1,
});
const GuideInnerElement = React.forwardRef(function GuideInnerElement(
{ style, children, ...rest },
ref
) {
const { contentWidth, nowPosition } = useContext(GuideVirtualizedContext);
return (
<div
ref={ref}
style={{
...style,
width: contentWidth,
position: 'relative',
}}
{...rest}
>
{nowPosition >= 0 && (
<Box
style={{
position: 'absolute',
left: nowPosition + CHANNEL_WIDTH,
top: 0,
bottom: 0,
width: '2px',
backgroundColor: '#38b2ac',
zIndex: 15,
pointerEvents: 'none',
}}
/>
)}
{children}
</div>
);
});
GuideInnerElement.displayName = 'GuideInnerElement';
const GuideRow = React.memo(function GuideRow({ index, style, data }) {
const {
filteredChannels,
programsByChannelId,
expandedProgramId,
rowHeights,
logos,
hoveredChannelId,
setHoveredChannelId,
renderProgram,
hourTimeline,
handleLogoClick,
contentWidth,
start,
} = data;
const channel = filteredChannels[index];
if (!channel) {
return null;
}
const channelPrograms = programsByChannelId.get(channel.id) || [];
const hasExpandedProgram = channelPrograms.some(
(program) => program.id === expandedProgramId
);
const rowHeight =
rowHeights[index] ??
(hasExpandedProgram ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT);
return (
<div style={{ ...style, width: contentWidth }}>
<Box
style={{
display: 'flex',
height: rowHeight,
borderBottom: '0px solid #27272A',
transition: 'height 0.2s ease',
position: 'relative',
overflow: 'visible',
}}
>
<Box
className="channel-logo"
style={{
width: CHANNEL_WIDTH,
minWidth: CHANNEL_WIDTH,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#18181B',
borderRight: '1px solid #27272A',
borderBottom: '1px solid #27272A',
boxShadow: '2px 0 5px rgba(0,0,0,0.2)',
left: 0,
zIndex: 30,
height: rowHeight,
transition: 'height 0.2s ease',
cursor: 'pointer',
position: 'relative',
}}
onClick={(event) => handleLogoClick(channel, event)}
onMouseEnter={() => setHoveredChannelId(channel.id)}
onMouseLeave={() => setHoveredChannelId(null)}
>
{hoveredChannelId === channel.id && (
<Flex
align="center"
justify="center"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
zIndex: 10,
animation: 'fadeIn 0.2s',
}}
>
<Play size={32} color="#fff" fill="#fff" />
</Flex>
)}
<Flex
direction="column"
align="center"
justify="space-between"
style={{
width: '100%',
height: '100%',
padding: '4px',
boxSizing: 'border-box',
zIndex: 5,
position: 'relative',
}}
>
<Box
style={{
width: '100%',
height: `${rowHeight - 32}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
padding: '4px',
marginBottom: '4px',
}}
>
<img
src={logos[channel.logo_id]?.cache_url || logo}
alt={channel.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
/>
</Box>
<Text
size="sm"
weight={600}
style={{
position: 'absolute',
bottom: '4px',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: '#18181B',
padding: '2px 8px',
borderRadius: 4,
fontSize: '0.85em',
border: '1px solid #27272A',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '36px',
}}
>
{channel.channel_number || '-'}
</Text>
</Flex>
</Box>
<Box
style={{
flex: 1,
position: 'relative',
height: rowHeight,
transition: 'height 0.2s ease',
paddingLeft: 0,
}}
>
{channelPrograms.length > 0 ? (
channelPrograms.map((program) => (
<div key={`${channel.id}-${program.id}-${program.start_time}`}>
{renderProgram(program, start)}
</div>
))
) : (
<>
{Array.from({ length: Math.ceil(hourTimeline.length / 2) }).map(
(_, placeholderIndex) => (
<Box
key={`placeholder-${channel.id}-${placeholderIndex}`}
style={{
position: 'absolute',
left: placeholderIndex * (HOUR_WIDTH * 2),
top: 0,
width: HOUR_WIDTH * 2,
height: rowHeight - 4,
border: '1px dashed #2D3748',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#4A5568',
}}
>
<Text size="sm">No program data</Text>
</Box>
)
)}
</>
)}
</Box>
</Box>
</div>
);
});
GuideRow.displayName = 'GuideRow';
export default function TVChannelGuide({ startDate, endDate }) {
const channels = useChannelsStore((s) => s.channels);
const recordings = useChannelsStore((s) => s.recordings);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const profiles = useChannelsStore((s) => s.profiles);
const logos = useLogosStore((s) => s.logos);
const tvgsById = useEPGsStore((s) => s.tvgsById);
const [programs, setPrograms] = useState([]);
const [guideChannels, setGuideChannels] = useState([]);
const [filteredChannels, setFilteredChannels] = useState([]);
const [now, setNow] = useState(dayjs());
const [expandedProgramId, setExpandedProgramId] = useState(null); // Track expanded program
const [recordingForProgram, setRecordingForProgram] = useState(null);
const [recordChoiceOpen, setRecordChoiceOpen] = useState(false);
const [recordChoiceProgram, setRecordChoiceProgram] = useState(null);
const [existingRuleMode, setExistingRuleMode] = useState(null);
const [rulesOpen, setRulesOpen] = useState(false);
const [rules, setRules] = useState([]);
const [initialScrollComplete, setInitialScrollComplete] = useState(false);
// New filter states
const [searchQuery, setSearchQuery] = useState('');
const [selectedGroupId, setSelectedGroupId] = useState('all');
const [selectedProfileId, setSelectedProfileId] = useState('all');
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const guideRef = useRef(null);
const timelineRef = useRef(null); // New ref for timeline scrolling
const listRef = useRef(null);
const isSyncingScroll = useRef(false);
const {
ref: guideContainerRef,
width: guideWidth,
height: guideHeight,
} = useElementSize();
// Add new state to track hovered logo
const [hoveredChannelId, setHoveredChannelId] = useState(null);
// Load program data once
useEffect(() => {
if (!Object.keys(channels).length === 0) {
console.warn('No channels provided or empty channels array');
notifications.show({ title: 'No channels available', color: 'red.5' });
return;
}
const fetchPrograms = async () => {
console.log('Fetching program grid...');
const fetched = await API.getGrid(); // GETs your EPG grid
const receivedCount = Array.isArray(fetched) ? fetched.length : 0;
console.log(`Received ${receivedCount} programs`);
// Include ALL channels, sorted by channel number - don't filter by EPG data
const sortedChannels = Object.values(channels).sort(
(a, b) =>
(a.channel_number || Infinity) - (b.channel_number || Infinity)
);
console.log(`Using all ${sortedChannels.length} available channels`);
const processedPrograms = (Array.isArray(fetched) ? fetched : []).map(
(program) => {
const start = dayjs(program.start_time);
const end = dayjs(program.end_time);
return {
...program,
startMs: start.valueOf(),
endMs: end.valueOf(),
};
}
);
setGuideChannels(sortedChannels);
setFilteredChannels(sortedChannels); // Initialize filtered channels
setPrograms(processedPrograms);
};
fetchPrograms();
}, [channels]);
// Apply filters when search, group, or profile changes
useEffect(() => {
if (!guideChannels.length) return;
let result = [...guideChannels];
// Apply search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter((channel) =>
channel.name.toLowerCase().includes(query)
);
}
// Apply channel group filter
if (selectedGroupId !== 'all') {
result = result.filter(
(channel) => channel.channel_group_id === parseInt(selectedGroupId)
);
}
// Apply profile filter
if (selectedProfileId !== 'all') {
// Get the profile's enabled channels
const profileChannels = profiles[selectedProfileId]?.channels || [];
// Check if channels is a Set (from the error message, it likely is)
const enabledChannelIds = Array.isArray(profileChannels)
? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
: profiles[selectedProfileId]?.channels instanceof Set
? Array.from(profiles[selectedProfileId].channels)
: [];
result = result.filter((channel) =>
enabledChannelIds.includes(channel.id)
);
}
setFilteredChannels(result);
}, [
searchQuery,
selectedGroupId,
selectedProfileId,
guideChannels,
profiles,
]);
const channelById = useMemo(() => {
return guideChannels.reduce((acc, channel) => {
acc[channel.id] = channel;
return acc;
}, {});
}, [guideChannels]);
const channelIdByTvgId = useMemo(() => {
const map = new Map();
guideChannels.forEach((channel) => {
const tvgRecord = channel.epg_data_id
? tvgsById[channel.epg_data_id]
: null;
const tvgId = tvgRecord?.tvg_id ?? channel.uuid;
if (tvgId) {
map.set(String(tvgId), channel.id);
}
});
return map;
}, [guideChannels, tvgsById]);
const programsByChannelId = useMemo(() => {
if (!programs.length) return new Map();
const map = new Map();
programs.forEach((program) => {
const channelId = channelIdByTvgId.get(String(program.tvg_id));
if (!channelId) return;
if (!map.has(channelId)) {
map.set(channelId, []);
}
map.get(channelId).push(program);
});
map.forEach((list) =>
list.sort((a, b) => (a.startMs || 0) - (b.startMs || 0))
);
return map;
}, [programs, channelIdByTvgId]);
const recordingsByProgramId = useMemo(() => {
const map = new Map();
(recordings || []).forEach((recording) => {
const programId = recording?.custom_properties?.program?.id;
if (programId != null) {
map.set(programId, recording);
}
});
return map;
}, [recordings]);
const rowHeights = useMemo(() => {
if (!filteredChannels.length) return [];
return filteredChannels.map((channel) => {
const channelPrograms = programsByChannelId.get(channel.id) || [];
const hasExpandedProgram = channelPrograms.some(
(program) => program.id === expandedProgramId
);
return hasExpandedProgram ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT;
});
}, [filteredChannels, programsByChannelId, expandedProgramId]);
const getItemSize = useCallback(
(index) => rowHeights[index] ?? PROGRAM_HEIGHT,
[rowHeights]
);
useEffect(() => {
if (!listRef.current) return;
listRef.current.resetAfterIndex(0, true);
}, [rowHeights]);
useEffect(() => {
if (!listRef.current) return;
listRef.current.scrollToItem(0);
}, [searchQuery, selectedGroupId, selectedProfileId]);
// Use start/end from props or default to "today at midnight" +24h
const defaultStart = dayjs(startDate || dayjs().startOf('day'));
const defaultEnd = endDate ? dayjs(endDate) : defaultStart.add(24, 'hour');
// Expand timeline if needed based on actual earliest/ latest program
const earliestProgramStart = useMemo(() => {
if (!programs.length) return defaultStart;
return programs.reduce((acc, p) => {
const s = dayjs(p.start_time);
return s.isBefore(acc) ? s : acc;
}, defaultStart);
}, [programs, defaultStart]);
const latestProgramEnd = useMemo(() => {
if (!programs.length) return defaultEnd;
return programs.reduce((acc, p) => {
const e = dayjs(p.end_time);
return e.isAfter(acc) ? e : acc;
}, defaultEnd);
}, [programs, defaultEnd]);
const start = earliestProgramStart.isBefore(defaultStart)
? earliestProgramStart
: defaultStart;
const end = latestProgramEnd.isAfter(defaultEnd)
? latestProgramEnd
: defaultEnd;
// Format day label using relative terms when possible (Today, Tomorrow, etc)
const formatDayLabel = useCallback((time) => {
const today = dayjs().startOf('day');
const tomorrow = today.add(1, 'day');
const weekLater = today.add(7, 'day');
const day = time.startOf('day');
if (day.isSame(today, 'day')) {
return 'Today';
} else if (day.isSame(tomorrow, 'day')) {
return 'Tomorrow';
} else if (day.isBefore(weekLater)) {
// Within a week, show day name
return time.format('dddd');
} else {
// Beyond a week, show month and day
return time.format(dateFormat);
}
}, [dateFormat]);
// Hourly marks with day labels
const hourTimeline = useMemo(() => {
const hours = [];
let current = start;
let currentDay = null;
while (current.isBefore(end)) {
// Check if we're entering a new day
const day = current.startOf('day');
const isNewDay = !currentDay || !day.isSame(currentDay, 'day');
if (isNewDay) {
currentDay = day;
}
// Add day information to our hour object
hours.push({
time: current,
isNewDay,
dayLabel: formatDayLabel(current),
});
current = current.add(1, 'hour');
}
return hours;
}, [start, end, formatDayLabel]);
// Scroll to the nearest half-hour mark ONLY on initial load
useEffect(() => {
if (
guideRef.current &&
timelineRef.current &&
programs.length > 0 &&
!initialScrollComplete
) {
// Round the current time to the nearest half-hour mark
const roundedNow =
now.minute() < 30
? now.startOf('hour')
: now.startOf('hour').add(30, 'minute');
const nowOffset = roundedNow.diff(start, 'minute');
const scrollPosition =
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
MINUTE_BLOCK_WIDTH;
const scrollPos = Math.max(scrollPosition, 0);
guideRef.current.scrollLeft = scrollPos;
timelineRef.current.scrollLeft = scrollPos; // Sync timeline scroll
// Mark initial scroll as complete
setInitialScrollComplete(true);
}
}, [programs, start, now, initialScrollComplete]);
// Update “now” every second
useEffect(() => {
const interval = setInterval(() => {
setNow(dayjs());
}, 1000);
return () => clearInterval(interval);
}, []);
// Pixel offset for the “now” vertical line
const nowPosition = useMemo(() => {
if (now.isBefore(start) || now.isAfter(end)) return -1;
const minutesSinceStart = now.diff(start, 'minute');
return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
}, [now, start, end]);
const contentWidth = useMemo(
() => hourTimeline.length * HOUR_WIDTH + CHANNEL_WIDTH,
[hourTimeline.length]
);
const virtualizedHeight = useMemo(
() => guideHeight || 600,
[guideHeight]
);
const virtualizedWidth = useMemo(() => {
if (guideWidth) return guideWidth;
if (typeof window !== 'undefined') {
return window.innerWidth;
}
return Math.max(contentWidth, 1200);
}, [guideWidth, contentWidth]);
const itemKey = useCallback(
(index) => filteredChannels[index]?.id ?? index,
[filteredChannels]
);
useEffect(() => {
const guideEl = guideRef.current;
if (!guideEl) return;
let frame;
const syncScroll = () => {
if (!timelineRef.current) return;
if (isSyncingScroll.current) return;
isSyncingScroll.current = true;
const scrollLeft = guideEl.scrollLeft;
frame = requestAnimationFrame(() => {
if (timelineRef.current) {
timelineRef.current.scrollLeft = scrollLeft;
}
isSyncingScroll.current = false;
});
};
guideEl.addEventListener('scroll', syncScroll);
return () => {
guideEl.removeEventListener('scroll', syncScroll);
if (frame) cancelAnimationFrame(frame);
isSyncingScroll.current = false;
};
}, [guideWidth, guideRef, timelineRef]);
// Helper: find channel by tvg_id
const findChannelByTvgId = useCallback(
(tvgId) => {
const channelId = channelIdByTvgId.get(String(tvgId));
return channelId ? channelById[channelId] : undefined;
},
[channelById, channelIdByTvgId]
);
const openRecordChoice = useCallback(
async (program) => {
setRecordChoiceProgram(program);
setRecordChoiceOpen(true);
try {
const rules = await API.listSeriesRules();
const rule = (rules || []).find(
(r) =>
String(r.tvg_id) === String(program.tvg_id) &&
(!r.title || r.title === program.title)
);
setExistingRuleMode(rule ? rule.mode : null);
} catch (error) {
console.warn('Failed to load series rules', error);
}
try {
const rec = recordingsByProgramId.get(program.id);
setRecordingForProgram(rec || null);
} catch (error) {
console.warn('Failed to resolve program recording', error);
}
},
[recordingsByProgramId]
);
const recordOne = useCallback(
async (program) => {
const channel = findChannelByTvgId(program.tvg_id);
if (!channel) {
notifications.show({
title: 'Channel not found',
message: 'Unable to schedule recording for this program.',
color: 'red',
});
return;
}
await API.createRecording({
channel: `${channel.id}`,
start_time: program.start_time,
end_time: program.end_time,
custom_properties: { program },
});
notifications.show({ title: 'Recording scheduled' });
},
[findChannelByTvgId]
);
const saveSeriesRule = useCallback(async (program, mode) => {
await API.createSeriesRule({
tvg_id: program.tvg_id,
mode,
title: program.title,
});
await API.evaluateSeriesRules(program.tvg_id);
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.warn('Failed to refresh recordings after saving series rule', error);
}
notifications.show({
title: mode === 'new' ? 'Record new episodes' : 'Record all episodes',
});
}, []);
const openRules = useCallback(async () => {
setRulesOpen(true);
try {
const r = await API.listSeriesRules();
setRules(r);
} catch (error) {
console.warn('Failed to fetch series rules', error);
}
}, []);
// The “Watch Now” click => show floating video
const showVideo = useVideoStore((s) => s.showVideo);
const handleWatchStream = useCallback(
(program) => {
const matched = findChannelByTvgId(program.tvg_id);
if (!matched) {
console.warn(`No channel found for tvg_id=${program.tvg_id}`);
return;
}
let vidUrl = `/proxy/ts/stream/${matched.uuid}`;
if (env_mode === 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
},
[env_mode, findChannelByTvgId, showVideo]
);
// Function to handle logo click to play channel
const handleLogoClick = useCallback(
(channel, event) => {
event.stopPropagation();
let vidUrl = `/proxy/ts/stream/${channel.uuid}`;
if (env_mode === 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
},
[env_mode, showVideo]
);
// On program click, toggle the expanded state
const handleProgramClick = useCallback(
(program, event) => {
event.stopPropagation();
const programStart = dayjs(program.start_time);
const startOffsetMinutes = programStart.diff(start, 'minute');
const leftPx =
(startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
const desiredScrollPosition = Math.max(0, leftPx - 20);
if (expandedProgramId === program.id) {
setExpandedProgramId(null);
setRecordingForProgram(null);
return;
}
setExpandedProgramId(program.id);
const programRecording = recordingsByProgramId.get(program.id) || null;
setRecordingForProgram(programRecording);
if (guideRef.current && timelineRef.current) {
const currentScrollPosition = guideRef.current.scrollLeft;
if (
desiredScrollPosition < currentScrollPosition ||
leftPx - currentScrollPosition < 100
) {
guideRef.current.scrollTo({
left: desiredScrollPosition,
behavior: 'smooth',
});
timelineRef.current.scrollTo({
left: desiredScrollPosition,
behavior: 'smooth',
});
}
}
},
[expandedProgramId, guideRef, recordingsByProgramId, start, timelineRef]
);
// Close the expanded program when clicking elsewhere
const handleClickOutside = useCallback(() => {
if (expandedProgramId) {
setExpandedProgramId(null);
setRecordingForProgram(null);
}
}, [expandedProgramId]);
// Function to scroll to current time - matches initial loading position
const scrollToNow = useCallback(() => {
if (guideRef.current && timelineRef.current && nowPosition >= 0) {
const roundedNow =
now.minute() < 30
? now.startOf('hour')
: now.startOf('hour').add(30, 'minute');
const nowOffset = roundedNow.diff(start, 'minute');
const scrollPosition =
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
MINUTE_BLOCK_WIDTH;
const scrollPos = Math.max(scrollPosition, 0);
guideRef.current.scrollLeft = scrollPos;
timelineRef.current.scrollLeft = scrollPos;
}
}, [guideRef, now, nowPosition, start, timelineRef]);
// Sync scrolling between timeline and main content
const handleTimelineScroll = useCallback(() => {
if (!timelineRef.current || !guideRef.current) return;
if (isSyncingScroll.current) return;
isSyncingScroll.current = true;
const target = timelineRef.current.scrollLeft;
guideRef.current.scrollLeft = target;
requestAnimationFrame(() => {
isSyncingScroll.current = false;
});
}, [guideRef, timelineRef]);
// Handle wheel events on the timeline for horizontal scrolling
const handleTimelineWheel = useCallback(
(event) => {
if (!timelineRef.current) return;
event.preventDefault();
const scrollAmount = event.shiftKey ? 250 : 125;
timelineRef.current.scrollLeft +=
event.deltaY > 0 ? scrollAmount : -scrollAmount;
handleTimelineScroll();
},
[handleTimelineScroll]
);
// Function to handle timeline time clicks with 15-minute snapping
const handleTimeClick = useCallback(
(clickedTime, event) => {
if (!timelineRef.current || !guideRef.current) return;
const hourBlockElement = event.currentTarget;
const rect = hourBlockElement.getBoundingClientRect();
const clickPositionX = event.clientX - rect.left;
const percentageAcross = clickPositionX / rect.width;
const minuteWithinHour = Math.floor(percentageAcross * 60);
let snappedMinute;
if (minuteWithinHour < 7.5) {
snappedMinute = 0;
} else if (minuteWithinHour < 22.5) {
snappedMinute = 15;
} else if (minuteWithinHour < 37.5) {
snappedMinute = 30;
} else if (minuteWithinHour < 52.5) {
snappedMinute = 45;
} else {
snappedMinute = 0;
clickedTime = clickedTime.add(1, 'hour');
}
const snappedTime = clickedTime.minute(snappedMinute);
const snappedOffset = snappedTime.diff(start, 'minute');
const scrollPosition =
(snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
timelineRef.current.scrollLeft = scrollPosition;
guideRef.current.scrollLeft = scrollPosition;
},
[start]
);
// Renders each program block
const renderProgram = useCallback(
(program, channelStart) => {
const programKey = `${program.tvg_id}-${program.start_time}`;
const programStart = dayjs(program.start_time);
const programEnd = dayjs(program.end_time);
const startOffsetMinutes = programStart.diff(channelStart, 'minute');
const durationMinutes = programEnd.diff(programStart, 'minute');
const leftPx =
(startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
const gapSize = 2;
const widthPx =
(durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
gapSize * 2;
const recording = recordingsByProgramId.get(program.id);
const isLive = now.isAfter(programStart) && now.isBefore(programEnd);
const isPast = now.isAfter(programEnd);
const isExpanded = expandedProgramId === program.id;
const rowHeight = isExpanded ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT;
const MIN_EXPANDED_WIDTH = 450;
const expandedWidthPx = Math.max(widthPx, MIN_EXPANDED_WIDTH);
const currentScrollLeft = guideRef.current?.scrollLeft || 0;
const programStartInView = leftPx + gapSize;
const programEndInView = leftPx + gapSize + widthPx;
const viewportLeft = currentScrollLeft;
const startsBeforeView = programStartInView < viewportLeft;
const extendsIntoView = programEndInView > viewportLeft;
let textOffsetLeft = 0;
if (startsBeforeView && extendsIntoView) {
const visibleStart = Math.max(viewportLeft - programStartInView, 0);
const maxOffset = widthPx - 200;
textOffsetLeft = Math.min(visibleStart, maxOffset);
}
return (
<Box
className="guide-program-container"
key={programKey}
style={{
position: 'absolute',
left: leftPx + gapSize,
top: 0,
width: isExpanded ? expandedWidthPx : widthPx,
height: rowHeight - 4,
cursor: 'pointer',
zIndex: isExpanded ? 25 : 5,
transition: isExpanded
? 'height 0.2s ease, width 0.2s ease'
: 'height 0.2s ease',
}}
onClick={(event) => handleProgramClick(program, event)}
>
<Paper
elevation={isExpanded ? 4 : 2}
className={`guide-program ${isLive ? 'live' : isPast ? 'past' : 'not-live'} ${isExpanded ? 'expanded' : ''}`}
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
position: 'relative',
display: 'flex',
flexDirection: 'column',
justifyContent: isExpanded ? 'flex-start' : 'space-between',
padding: isExpanded ? '12px' : '8px',
backgroundColor: isExpanded
? isLive
? '#1a365d'
: isPast
? '#18181B'
: '#1e40af'
: isLive
? '#18181B'
: isPast
? '#27272A'
: '#2c5282',
color: isPast ? '#a0aec0' : '#fff',
boxShadow: isExpanded ? '0 4px 8px rgba(0,0,0,0.4)' : 'none',
transition: 'all 0.2s ease',
}}
>
<Box
style={{
transform: `translateX(${textOffsetLeft}px)`,
transition: 'transform 0.1s ease-out',
}}
>
<Text
component="div"
size={isExpanded ? 'lg' : 'md'}
style={{
fontWeight: 'bold',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
<Group gap="xs">
{recording && (
<div
style={{
borderRadius: '50%',
width: '10px',
height: '10px',
display: 'flex',
backgroundColor: 'red',
}}
></div>
)}
{program.title}
</Group>
</Text>
<Text
size="sm"
style={{
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{programStart.format(timeFormat)} - {programEnd.format(timeFormat)}
</Text>
</Box>
{program.description && (
<Box
style={{
transform: `translateX(${textOffsetLeft}px)`,
transition: 'transform 0.1s ease-out',
}}
>
<Text
size="xs"
style={{
marginTop: '4px',
whiteSpace: isExpanded ? 'normal' : 'nowrap',
textOverflow: isExpanded ? 'clip' : 'ellipsis',
overflow: isExpanded ? 'auto' : 'hidden',
color: isPast ? '#718096' : '#cbd5e0',
maxHeight: isExpanded ? '80px' : 'unset',
}}
>
{program.description}
</Text>
</Box>
)}
{isExpanded && (
<Box style={{ marginTop: 'auto' }}>
<Flex gap="md" justify="flex-end" mt={8}>
{!isPast && (
<Button
leftSection={<Calendar size={14} />}
variant="filled"
color="red"
size="xs"
onClick={(event) => {
event.stopPropagation();
openRecordChoice(program);
}}
>
Record
</Button>
)}
{isLive && (
<Button
leftSection={<Video size={14} />}
variant="filled"
color="blue"
size="xs"
onClick={(event) => {
event.stopPropagation();
handleWatchStream(program);
}}
>
Watch Now
</Button>
)}
</Flex>
</Box>
)}
</Paper>
</Box>
);
},
[
expandedProgramId,
guideRef,
handleProgramClick,
handleWatchStream,
now,
openRecordChoice,
recordingsByProgramId,
timeFormat,
]
);
const listData = useMemo(
() => ({
filteredChannels,
programsByChannelId,
expandedProgramId,
rowHeights,
logos,
hoveredChannelId,
setHoveredChannelId,
renderProgram,
hourTimeline,
handleLogoClick,
contentWidth,
start,
}),
[
filteredChannels,
programsByChannelId,
expandedProgramId,
rowHeights,
logos,
hoveredChannelId,
renderProgram,
hourTimeline,
handleLogoClick,
contentWidth,
start,
setHoveredChannelId,
]
);
// Create group options for dropdown - but only include groups used by guide channels
const groupOptions = useMemo(() => {
const options = [{ value: 'all', label: 'All Channel Groups' }];
if (channelGroups && guideChannels.length > 0) {
// Get unique channel group IDs from the channels that have program data
const usedGroupIds = new Set();
guideChannels.forEach((channel) => {
if (channel.channel_group_id) {
usedGroupIds.add(channel.channel_group_id);
}
});
// Only add groups that are actually used by channels in the guide
Object.values(channelGroups)
.filter((group) => usedGroupIds.has(group.id))
.sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
.forEach((group) => {
options.push({
value: group.id.toString(),
label: group.name,
});
});
}
return options;
}, [channelGroups, guideChannels]);
// Create profile options for dropdown
const profileOptions = useMemo(() => {
const options = [{ value: 'all', label: 'All Profiles' }];
if (profiles) {
Object.values(profiles).forEach((profile) => {
if (profile.id !== '0') {
// Skip the 'All' default profile
options.push({
value: profile.id.toString(),
label: profile.name,
});
}
});
}
return options;
}, [profiles]);
// Clear all filters
const clearFilters = () => {
setSearchQuery('');
setSelectedGroupId('all');
setSelectedProfileId('all');
};
// Handle group selection changes, ensuring null becomes 'all'
const handleGroupChange = (value) => {
setSelectedGroupId(value || 'all');
};
// Handle profile selection changes, ensuring null becomes 'all'
const handleProfileChange = (value) => {
setSelectedProfileId(value || 'all');
};
// Handle date-time formats
const [timeFormatSetting] = useLocalStorage('time-format', '12h');
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const timeFormat = timeFormatSetting === '12h' ? 'h:mm A' : 'HH:mm';
const dateFormat = dateFormatSetting === 'mdy' ? 'MMMM D' : 'D MMMM';
return (
<Box
className="tv-guide"
style={{
overflow: 'hidden',
width: '100%',
height: '100%',
// backgroundColor: 'rgb(39, 39, 42)',
color: '#fff',
fontFamily: 'Roboto, sans-serif',
}}
onClick={handleClickOutside} // Close expanded program when clicking outside
>
{/* Sticky top bar */}
<Flex
direction="column"
style={{
// backgroundColor: '#424242',
color: '#fff',
padding: '12px 20px',
position: 'sticky',
top: 0,
zIndex: 1000,
}}
>
{/* Title and current time */}
<Flex justify="space-between" align="center" mb={12}>
<Title order={3} style={{ fontWeight: 'bold' }}>
TV Guide
</Title>
<Flex align="center" gap="md">
<Text>
{now.format(`dddd, ${dateFormat}, YYYY • ${timeFormat}`)}
</Text>
<Tooltip label="Jump to current time">
<ActionIcon
onClick={scrollToNow}
variant="filled"
size="md"
radius="xl"
color="teal"
>
<Clock size={16} />
</ActionIcon>
</Tooltip>
</Flex>
</Flex>
{/* Filter controls */}
<Flex gap="md" align="center">
<TextInput
placeholder="Search channels..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ width: '250px' }} // Reduced width from flex: 1
leftSection={<Search size={16} />}
rightSection={
searchQuery ? (
<ActionIcon
onClick={() => setSearchQuery('')}
variant="subtle"
color="gray"
size="sm"
>
<X size={14} />
</ActionIcon>
) : null
}
/>
<Select
placeholder="Filter by group"
data={groupOptions}
value={selectedGroupId}
onChange={handleGroupChange} // Use the new handler
style={{ width: '220px' }}
clearable={true} // Allow clearing the selection
/>
<Select
placeholder="Filter by profile"
data={profileOptions}
value={selectedProfileId}
onChange={handleProfileChange} // Use the new handler
style={{ width: '180px' }}
clearable={true} // Allow clearing the selection
/>
{(searchQuery !== '' ||
selectedGroupId !== 'all' ||
selectedProfileId !== 'all') && (
<Button variant="subtle" onClick={clearFilters} size="sm">
Clear Filters
</Button>
)}
<Button
variant="filled"
size="sm"
onClick={openRules}
style={{
backgroundColor: '#245043',
border: '1px solid #3BA882',
color: '#FFFFFF',
}}
>
Series Rules
</Button>
<Text size="sm" color="dimmed">
{filteredChannels.length}{' '}
{filteredChannels.length === 1 ? 'channel' : 'channels'}
</Text>
</Flex>
</Flex>
{/* Guide container with headers and scrollable content */}
<Box
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 120px)',
}}
>
{/* Logo header - Sticky, non-scrollable */}
<Box
style={{
display: 'flex',
position: 'sticky',
top: 0,
zIndex: 100,
}}
>
{/* Logo header cell - sticky in both directions */}
<Box
style={{
width: CHANNEL_WIDTH,
minWidth: CHANNEL_WIDTH,
flexShrink: 0,
height: '40px',
backgroundColor: '#18181B',
borderBottom: '1px solid #27272A',
borderRight: '1px solid #27272A', // Increased border width
position: 'sticky',
left: 0,
zIndex: 200,
}}
/>
{/* Timeline header with its own scrollbar */}
<Box
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
}}
>
<Box
ref={timelineRef}
style={{
overflowX: 'auto',
overflowY: 'hidden',
position: 'relative',
}}
onScroll={handleTimelineScroll}
onWheel={handleTimelineWheel} // Add wheel event handler
>
<Box
style={{
display: 'flex',
backgroundColor: '#1E2A27',
borderBottom: '1px solid #27272A',
width: hourTimeline.length * HOUR_WIDTH,
}}
>
{' '}
{hourTimeline.map((hourData) => {
const { time, isNewDay } = hourData;
return (
<Box
key={time.format()}
style={{
width: HOUR_WIDTH,
height: '40px',
position: 'relative',
color: '#a0aec0',
borderRight: '1px solid #8DAFAA',
cursor: 'pointer',
borderLeft: isNewDay ? '2px solid #3BA882' : 'none', // Highlight day boundaries
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421', // Subtle background for new days
}}
onClick={(e) => handleTimeClick(time, e)}
>
{/* Remove the special day label for new days since we'll show day for all hours */}
{/* Position time label at the left border of each hour block */}
<Text
size="sm"
style={{
position: 'absolute',
top: '8px', // Consistent positioning for all hours
left: '4px',
transform: 'none',
borderRadius: '2px',
lineHeight: 1.2,
textAlign: 'left',
}}
>
{/* Show day above time for every hour using the same format */}
<Text
span
size="xs"
style={{
display: 'block',
opacity: 0.7,
fontWeight: isNewDay ? 600 : 400, // Still emphasize day transitions
color: isNewDay ? '#3BA882' : undefined,
}}
>
{formatDayLabel(time)}{' '}
{/* Use same formatDayLabel function for all hours */}
</Text>
{time.format(timeFormat)}
<Text span size="xs" ml={1} opacity={0.7}>
{/*time.format('A')*/}
</Text>
</Text>
{/* Hour boundary marker - more visible */}
<Box
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '1px',
backgroundColor: '#27272A',
zIndex: 10,
}}
/>
{/* Quarter hour tick marks */}
<Box
style={{
position: 'absolute',
bottom: 0,
width: '100%',
display: 'flex',
justifyContent: 'space-between',
padding: '0 1px',
}}
>
{[15, 30, 45].map((minute) => (
<Box
key={minute}
style={{
width: '1px',
height: '8px',
backgroundColor: '#718096',
position: 'absolute',
bottom: 0,
left: `${(minute / 60) * 100}%`,
}}
/>
))}
</Box>
</Box>
);
})}
</Box>
</Box>
</Box>
</Box>
{/* Main scrollable container for program content */}
<Box
ref={guideContainerRef}
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
}}
>
{filteredChannels.length > 0 ? (
<GuideVirtualizedContext.Provider
value={{ contentWidth, nowPosition }}
>
<VariableSizeList
height={virtualizedHeight}
width={virtualizedWidth}
itemCount={filteredChannels.length}
itemSize={getItemSize}
estimatedItemSize={PROGRAM_HEIGHT}
outerRef={guideRef}
innerElementType={GuideInnerElement}
itemKey={itemKey}
itemData={listData}
ref={listRef}
overscanCount={5}
>
{GuideRow}
</VariableSizeList>
</GuideVirtualizedContext.Provider>
) : (
<Box
style={{
padding: '30px',
textAlign: 'center',
color: '#a0aec0',
}}
>
<Text size="lg">No channels match your filters</Text>
<Button variant="subtle" onClick={clearFilters} mt={10}>
Clear Filters
</Button>
</Box>
)}
</Box>
</Box>
{/* Record choice modal */}
{recordChoiceOpen && recordChoiceProgram && (
<Modal
opened={recordChoiceOpen}
onClose={() => setRecordChoiceOpen(false)}
title={`Record: ${recordChoiceProgram.title}`}
centered
radius="md"
zIndex={9999}
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
styles={{
content: { backgroundColor: '#18181B', color: 'white' },
header: { backgroundColor: '#18181B', color: 'white' },
title: { color: 'white' },
}}
>
<Flex direction="column" gap="sm">
<Button onClick={() => { recordOne(recordChoiceProgram); setRecordChoiceOpen(false); }}>Just this one</Button>
<Button variant="light" onClick={() => { saveSeriesRule(recordChoiceProgram, 'all'); setRecordChoiceOpen(false); }}>Every episode</Button>
<Button variant="light" onClick={() => { saveSeriesRule(recordChoiceProgram, 'new'); setRecordChoiceOpen(false); }}>New episodes only</Button>
{recordingForProgram && (
<>
<Button color="orange" variant="light" onClick={async () => {
try {
await API.deleteRecording(recordingForProgram.id);
} catch (error) {
console.warn('Failed to delete recording', error);
}
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.warn('Failed to refresh recordings after delete', error);
}
setRecordChoiceOpen(false);
}}>Remove this recording</Button>
<Button color="red" variant="light" onClick={async () => {
try {
await API.bulkRemoveSeriesRecordings({ tvg_id: recordChoiceProgram.tvg_id, title: recordChoiceProgram.title, scope: 'title' });
} catch (error) {
console.warn('Failed to bulk remove series recordings', error);
}
try {
await API.deleteSeriesRule(recordChoiceProgram.tvg_id);
} catch (error) {
console.warn('Failed to delete series rule', error);
}
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.warn('Failed to refresh recordings after bulk delete', error);
}
setRecordChoiceOpen(false);
}}>Remove this series (scheduled)</Button>
</>
)}
{existingRuleMode && (
<Button
color="red"
variant="subtle"
onClick={async () => {
try {
await API.deleteSeriesRule(recordChoiceProgram.tvg_id);
} catch (error) {
console.warn('Failed to delete series rule', error);
}
setExistingRuleMode(null);
setRecordChoiceOpen(false);
}}
>
Remove series rule ({existingRuleMode})
</Button>
)}
</Flex>
</Modal>
)}
{/* Series rules modal */}
{rulesOpen && (
<Modal
opened={rulesOpen}
onClose={() => setRulesOpen(false)}
title="Series Recording Rules"
centered
radius="md"
zIndex={9999}
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
styles={{
content: { backgroundColor: '#18181B', color: 'white' },
header: { backgroundColor: '#18181B', color: 'white' },
title: { color: 'white' },
}}
>
<Stack gap="sm">
{(!rules || rules.length === 0) && (
<Text size="sm" c="dimmed">No series rules configured</Text>
)}
{rules && rules.map((r) => (
<Flex key={`${r.tvg_id}-${r.mode}`} justify="space-between" align="center">
<Text size="sm">{r.title || r.tvg_id} {r.mode === 'new' ? 'New episodes' : 'Every episode'}</Text>
<Group gap="xs">
<Button size="xs" variant="subtle" onClick={async () => {
await API.evaluateSeriesRules(r.tvg_id);
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.warn('Failed to refresh recordings after evaluation', error);
}
notifications.show({ title: 'Evaluated', message: 'Checked for episodes' });
}}>Evaluate Now</Button>
<Button size="xs" variant="light" color="orange" onClick={async () => {
await API.bulkRemoveSeriesRecordings({ tvg_id: r.tvg_id, title: r.title, scope: 'title' });
try {
await API.deleteSeriesRule(r.tvg_id);
} catch (error) {
console.warn('Failed to delete series rule', error);
}
try {
await useChannelsStore.getState().fetchRecordings();
} catch (error) {
console.warn('Failed to refresh recordings after removing series', error);
}
const updated = await API.listSeriesRules();
setRules(updated);
}}>Remove this series (scheduled)</Button>
</Group>
</Flex>
))}
</Stack>
</Modal>
)}
</Box>
);
}