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.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 && (
}
variant="filled"
color="red"
size="xs"
- onClick={(event) => {
- event.stopPropagation();
+ onClick={(e) => {
+ e.stopPropagation();
openRecordChoice(program);
}}
>
@@ -1083,8 +747,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
variant="filled"
color="blue"
size="xs"
- onClick={(event) => {
- event.stopPropagation();
+ onClick={(e) => {
+ e.stopPropagation();
handleWatchStream(program);
}}
>
@@ -1094,52 +758,10 @@ 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(() => {
@@ -1486,47 +1108,260 @@ export default function TVChannelGuide({ startDate, endDate }) {
{/* Main scrollable container for program content */}
- {filteredChannels.length > 0 ? (
-
-
+ {/* 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
+
+
+
+ ))}
+ >
+ )}
+
+
+ );
+ })
+ ) : (
+
- {GuideRow}
-
-
- ) : (
-
- No channels match your filters
-
-
- )}
+ 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 }) {