From db024130be7eb7d07bc5f74cf8127128f2ce8438 Mon Sep 17 00:00:00 2001 From: Jim McBride Date: Sun, 21 Sep 2025 01:02:32 -0500 Subject: [PATCH] Virtualize TV guide rendering --- frontend/src/pages/Guide.jsx | 1331 ++++++++++++++++++++-------------- 1 file changed, 770 insertions(+), 561 deletions(-) diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 7671fb57..644bc6ea 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -1,5 +1,12 @@ // frontend/src/pages/Guide.js -import React, { useMemo, useState, useEffect, useRef } from 'react'; +import React, { + useMemo, + useState, + useEffect, + useRef, + useCallback, + useContext, +} from 'react'; import dayjs from 'dayjs'; import API from '../api'; import useChannelsStore from '../store/channels'; @@ -23,12 +30,13 @@ import { Transition, Modal, Stack, - useMantineTheme, } 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 @@ -38,8 +46,243 @@ 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 ( +
+ {nowPosition >= 0 && ( + + )} + {children} +
+ ); +}); +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 ( +
+ + handleLogoClick(channel, event)} + onMouseEnter={() => setHoveredChannelId(channel.id)} + onMouseLeave={() => setHoveredChannelId(null)} + > + {hoveredChannelId === channel.id && ( + + + + )} + + + + {channel.name} + + + + {channel.channel_number || '-'} + + + + + + {channelPrograms.length > 0 ? ( + channelPrograms.map((program) => ( +
+ {renderProgram(program, start)} +
+ )) + ) : ( + <> + {Array.from({ length: Math.ceil(hourTimeline.length / 2) }).map( + (_, placeholderIndex) => ( + + No program data + + ) + )} + + )} +
+
+
+ ); +}); +GuideRow.displayName = 'GuideRow'; + export default function TVChannelGuide({ startDate, endDate }) { - const theme = useMantineTheme(); const channels = useChannelsStore((s) => s.channels); const recordings = useChannelsStore((s) => s.recordings); const channelGroups = useChannelsStore((s) => s.channelGroups); @@ -59,7 +302,6 @@ export default function TVChannelGuide({ startDate, endDate }) { const [existingRuleMode, setExistingRuleMode] = useState(null); const [rulesOpen, setRulesOpen] = useState(false); const [rules, setRules] = useState([]); - const [loading, setLoading] = useState(true); const [initialScrollComplete, setInitialScrollComplete] = useState(false); // New filter states @@ -71,6 +313,13 @@ export default function TVChannelGuide({ startDate, endDate }) { 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); @@ -80,14 +329,14 @@ export default function TVChannelGuide({ startDate, endDate }) { if (!Object.keys(channels).length === 0) { console.warn('No channels provided or empty channels array'); notifications.show({ title: 'No channels available', color: 'red.5' }); - setLoading(false); return; } const fetchPrograms = async () => { console.log('Fetching program grid...'); const fetched = await API.getGrid(); // GETs your EPG grid - console.log(`Received ${fetched.length} programs`); + 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( @@ -97,10 +346,21 @@ export default function TVChannelGuide({ startDate, endDate }) { 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(fetched); - setLoading(false); + setPrograms(processedPrograms); }; fetchPrograms(); @@ -152,6 +412,84 @@ export default function TVChannelGuide({ startDate, endDate }) { 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'); @@ -180,22 +518,10 @@ export default function TVChannelGuide({ startDate, endDate }) { ? latestProgramEnd : defaultEnd; - // Time increments in 15-min steps (for placing programs) - const programTimeline = useMemo(() => { - const times = []; - let current = start; - while (current.isBefore(end)) { - times.push(current); - current = current.add(MINUTE_INCREMENT, 'minute'); - } - return times; - }, [start, end]); - // Format day label using relative terms when possible (Today, Tomorrow, etc) - const formatDayLabel = (time) => { + const formatDayLabel = useCallback((time) => { const today = dayjs().startOf('day'); const tomorrow = today.add(1, 'day'); - const dayAfterTomorrow = today.add(2, 'day'); const weekLater = today.add(7, 'day'); const day = time.startOf('day'); @@ -211,7 +537,7 @@ export default function TVChannelGuide({ startDate, endDate }) { // Beyond a week, show month and day return time.format(dateFormat); } - }; + }, [dateFormat]); // Hourly marks with day labels const hourTimeline = useMemo(() => { @@ -238,7 +564,7 @@ export default function TVChannelGuide({ startDate, endDate }) { current = current.add(1, 'hour'); } return hours; - }, [start, end]); + }, [start, end, formatDayLabel]); // Scroll to the nearest half-hour mark ONLY on initial load useEffect(() => { @@ -282,179 +608,229 @@ export default function TVChannelGuide({ startDate, endDate }) { 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 - function findChannelByTvgId(tvgId) { - return guideChannels.find( - (ch) => - tvgsById[ch.epg_data_id]?.tvg_id === tvgId || - (!ch.epg_data_id && ch.uuid === tvgId) - ); - } + const findChannelByTvgId = useCallback( + (tvgId) => { + const channelId = channelIdByTvgId.get(String(tvgId)); + return channelId ? channelById[channelId] : undefined; + }, + [channelById, channelIdByTvgId] + ); - const openRecordChoice = async (program) => { - setRecordChoiceProgram(program); - setRecordChoiceOpen(true); - try { - const rules = await API.listSeriesRules(); - // Only treat as existing if the rule matches this specific show's title (or has no title constraint) - 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 {} - // Also detect if this program already has a scheduled recording - try { - const rec = (recordings || []).find((r) => r?.custom_properties?.program?.id == program.id); - setRecordingForProgram(rec || null); - } catch {} - }; + 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 = async (program) => { - const channel = findChannelByTvgId(program.tvg_id); - await API.createRecording({ - channel: `${channel.id}`, - start_time: program.start_time, - end_time: program.end_time, - custom_properties: { program }, + 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, }); - notifications.show({ title: 'Recording scheduled' }); - }; - - const saveSeriesRule = async (program, mode) => { - await API.createSeriesRule({ tvg_id: program.tvg_id, mode, title: program.title }); await API.evaluateSeriesRules(program.tvg_id); - // Refresh recordings so icons and DVR reflect new schedules try { await useChannelsStore.getState().fetchRecordings(); - } catch (e) { - console.warn('Failed to refresh recordings after saving series rule', e); + } catch (error) { + console.warn('Failed to refresh recordings after saving series rule', error); } - notifications.show({ title: mode === 'new' ? 'Record new episodes' : 'Record all episodes' }); - }; + notifications.show({ + title: mode === 'new' ? 'Record new episodes' : 'Record all episodes', + }); + }, []); - const openRules = async () => { + const openRules = useCallback(async () => { setRulesOpen(true); try { const r = await API.listSeriesRules(); setRules(r); - } catch (e) { - // handled by API + } catch (error) { + console.warn('Failed to fetch series rules', error); } - }; - - const deleteAllUpcoming = async () => { - const ok = window.confirm('Delete ALL upcoming recordings?'); - if (!ok) return; - await API.deleteAllUpcomingRecordings(); - try { await useChannelsStore.getState().fetchRecordings(); } catch {} - }; + }, []); // The “Watch Now” click => show floating video const showVideo = useVideoStore((s) => s.showVideo); - function handleWatchStream(program) { - const matched = findChannelByTvgId(program.tvg_id); - if (!matched) { - console.warn(`No channel found for tvg_id=${program.tvg_id}`); - return; - } - // Build a playable stream URL for that channel - let vidUrl = `/proxy/ts/stream/${matched.uuid}`; - if (env_mode == 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } + 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); - } + showVideo(vidUrl); + }, + [env_mode, findChannelByTvgId, showVideo] + ); // Function to handle logo click to play channel - function handleLogoClick(channel, event) { - // Prevent event from bubbling up - event.stopPropagation(); + const handleLogoClick = useCallback( + (channel, event) => { + event.stopPropagation(); - // Build a playable stream URL for the channel - let vidUrl = `/proxy/ts/stream/${channel.uuid}`; - if (env_mode === 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } + let vidUrl = `/proxy/ts/stream/${channel.uuid}`; + if (env_mode === 'dev') { + vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; + } - // Use the existing showVideo function - showVideo(vidUrl); - } + showVideo(vidUrl); + }, + [env_mode, showVideo] + ); // On program click, toggle the expanded state - function handleProgramClick(program, event) { - // Prevent event from bubbling up to parent elements - event.stopPropagation(); + const handleProgramClick = useCallback( + (program, event) => { + event.stopPropagation(); - // Get the program's start time and calculate its position - const programStart = dayjs(program.start_time); - const startOffsetMinutes = programStart.diff(start, 'minute'); - const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; + const programStart = dayjs(program.start_time); + const startOffsetMinutes = programStart.diff(start, 'minute'); + const leftPx = + (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - // Calculate desired scroll position (account for channel column width) - const desiredScrollPosition = Math.max(0, leftPx - 20); // 20px buffer + const desiredScrollPosition = Math.max(0, leftPx - 20); - // If already expanded, collapse it - if (expandedProgramId === program.id) { - setExpandedProgramId(null); - setRecordingForProgram(null); - return; - } + if (expandedProgramId === program.id) { + setExpandedProgramId(null); + setRecordingForProgram(null); + return; + } - // Otherwise expand this program - setExpandedProgramId(program.id); + setExpandedProgramId(program.id); - // Check if this program has a recording - const programRecording = recordings.find((recording) => { - if (recording.custom_properties) { - const customProps = recording.custom_properties || {}; - if (customProps.program && customProps.program.id == program.id) { - return true; + 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', + }); } } - return false; - }); - - setRecordingForProgram(programRecording); - - // Scroll to show the start of the program if it's not already fully visible - if (guideRef.current && timelineRef.current) { - const currentScrollPosition = guideRef.current.scrollLeft; - - // Check if we need to scroll (if program start is before current view or too close to edge) - if ( - desiredScrollPosition < currentScrollPosition || - leftPx - currentScrollPosition < 100 - ) { - // 100px from left edge - - // Smooth scroll to the program's start - guideRef.current.scrollTo({ - left: desiredScrollPosition, - behavior: 'smooth', - }); - - // Also sync the timeline scroll - timelineRef.current.scrollTo({ - left: desiredScrollPosition, - behavior: 'smooth', - }); - } - } - } + }, + [expandedProgramId, guideRef, recordingsByProgramId, start, timelineRef] + ); // Close the expanded program when clicking elsewhere - const handleClickOutside = () => { + const handleClickOutside = useCallback(() => { if (expandedProgramId) { setExpandedProgramId(null); setRecordingForProgram(null); } - }; + }, [expandedProgramId]); // Function to scroll to current time - matches initial loading position - const scrollToNow = () => { + const scrollToNow = useCallback(() => { if (guideRef.current && timelineRef.current && nowPosition >= 0) { - // Round the current time to the nearest half-hour mark const roundedNow = now.minute() < 30 ? now.startOf('hour') @@ -466,60 +842,47 @@ export default function TVChannelGuide({ startDate, endDate }) { const scrollPos = Math.max(scrollPosition, 0); guideRef.current.scrollLeft = scrollPos; - timelineRef.current.scrollLeft = scrollPos; // Sync timeline scroll + timelineRef.current.scrollLeft = scrollPos; } - }; + }, [guideRef, now, nowPosition, start, timelineRef]); // Sync scrolling between timeline and main content - const handleTimelineScroll = () => { - if (timelineRef.current && guideRef.current) { - guideRef.current.scrollLeft = timelineRef.current.scrollLeft; - } - }; - - // Sync scrolling between main content and timeline - const handleGuideScroll = () => { - if (guideRef.current && timelineRef.current) { - timelineRef.current.scrollLeft = guideRef.current.scrollLeft; - } - }; + 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 = (e) => { - if (timelineRef.current) { - // Prevent the default vertical scroll - e.preventDefault(); - - // Determine scroll amount (with shift key for faster scrolling) - const scrollAmount = e.shiftKey ? 250 : 125; - - // Scroll horizontally based on wheel direction + const handleTimelineWheel = useCallback( + (event) => { + if (!timelineRef.current) return; + event.preventDefault(); + const scrollAmount = event.shiftKey ? 250 : 125; timelineRef.current.scrollLeft += - e.deltaY > 0 ? scrollAmount : -scrollAmount; - - // Sync the main content scroll position - if (guideRef.current) { - guideRef.current.scrollLeft = timelineRef.current.scrollLeft; - } - } - }; + event.deltaY > 0 ? scrollAmount : -scrollAmount; + handleTimelineScroll(); + }, + [handleTimelineScroll] + ); // Function to handle timeline time clicks with 15-minute snapping - const handleTimeClick = (clickedTime, event) => { - if (timelineRef.current && guideRef.current) { - // Calculate where in the hour block the click happened + 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; // Position within the hour block - const percentageAcross = clickPositionX / rect.width; // 0 to 1 value + const clickPositionX = event.clientX - rect.left; + const percentageAcross = clickPositionX / rect.width; - // Calculate the minute within the hour based on click position const minuteWithinHour = Math.floor(percentageAcross * 60); - // Create a new time object with the calculated minute - const exactTime = clickedTime.minute(minuteWithinHour); - - // Determine the nearest 15-minute interval (0, 15, 30, 45) let snappedMinute; if (minuteWithinHour < 7.5) { snappedMinute = 0; @@ -530,109 +893,86 @@ export default function TVChannelGuide({ startDate, endDate }) { } else if (minuteWithinHour < 52.5) { snappedMinute = 45; } else { - // If we're past 52.5 minutes, snap to the next hour snappedMinute = 0; clickedTime = clickedTime.add(1, 'hour'); } - // Create the snapped time const snappedTime = clickedTime.minute(snappedMinute); - - // Calculate the offset from the start of the timeline to the snapped time const snappedOffset = snappedTime.diff(start, 'minute'); - - // Convert to pixels const scrollPosition = (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - // Scroll both containers to the snapped position timelineRef.current.scrollLeft = scrollPosition; guideRef.current.scrollLeft = scrollPosition; - } - }; + }, + [start] + ); + // Renders each program block - function renderProgram(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 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; - // Calculate width with a small gap (2px on each side) - const gapSize = 2; - const widthPx = - (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - gapSize * 2; + const gapSize = 2; + const widthPx = + (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - + gapSize * 2; - // Check if we have a recording for this program - const recording = recordings.find((recording) => { - if (recording.custom_properties) { - const customProps = recording.custom_properties || {}; - if (customProps.program && customProps.program.id == program.id) { - return recording; - } + 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 null; - }); - // Highlight if currently live - const isLive = now.isAfter(programStart) && now.isBefore(programEnd); - - // Determine if the program has ended - const isPast = now.isAfter(programEnd); // Check if this program is expanded - const isExpanded = expandedProgramId === program.id; - - // Set the height based on expanded state - const rowHeight = isExpanded ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT; - - // Determine expanded width - if program is short, ensure it has a minimum expanded width - // This will allow it to overlap programs to the right - const MIN_EXPANDED_WIDTH = 450; // Minimum width in pixels when expanded - const expandedWidthPx = Math.max(widthPx, MIN_EXPANDED_WIDTH); - - // Calculate text positioning for long programs that start before the visible area - const currentScrollLeft = guideRef.current?.scrollLeft || 0; - const programStartInView = leftPx + gapSize; - const programEndInView = leftPx + gapSize + widthPx; - const viewportLeft = currentScrollLeft; - - // Check if program starts before viewport but extends into it - const startsBeforeView = programStartInView < viewportLeft; - const extendsIntoView = programEndInView > viewportLeft; - - // Calculate text offset to position it at the visible portion - let textOffsetLeft = 0; - if (startsBeforeView && extendsIntoView) { - // Position text at the start of the visible area, but not beyond the program end - const visibleStart = Math.max(viewportLeft - programStartInView, 0); - const maxOffset = widthPx - 200; // Leave some space for text, don't push to very end - textOffsetLeft = Math.min(visibleStart, maxOffset); - } - - return ( - handleProgramClick(program, e)} - > - handleProgramClick(program, event)} + > + - {programStart.format(timeFormat)} -{' '} - {programEnd.format(timeFormat)} + {programStart.format(timeFormat)} - {programEnd.format(timeFormat)} - {' '} - {/* Description is always shown but expands when row is expanded */} + {program.description && ( )} - {/* Expanded content */} {isExpanded && ( - {/* Always show Record for not-past; it opens options (schedule/remove) */} {!isPast && ( - - )} - + {GuideRow} + + + ) : ( + + No channels match your filters + + + )} @@ -1388,22 +1553,54 @@ export default function TVChannelGuide({ startDate, endDate }) { {recordingForProgram && ( <> )} {existingRuleMode && ( - + )} @@ -1435,13 +1632,25 @@ export default function TVChannelGuide({ startDate, endDate }) {