From 00b8119b811a39fa448445c71af3ec2fbc8105db Mon Sep 17 00:00:00 2001 From: Jim McBride Date: Sun, 21 Sep 2025 01:25:29 -0500 Subject: [PATCH] Revert "Virtualize TV guide rendering" This reverts commit db024130be7eb7d07bc5f74cf8127128f2ce8438. --- frontend/src/pages/Guide.jsx | 1331 ++++++++++++++-------------------- 1 file changed, 561 insertions(+), 770 deletions(-) diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 644bc6ea..7671fb57 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -1,12 +1,5 @@ // frontend/src/pages/Guide.js -import React, { - useMemo, - useState, - useEffect, - useRef, - useCallback, - useContext, -} from 'react'; +import React, { useMemo, useState, useEffect, useRef } from 'react'; import dayjs from 'dayjs'; import API from '../api'; import useChannelsStore from '../store/channels'; @@ -30,13 +23,12 @@ 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 @@ -46,243 +38,8 @@ 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); @@ -302,6 +59,7 @@ 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 @@ -313,13 +71,6 @@ 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); @@ -329,14 +80,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 - const receivedCount = Array.isArray(fetched) ? fetched.length : 0; - console.log(`Received ${receivedCount} programs`); + console.log(`Received ${fetched.length} programs`); // Include ALL channels, sorted by channel number - don't filter by EPG data const sortedChannels = Object.values(channels).sort( @@ -346,21 +97,10 @@ 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(processedPrograms); + setPrograms(fetched); + setLoading(false); }; fetchPrograms(); @@ -412,84 +152,6 @@ 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'); @@ -518,10 +180,22 @@ 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 = useCallback((time) => { + const formatDayLabel = (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'); @@ -537,7 +211,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(() => { @@ -564,7 +238,7 @@ export default function TVChannelGuide({ startDate, endDate }) { current = current.add(1, 'hour'); } return hours; - }, [start, end, formatDayLabel]); + }, [start, end]); // Scroll to the nearest half-hour mark ONLY on initial load useEffect(() => { @@ -608,229 +282,179 @@ 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 - const findChannelByTvgId = useCallback( - (tvgId) => { - const channelId = channelIdByTvgId.get(String(tvgId)); - return channelId ? channelById[channelId] : undefined; - }, - [channelById, channelIdByTvgId] - ); + function findChannelByTvgId(tvgId) { + return guideChannels.find( + (ch) => + tvgsById[ch.epg_data_id]?.tvg_id === tvgId || + (!ch.epg_data_id && ch.uuid === tvgId) + ); + } - 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 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 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, + 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 }, }); + 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 (error) { - console.warn('Failed to refresh recordings after saving series rule', error); + } catch (e) { + console.warn('Failed to refresh recordings after saving series rule', e); } - notifications.show({ - title: mode === 'new' ? 'Record new episodes' : 'Record all episodes', - }); - }, []); + notifications.show({ title: mode === 'new' ? 'Record new episodes' : 'Record all episodes' }); + }; - const openRules = useCallback(async () => { + const openRules = async () => { setRulesOpen(true); try { const r = await API.listSeriesRules(); setRules(r); - } catch (error) { - console.warn('Failed to fetch series rules', error); + } catch (e) { + // handled by API } - }, []); + }; + + 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); - 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}`; - } + 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}`; + } - showVideo(vidUrl); - }, - [env_mode, findChannelByTvgId, showVideo] - ); + showVideo(vidUrl); + } // Function to handle logo click to play channel - const handleLogoClick = useCallback( - (channel, event) => { - event.stopPropagation(); + function handleLogoClick(channel, event) { + // Prevent event from bubbling up + event.stopPropagation(); - let vidUrl = `/proxy/ts/stream/${channel.uuid}`; - if (env_mode === 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } + // 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}`; + } - showVideo(vidUrl); - }, - [env_mode, showVideo] - ); + // Use the existing showVideo function + showVideo(vidUrl); + } // On program click, toggle the expanded state - const handleProgramClick = useCallback( - (program, event) => { - event.stopPropagation(); + function handleProgramClick(program, event) { + // Prevent event from bubbling up to parent elements + event.stopPropagation(); - const programStart = dayjs(program.start_time); - const startOffsetMinutes = programStart.diff(start, 'minute'); - const leftPx = - (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; + // 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 desiredScrollPosition = Math.max(0, leftPx - 20); + // Calculate desired scroll position (account for channel column width) + const desiredScrollPosition = Math.max(0, leftPx - 20); // 20px buffer - if (expandedProgramId === program.id) { - setExpandedProgramId(null); - setRecordingForProgram(null); - return; - } + // If already expanded, collapse it + if (expandedProgramId === program.id) { + setExpandedProgramId(null); + setRecordingForProgram(null); + return; + } - setExpandedProgramId(program.id); + // Otherwise expand this program + 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', - }); + // 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; } } - }, - [expandedProgramId, guideRef, recordingsByProgramId, start, timelineRef] - ); + 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', + }); + } + } + } // Close the expanded program when clicking elsewhere - const handleClickOutside = useCallback(() => { + const handleClickOutside = () => { if (expandedProgramId) { setExpandedProgramId(null); setRecordingForProgram(null); } - }, [expandedProgramId]); + }; // Function to scroll to current time - matches initial loading position - const scrollToNow = useCallback(() => { + const scrollToNow = () => { 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') @@ -842,47 +466,60 @@ export default function TVChannelGuide({ startDate, endDate }) { const scrollPos = Math.max(scrollPosition, 0); guideRef.current.scrollLeft = scrollPos; - timelineRef.current.scrollLeft = scrollPos; + timelineRef.current.scrollLeft = scrollPos; // Sync timeline scroll } - }, [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]); + 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; + } + }; // 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; + 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 timelineRef.current.scrollLeft += - event.deltaY > 0 ? scrollAmount : -scrollAmount; - handleTimelineScroll(); - }, - [handleTimelineScroll] - ); + e.deltaY > 0 ? scrollAmount : -scrollAmount; + + // Sync the main content scroll position + if (guideRef.current) { + guideRef.current.scrollLeft = timelineRef.current.scrollLeft; + } + } + }; // Function to handle timeline time clicks with 15-minute snapping - const handleTimeClick = useCallback( - (clickedTime, event) => { - if (!timelineRef.current || !guideRef.current) return; - + const handleTimeClick = (clickedTime, event) => { + if (timelineRef.current && guideRef.current) { + // Calculate where in the hour block the click happened const hourBlockElement = event.currentTarget; const rect = hourBlockElement.getBoundingClientRect(); - const clickPositionX = event.clientX - rect.left; - const percentageAcross = clickPositionX / rect.width; + const clickPositionX = event.clientX - rect.left; // Position within the hour block + const percentageAcross = clickPositionX / rect.width; // 0 to 1 value + // 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; @@ -893,86 +530,109 @@ 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 - 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; + 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 gapSize = 2; - const widthPx = - (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - - gapSize * 2; + // Calculate width with a small gap (2px on each side) + 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); + // 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; + } } + return null; + }); - return ( - 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 && ( - - )} + No channels match your filters + + + )} + @@ -1553,54 +1388,22 @@ export default function TVChannelGuide({ startDate, endDate }) { {recordingForProgram && ( <> )} {existingRuleMode && ( - + )} @@ -1632,25 +1435,13 @@ export default function TVChannelGuide({ startDate, endDate }) {