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.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 && (
}
variant="filled"
color="red"
size="xs"
- onClick={(e) => {
- e.stopPropagation();
+ onClick={(event) => {
+ event.stopPropagation();
openRecordChoice(program);
}}
>
@@ -747,8 +1083,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
variant="filled"
color="blue"
size="xs"
- onClick={(e) => {
- e.stopPropagation();
+ onClick={(event) => {
+ event.stopPropagation();
handleWatchStream(program);
}}
>
@@ -758,10 +1094,52 @@ export default function TVChannelGuide({ startDate, endDate }) {
)}
-
-
- );
- }
+
+
+ );
+ },
+ [
+ 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(() => {
@@ -1108,260 +1486,47 @@ export default function TVChannelGuide({ startDate, endDate }) {
{/* Main scrollable container for program content */}
- {/* Content wrapper with min-width to ensure scroll range */}
-
- {/* Now line - positioned absolutely within content */}
- {nowPosition >= 0 && (
-
- )}
-
- {/* Channel rows with logos and programs */}
- {filteredChannels.length > 0 ? (
- filteredChannels.map((channel) => {
- const channelPrograms = programs.filter(
- (p) =>
- (channel.epg_data_id &&
- p.tvg_id === tvgsById[channel.epg_data_id].tvg_id) ||
- (!channel.epg_data_id && p.tvg_id === channel.uuid)
- );
- // Check if any program in this channel is expanded
- const hasExpandedProgram = channelPrograms.some(
- (prog) => prog.id === expandedProgramId
- );
- const rowHeight = hasExpandedProgram
- ? EXPANDED_PROGRAM_HEIGHT
- : PROGRAM_HEIGHT;
-
- return (
-
- {/* Channel logo - sticky horizontally */}
- handleLogoClick(channel, e)}
- onMouseEnter={() => setHoveredChannelId(channel.id)}
- onMouseLeave={() => setHoveredChannelId(null)}
- >
- {/* Play icon overlay - visible on hover (moved outside to cover entire box) */}
- {hoveredChannelId === channel.id && (
-
- {' '}
- {/* Changed from Video to Play and increased size */}
-
- )}
-
- {/* Logo content - restructured for better positioning */}
-
- {/* Logo container with padding */}
-
-
-
-
- {/* Channel number - fixed position at bottom with consistent height */}
-
- {channel.channel_number || '-'}
-
-
-
-
- {/* Programs for this channel */}
-
- {channelPrograms.length > 0 ? (
- channelPrograms.map((program) => (
-
- {renderProgram(program, start)}
-
- ))
- ) : (
- // Simple placeholder for channels with no program data - 2 hour blocks
- <>
- {/* Generate repeating placeholder blocks every 2 hours across the timeline */}
- {Array.from({
- length: Math.ceil(hourTimeline.length / 2),
- }).map((_, index) => (
-
-
-
- No Program Information Available
-
-
-
- ))}
- >
- )}
-
-
- );
- })
- ) : (
- 0 ? (
+
+
- No channels match your filters
-
-
- )}
-
+ {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 }) {