Dispatcharr/frontend/src/pages/Guide.jsx
2026-05-25 18:08:41 -07:00

1520 lines
45 KiB
JavaScript

// frontend/src/pages/Guide.js
import React, {
useMemo,
useState,
useEffect,
useRef,
useCallback,
Suspense,
} from 'react';
import useChannelsStore from '../store/channels';
import useLogosStore from '../store/logos';
import useVideoStore from '../store/useVideoStore';
import useSettingsStore from '../store/settings';
import {
ActionIcon,
Badge,
Box,
Button,
Flex,
Group,
LoadingOverlay,
Paper,
Select,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { Clock, Search, X } from 'lucide-react';
import './guide.css';
import useEPGsStore from '../store/epgs';
import { useElementSize } from '@mantine/hooks';
import { VariableSizeList } from 'react-window';
import {
buildChannelIdMap,
calculateEarliestProgramStart,
calculateEnd,
calculateHourTimeline,
calculateLatestProgramEnd,
calculateNowPosition,
calculateScrollPosition,
calculateScrollPositionByTimeClick,
calculateStart,
CHANNEL_WIDTH,
computeRowHeights,
createRecording,
createSeriesRule,
fetchPrograms,
fetchRules,
filterGuideChannels,
formatSeasonEpisode,
formatTime,
getProfileOptions,
getRuleByProgram,
HOUR_WIDTH,
mapProgramsByChannel,
mapRecordingsByProgramId,
matchChannelByTvgId,
MINUTE_BLOCK_WIDTH,
MINUTE_INCREMENT,
PROGRAM_GAP_PX,
PROGRAM_HEIGHT,
PX_PER_MS,
calcProgressPct,
sortChannels,
evaluateSeriesRulesByTvgId,
} from '../utils/guideUtils';
import API from '../api';
import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js';
import {
add,
convertToMs,
format,
getNow,
initializeTime,
startOfDay,
useDateTimeFormat,
} from '../utils/dateTimeUtils.js';
import GuideRow from '../components/GuideRow.jsx';
import HourTimeline from '../components/HourTimeline';
const ProgramRecordingModal = React.lazy(
() => import('../components/forms/ProgramRecordingModal')
);
const SeriesRecordingModal = React.lazy(
() => import('../components/forms/SeriesRecordingModal')
);
const ProgramDetailModal = React.lazy(
() => import('../components/ProgramDetailModal')
);
import { showNotification } from '../utils/notificationUtils.js';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
export default function TVChannelGuide({ startDate, endDate }) {
const [isChannelsLoading, setIsChannelsLoading] = useState(false);
const [allowAllGroups, setAllowAllGroups] = useState(true);
const MAX_ALL_CHANNELS = 99999;
const recordings = useChannelsStore((s) => s.recordings);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const profiles = useChannelsStore((s) => s.profiles);
const [isProgramsLoading, setIsProgramsLoading] = useState(true);
const logos = useLogosStore((s) => s.logos);
const tvgsById = useEPGsStore((s) => s.tvgsById);
const epgs = useEPGsStore((s) => s.epgs);
const [programs, setPrograms] = useState([]);
const [guideChannels, setGuideChannels] = useState([]);
const [now, setNow] = useState(getNow());
const [selectedProgram, setSelectedProgram] = useState(null);
const [selectedChannel, setSelectedChannel] = useState(null);
const [recordingForProgram, setRecordingForProgram] = useState(null);
const [recordChoiceOpen, setRecordChoiceOpen] = useState(false);
const [recordChoiceProgram, setRecordChoiceProgram] = useState(null);
const [recordChoiceChannel, setRecordChoiceChannel] = useState(null);
const [existingRuleMode, setExistingRuleMode] = useState(null);
const [existingRule, setExistingRule] = useState(null);
const [rulesOpen, setRulesOpen] = useState(false);
const [rules, setRules] = useState([]);
const [initialScrollComplete, setInitialScrollComplete] = useState(false);
// New filter states
const [searchQuery, setSearchQuery] = useState('');
const [selectedGroupId, setSelectedGroupId] = useState('all');
const [selectedProfileId, setSelectedProfileId] = useState('all');
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const guideRef = useRef(null);
const timelineRef = useRef(null); // New ref for timeline scrolling
const listRef = useRef(null);
const tvGuideRef = useRef(null); // Ref for the main tv-guide wrapper
const isSyncingScroll = useRef(false);
const guideScrollLeftRef = useRef(0);
const nowLineRef = useRef(null);
const [settledScrollLeft, setSettledScrollLeft] = useState(0);
const scrollDebounceRef = useRef(null);
const {
ref: guideContainerRef,
width: guideWidth,
height: guideHeight,
} = useElementSize();
// Decide if 'All Channel Groups' should be enabled (based on total channel count)
useEffect(() => {
let cancelled = false;
(async () => {
try {
const params = new URLSearchParams();
const ids = await API.getAllChannelIds(params);
if (cancelled) {
return;
}
const total = Array.isArray(ids)
? ids.length
: (ids?.length ?? ids?.count ?? 0);
setAllowAllGroups(total <= MAX_ALL_CHANNELS);
} catch (e) {
// If we cannot determine, keep current default (true)
console.error('Failed to get total channel IDs', e);
}
})();
return () => {
cancelled = true;
};
}, []);
// If 'All' is not allowed, default to the first available group
useEffect(() => {
if (!allowAllGroups && selectedGroupId === 'all') {
const firstGroup = Object.values(channelGroups).find(
(g) => g?.hasChannels
);
if (firstGroup) {
setSelectedGroupId(String(firstGroup.id));
}
}
}, [allowAllGroups, channelGroups, selectedGroupId]);
// Fetch channels on demand based on filters
useEffect(() => {
let cancelled = false;
const loadGuideData = async () => {
try {
setIsChannelsLoading(true);
const params = new URLSearchParams();
// Group filter by name, if not 'all'
if (selectedGroupId !== 'all') {
const group = channelGroups[Number(selectedGroupId)];
if (group?.name) params.set('channel_group', group.name);
} else if (!allowAllGroups) {
// If 'all' is not allowed, fall back to first available group
const firstGroup = Object.values(channelGroups).find(
(g) => g?.hasChannels
);
if (firstGroup?.name) params.set('channel_group', firstGroup.name);
}
// Profile filter
if (selectedProfileId && selectedProfileId !== 'all') {
params.set('channel_profile_id', String(selectedProfileId));
}
// Search filter
if (searchQuery && searchQuery.trim() !== '') {
params.set('search', searchQuery.trim());
}
// Fetch channels and programs in parallel — programs don't depend
// on channels so there's no reason to wait for one before the other.
const [channels, programData] = await Promise.all([
API.getChannelsSummary(params),
fetchPrograms(),
]);
if (cancelled) return;
setGuideChannels(sortChannels(channels || []));
setPrograms(programData);
} catch (e) {
if (cancelled) return;
console.error('Failed to load guide data:', e);
} finally {
if (!cancelled) {
setIsChannelsLoading(false);
setIsProgramsLoading(false);
}
}
};
loadGuideData();
return () => {
cancelled = true;
};
}, [
allowAllGroups,
channelGroups,
searchQuery,
selectedGroupId,
selectedProfileId,
]);
// Apply filters when search, group, or profile changes
const filteredChannels = useMemo(() => {
if (!guideChannels.length) return [];
return filterGuideChannels(
guideChannels,
searchQuery,
selectedGroupId,
selectedProfileId,
profiles
);
}, [
searchQuery,
selectedGroupId,
selectedProfileId,
guideChannels,
profiles,
]);
// Use start/end from props or default to "today at midnight" +24h
const defaultStart = initializeTime(startDate || startOfDay(getNow()));
const defaultEnd = endDate
? initializeTime(endDate)
: add(defaultStart, 24, 'hour');
// Expand timeline if needed based on actual earliest/ latest program
const earliestProgramStart = useMemo(
() => calculateEarliestProgramStart(programs, defaultStart),
[programs, defaultStart]
);
const latestProgramEnd = useMemo(
() => calculateLatestProgramEnd(programs, defaultEnd),
[programs, defaultEnd]
);
const start = calculateStart(earliestProgramStart, defaultStart);
const end = calculateEnd(latestProgramEnd, defaultEnd);
// Pre-compute timeline origin in ms for horizontal culling in GuideRow
const timelineStartMs = useMemo(() => convertToMs(start), [start]);
const channelIdByTvgId = useMemo(
() => buildChannelIdMap(guideChannels, tvgsById, epgs),
[guideChannels, tvgsById, epgs]
);
// Local map of channel id -> channel object for quick lookup
const channelById = useMemo(() => {
const map = new Map();
for (const ch of guideChannels) {
if (ch && ch.id !== undefined && ch.id !== null) {
map.set(ch.id, ch);
}
}
return map;
}, [guideChannels]);
const programsByChannelId = useMemo(
() => mapProgramsByChannel(programs, channelIdByTvgId),
[programs, channelIdByTvgId, now]
);
const recordingsByProgramId = useMemo(
() => mapRecordingsByProgramId(recordings),
[recordings]
);
const rowHeights = useMemo(
() => computeRowHeights(filteredChannels),
[filteredChannels]
);
const getItemSize = useCallback(
(index) => rowHeights[index] ?? PROGRAM_HEIGHT,
[rowHeights]
);
const { timeFormat, dateFormat } = useDateTimeFormat();
// Format day label using relative terms when possible (Today, Tomorrow, etc)
const formatDayLabel = useCallback(
(time) => formatTime(time, dateFormat),
[dateFormat]
);
// Hourly marks with day labels
const hourTimeline = useMemo(
() => calculateHourTimeline(start, end, formatDayLabel),
[start, end, formatDayLabel]
);
useEffect(() => {
const node = guideRef.current;
if (!node) return undefined;
const handleScroll = () => {
if (isSyncingScroll.current) {
return;
}
const { scrollLeft } = node;
// Always sync if timeline is out of sync, even if ref matches
if (
timelineRef.current &&
timelineRef.current.scrollLeft !== scrollLeft
) {
isSyncingScroll.current = true;
timelineRef.current.scrollLeft = scrollLeft;
guideScrollLeftRef.current = scrollLeft;
updateNowLine();
requestAnimationFrame(() => {
isSyncingScroll.current = false;
});
} else if (scrollLeft !== guideScrollLeftRef.current) {
// Update ref even if timeline was already synced
guideScrollLeftRef.current = scrollLeft;
updateNowLine();
}
};
node.addEventListener('scroll', handleScroll, { passive: true });
return () => {
node.removeEventListener('scroll', handleScroll);
};
}, []);
// Update "now" every 60 seconds (on a 24h guide, per-second is imperceptible)
useEffect(() => {
const interval = setInterval(() => {
setNow(getNow());
}, 60000);
return () => clearInterval(interval);
}, []);
// Recover from browser tab throttling — when the user returns to the tab,
// force-refresh "now" so programs are remapped with current time values.
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
setNow(getNow());
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () =>
document.removeEventListener('visibilitychange', handleVisibilityChange);
}, []);
// Pixel offset for the "now" vertical line
const nowPosition = useMemo(
() => calculateNowPosition(now, start, end),
[now, start, end]
);
// Keep timelineStartMs in a ref for real-time now-line calculation
const timelineStartMsRef = useRef(timelineStartMs);
timelineStartMsRef.current = timelineStartMs;
// Update the now-line DOM element directly (no React re-render)
// Uses Date.now() for sub-minute precision to stay aligned with progress bars
const updateNowLine = useCallback(() => {
if (nowLineRef.current) {
const nowPx = (Date.now() - timelineStartMsRef.current) * PX_PER_MS;
nowLineRef.current.style.left = `${Math.round(nowPx + CHANNEL_WIDTH - guideScrollLeftRef.current)}px`;
}
// Debounce horizontal culling update — fires 150ms after scrolling stops
if (scrollDebounceRef.current) {
clearTimeout(scrollDebounceRef.current);
}
scrollDebounceRef.current = setTimeout(() => {
setSettledScrollLeft(guideScrollLeftRef.current);
}, 150);
}, []);
// Advance now-line and all progress bars every second via direct DOM updates
// (no React re-renders). Using a single Date.now() per tick keeps everything in sync.
// Pixel positions are rounded to avoid sub-pixel rendering artifacts (line pulsing).
// Progress bar scaleX is adjusted to account for the card's gap offset so the
// fill edge aligns exactly with the now line on the timeline grid.
useEffect(() => {
const interval = setInterval(() => {
const nowMs = Date.now();
if (nowLineRef.current) {
const nowPx = (nowMs - timelineStartMsRef.current) * PX_PER_MS;
nowLineRef.current.style.left = `${Math.round(nowPx + CHANNEL_WIDTH - guideScrollLeftRef.current)}px`;
}
document.querySelectorAll('.guide-progress-fill').forEach((el) => {
const startMs = Number(el.dataset.startMs);
const endMs = Number(el.dataset.endMs);
const durationMs = endMs - startMs;
if (durationMs <= 0) return;
el.style.transform = `scaleX(${calcProgressPct(nowMs, startMs, durationMs)})`;
});
}, 1000);
return () => clearInterval(interval);
}, []);
// Clear debounce timeout on unmount
useEffect(() => {
return () => {
if (scrollDebounceRef.current) {
clearTimeout(scrollDebounceRef.current);
}
};
}, []);
// Sync now-line whenever nowPosition changes (every 60s)
useEffect(() => {
updateNowLine();
}, [nowPosition, updateNowLine]);
useEffect(() => {
const tvGuide = tvGuideRef.current;
if (!tvGuide) return undefined;
const handleContainerWheel = (event) => {
const guide = guideRef.current;
const timeline = timelineRef.current;
if (!guide) {
return;
}
if (event.deltaX !== 0 || (event.shiftKey && event.deltaY !== 0)) {
event.preventDefault();
event.stopPropagation();
const delta = event.deltaX !== 0 ? event.deltaX : event.deltaY;
const newScrollLeft = guide.scrollLeft + delta;
// Set both guide and timeline scroll positions
if (typeof guide.scrollTo === 'function') {
guide.scrollTo({ left: newScrollLeft, behavior: 'auto' });
} else {
guide.scrollLeft = newScrollLeft;
}
// Also sync timeline immediately
if (timeline) {
if (typeof timeline.scrollTo === 'function') {
timeline.scrollTo({ left: newScrollLeft, behavior: 'auto' });
} else {
timeline.scrollLeft = newScrollLeft;
}
}
// Update the ref to keep state in sync
guideScrollLeftRef.current = newScrollLeft;
updateNowLine();
}
};
tvGuide.addEventListener('wheel', handleContainerWheel, {
passive: false,
capture: true,
});
return () => {
tvGuide.removeEventListener('wheel', handleContainerWheel, {
capture: true,
});
};
}, []);
// Fallback: continuously monitor for any scroll changes
useEffect(() => {
let rafId = null;
let lastCheck = 0;
const checkSync = (timestamp) => {
// Throttle to check every 100ms instead of every frame
if (timestamp - lastCheck > 100) {
const guide = guideRef.current;
const timeline = timelineRef.current;
if (guide && timeline && guide.scrollLeft !== timeline.scrollLeft) {
timeline.scrollLeft = guide.scrollLeft;
guideScrollLeftRef.current = guide.scrollLeft;
updateNowLine();
}
lastCheck = timestamp;
}
rafId = requestAnimationFrame(checkSync);
};
rafId = requestAnimationFrame(checkSync);
return () => {
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
useEffect(() => {
const tvGuide = tvGuideRef.current;
if (!tvGuide) return;
let lastTouchX = null;
let isTouching = false;
let rafId = null;
let lastScrollLeft = 0;
let stableFrames = 0;
const syncScrollPositions = () => {
const guide = guideRef.current;
const timeline = timelineRef.current;
if (!guide || !timeline) return false;
const currentScroll = guide.scrollLeft;
// Check if scroll position has changed
if (currentScroll !== lastScrollLeft) {
timeline.scrollLeft = currentScroll;
guideScrollLeftRef.current = currentScroll;
updateNowLine();
lastScrollLeft = currentScroll;
stableFrames = 0;
return true; // Still scrolling
} else {
stableFrames++;
return stableFrames < 10; // Continue for 10 stable frames to catch late updates
}
};
const startPolling = () => {
if (rafId) return; // Already polling
const poll = () => {
const shouldContinue = isTouching || syncScrollPositions();
if (shouldContinue) {
rafId = requestAnimationFrame(poll);
} else {
rafId = null;
}
};
rafId = requestAnimationFrame(poll);
};
const handleTouchStart = (e) => {
if (e.touches.length === 1) {
const guide = guideRef.current;
if (guide) {
lastTouchX = e.touches[0].clientX;
lastScrollLeft = guide.scrollLeft;
isTouching = true;
stableFrames = 0;
startPolling();
}
}
};
const handleTouchMove = (e) => {
if (!isTouching || e.touches.length !== 1) return;
const guide = guideRef.current;
if (!guide) return;
const touchX = e.touches[0].clientX;
const deltaX = lastTouchX - touchX;
lastTouchX = touchX;
if (Math.abs(deltaX) > 0) {
guide.scrollLeft += deltaX;
}
};
const handleTouchEnd = () => {
isTouching = false;
lastTouchX = null;
// Polling continues until scroll stabilizes
};
tvGuide.addEventListener('touchstart', handleTouchStart, { passive: true });
tvGuide.addEventListener('touchmove', handleTouchMove, { passive: false });
tvGuide.addEventListener('touchend', handleTouchEnd, { passive: true });
tvGuide.addEventListener('touchcancel', handleTouchEnd, { passive: true });
return () => {
if (rafId) cancelAnimationFrame(rafId);
tvGuide.removeEventListener('touchstart', handleTouchStart);
tvGuide.removeEventListener('touchmove', handleTouchMove);
tvGuide.removeEventListener('touchend', handleTouchEnd);
tvGuide.removeEventListener('touchcancel', handleTouchEnd);
};
}, []);
const syncScrollLeft = useCallback((nextLeft, behavior = 'auto') => {
const guideNode = guideRef.current;
const timelineNode = timelineRef.current;
isSyncingScroll.current = true;
if (guideNode) {
if (typeof guideNode.scrollTo === 'function') {
guideNode.scrollTo({ left: nextLeft, behavior });
} else {
guideNode.scrollLeft = nextLeft;
}
}
if (timelineNode) {
if (typeof timelineNode.scrollTo === 'function') {
timelineNode.scrollTo({ left: nextLeft, behavior });
} else {
timelineNode.scrollLeft = nextLeft;
}
}
guideScrollLeftRef.current = nextLeft;
updateNowLine();
requestAnimationFrame(() => {
isSyncingScroll.current = false;
});
}, []);
// Holds the scroll position to restore after a filter-induced remount.
// null means "use the default scroll-to-now on first load".
const savedScrollLeftRef = useRef(null);
// When channels become empty (filter transition unmounts the list), save the
// current scroll position so we can restore it once new channels arrive.
// Only save if the initial scroll has already happened — otherwise the saved
// position would be 0 (the DOM default) and we'd skip the scroll-to-now.
useEffect(() => {
if (filteredChannels.length === 0) {
if (initialScrollComplete) {
savedScrollLeftRef.current = guideScrollLeftRef.current;
}
setInitialScrollComplete(false);
}
}, [filteredChannels.length, initialScrollComplete]);
// Scroll on initial load, or restore saved position after a filter transition.
// Guard with guideRef.current — the VariableSizeList outer div is null while
// unmounted, so we must wait until it remounts before calling syncScrollLeft.
useEffect(() => {
if (programs.length > 0 && !initialScrollComplete && guideRef.current) {
if (savedScrollLeftRef.current !== null) {
// Restore where the user was before the filter change
syncScrollLeft(savedScrollLeftRef.current);
savedScrollLeftRef.current = null;
} else {
// Genuine first load — scroll to current time
syncScrollLeft(calculateScrollPosition(now, start));
}
setInitialScrollComplete(true);
}
}, [
programs,
start,
now,
initialScrollComplete,
syncScrollLeft,
filteredChannels.length,
]);
const findChannelByTvgId = useCallback(
(tvgId) => matchChannelByTvgId(channelIdByTvgId, channelById, tvgId),
[channelById, channelIdByTvgId]
);
const openRecordChoice = useCallback(
async (program, channel) => {
setRecordChoiceProgram(program);
setRecordChoiceChannel(channel);
setRecordChoiceOpen(true);
try {
const rules = await fetchRules();
const rule = getRuleByProgram(rules, program);
setExistingRuleMode(rule ? rule.mode : null);
setExistingRule(rule || null);
} catch (error) {
console.warn('Failed to fetch series rules metadata', error);
}
setRecordingForProgram(recordingsByProgramId.get(program.id) || null);
},
[recordingsByProgramId]
);
const recordOne = useCallback(async (program, channel) => {
if (!channel) {
showNotification({
title: 'Unable to schedule recording',
message: 'No channel found for this program.',
color: 'red.6',
});
return;
}
await createRecording({
channel: `${channel.id}`,
start_time: program.start_time,
end_time: program.end_time,
custom_properties: { program },
});
showNotification({ title: 'Recording scheduled' });
}, []);
const saveSeriesRule = useCallback(async (program, mode) => {
await createSeriesRule({
tvg_id: program.tvg_id,
mode,
title: program.title,
});
await evaluateSeriesRulesByTvgId(program.tvg_id);
// recordings_refreshed WS event triggers the debounced fetchRecordings()
showNotification({
title: mode === 'new' ? 'Record new episodes' : 'Record all episodes',
});
}, []);
const openRules = useCallback(async () => {
setRulesOpen(true);
try {
const r = await fetchRules();
setRules(r);
} catch (error) {
console.warn('Failed to load series rules', error);
}
}, []);
const showVideo = useVideoStore((s) => s.showVideo);
const handleLogoClick = useCallback(
(channel, event) => {
event.stopPropagation();
showVideo(getShowVideoUrl(channel, env_mode), 'live', {
name: channel.name,
});
},
[env_mode, showVideo]
);
const handleProgramClick = useCallback(
(program, channel, event) => {
event.stopPropagation();
setSelectedProgram(program);
setSelectedChannel(channel || findChannelByTvgId(program.tvg_id));
setRecordingForProgram(recordingsByProgramId.get(program.id) || null);
},
[findChannelByTvgId, recordingsByProgramId]
);
const handleCloseModal = useCallback(() => {
setSelectedProgram(null);
setSelectedChannel(null);
setRecordingForProgram(null);
}, []);
const scrollToNow = useCallback(() => {
if (nowPosition < 0) {
return;
}
syncScrollLeft(calculateScrollPosition(now, start), 'smooth');
}, [now, nowPosition, start, syncScrollLeft]);
const handleTimelineScroll = useCallback(() => {
if (!timelineRef.current || isSyncingScroll.current) {
return;
}
const nextLeft = timelineRef.current.scrollLeft;
if (nextLeft === guideScrollLeftRef.current) {
return;
}
guideScrollLeftRef.current = nextLeft;
updateNowLine();
isSyncingScroll.current = true;
if (guideRef.current) {
if (typeof guideRef.current.scrollTo === 'function') {
guideRef.current.scrollTo({ left: nextLeft });
} else {
guideRef.current.scrollLeft = nextLeft;
}
}
requestAnimationFrame(() => {
isSyncingScroll.current = false;
});
}, []);
const handleTimelineWheel = useCallback((event) => {
if (!timelineRef.current) {
return;
}
event.preventDefault();
const scrollAmount = event.shiftKey ? 250 : 125;
const delta = event.deltaY > 0 ? scrollAmount : -scrollAmount;
timelineRef.current.scrollBy({ left: delta, behavior: 'smooth' });
}, []);
const handleTimeClick = useCallback(
(clickedTime, event) => {
syncScrollLeft(
calculateScrollPositionByTimeClick(event, clickedTime, start),
'smooth'
);
},
[start, syncScrollLeft]
);
const renderProgram = useCallback(
(program, channelStart = start, channel = null) => {
const {
programStart,
programEnd,
startMs: programStartMs,
endMs: programEndMs,
isLive,
isPast,
} = program;
const startOffsetMinutes =
(programStartMs - convertToMs(channelStart)) / 60000;
const durationMinutes = (programEndMs - programStartMs) / 60000;
const leftPx =
(startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
const widthPx =
(durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
PROGRAM_GAP_PX * 2;
const recording = recordingsByProgramId.get(program.id);
const programStartInView = leftPx + PROGRAM_GAP_PX;
const programEndInView = leftPx + PROGRAM_GAP_PX + widthPx;
const viewportLeft = guideScrollLeftRef.current;
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);
}
const seasonEpisodeLabel = formatSeasonEpisode(
program.season,
program.episode
);
return (
<Box
className="guide-program-container"
key={`${channel?.id || 'unknown'}-${program.id || `${program.tvg_id}-${program.start_time}`}`}
style={{ cursor: 'pointer', zIndex: 5 }}
pos="absolute"
left={leftPx + PROGRAM_GAP_PX}
top={0}
w={widthPx}
h={PROGRAM_HEIGHT - 4}
onClick={(event) => handleProgramClick(program, channel, event)}
>
<Paper
elevation={2}
className={`guide-program ${isLive ? 'live' : isPast ? 'past' : 'not-live'}`}
style={{
overflow: 'hidden',
flexDirection: 'column',
justifyContent: 'flex-start',
backgroundColor: isLive
? '#18181B'
: isPast
? '#27272A'
: '#2c5282',
}}
w={'100%'}
h={'100%'}
pos="relative"
display={'flex'}
p={8}
c={isPast ? '#a0aec0' : '#fff'}
>
<Box
style={{
transform: `translateX(${textOffsetLeft}px)`,
transition: 'transform 0.1s ease-out',
display: 'flex',
flexDirection: 'column',
justifyContent:
program.sub_title || program.description
? 'space-between'
: 'flex-start',
flex: '1 1 0',
minHeight: 0,
overflow: 'hidden',
}}
>
{/* Row 1: Title with recording indicator */}
<Text
component="div"
size="md"
style={{
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
fw={'bold'}
>
<Group gap="xs" wrap="nowrap">
{recording && (
<div
style={{
borderRadius: '50%',
width: '10px',
height: '10px',
display: 'flex',
flexShrink: 0,
backgroundColor: 'red',
}}
></div>
)}
{program.title}
</Group>
</Text>
{/* Row 2: S/E badge + Subtitle or description fallback */}
{(seasonEpisodeLabel ||
program.sub_title ||
program.description) && (
<Group
gap={4}
wrap="nowrap"
style={{ overflow: 'hidden', minWidth: 0 }}
>
{seasonEpisodeLabel && (
<Badge
size="xs"
variant="light"
color="cyan"
styles={{
root: {
backgroundColor: 'rgba(20,90,110,0.55)',
flexShrink: 0,
},
}}
>
{seasonEpisodeLabel}
</Badge>
)}
{(program.sub_title || program.description) && (
<Text
size="xs"
fs={program.sub_title ? 'italic' : 'normal'}
style={{
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
minWidth: 0,
}}
c={isPast ? '#718096' : '#e2e8f0'}
>
{program.sub_title || program.description}
</Text>
)}
</Group>
)}
{/* Row 3: Time + LIVE/NEW badges */}
<Group gap={4} wrap="nowrap" style={{ overflow: 'hidden' }}>
<Text
size="sm"
style={{
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{format(programStart, timeFormat)} -{' '}
{format(programEnd, timeFormat)}
</Text>
{program.is_live && (
<Badge
size="xs"
variant="light"
color="red"
styles={{
root: {
backgroundColor: 'rgba(120,20,20,0.55)',
flexShrink: 0,
},
}}
>
LIVE
</Badge>
)}
{program.is_new && (
<Badge
size="xs"
variant="light"
color="green"
styles={{
root: {
backgroundColor: 'rgba(20,100,20,0.55)',
flexShrink: 0,
},
}}
>
NEW
</Badge>
)}
</Group>
</Box>
{/* Progress bar for currently-airing programs — updated every second via DOM */}
{isLive &&
(() => {
const durationMs = programEndMs - programStartMs;
if (durationMs <= 0) return null;
const initialPct = calcProgressPct(
Date.now(),
programStartMs,
durationMs
);
return (
<Box
pos="absolute"
bottom={0}
left={0}
right={0}
h={4}
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderRadius: '0 0 8px 8px',
overflow: 'hidden',
}}
>
<Box
className="guide-progress-fill"
data-start-ms={programStartMs}
data-end-ms={programEndMs}
h="100%"
style={{
width: '100%',
backgroundColor: 'rgba(255, 255, 255, 0.5)',
borderRadius: '0 0 0 8px',
transformOrigin: 'left',
transform: `scaleX(${initialPct})`,
}}
/>
</Box>
);
})()}
</Paper>
</Box>
);
},
[handleProgramClick, recordingsByProgramId, start, timeFormat]
);
const contentWidth = useMemo(
() => hourTimeline.length * HOUR_WIDTH + CHANNEL_WIDTH,
[hourTimeline]
);
const virtualizedHeight = useMemo(() => guideHeight || 600, [guideHeight]);
const virtualizedWidth = useMemo(() => {
if (guideWidth) {
return guideWidth;
}
if (typeof window !== 'undefined') {
return Math.min(window.innerWidth, contentWidth);
}
return contentWidth;
}, [guideWidth, contentWidth]);
const itemKey = useCallback(
(index) => filteredChannels[index]?.id ?? index,
[filteredChannels]
);
const listData = useMemo(
() => ({
filteredChannels,
programsByChannelId,
rowHeights,
logos,
renderProgram,
handleLogoClick,
contentWidth,
guideScrollLeftRef,
viewportWidth:
guideWidth ||
(typeof window !== 'undefined' ? window.innerWidth : 1200),
timelineStartMs,
settledScrollLeft, // triggers row re-renders after scrolling stops
}),
[
filteredChannels,
programsByChannelId,
rowHeights,
logos,
renderProgram,
handleLogoClick,
contentWidth,
guideWidth,
timelineStartMs,
settledScrollLeft,
]
);
useEffect(() => {
if (listRef.current) {
listRef.current.resetAfterIndex(0, true);
}
}, [rowHeights]);
useEffect(() => {
if (listRef.current) {
listRef.current.scrollToItem(0);
}
}, [searchQuery, selectedGroupId, selectedProfileId]);
// Group options: show all groups; gate 'All' if too many channels
const groupOptions = useMemo(() => {
const opts = [];
if (allowAllGroups) {
opts.push({ value: 'all', label: 'All Channel Groups' });
}
const groupsArr = Object.values(channelGroups)
.filter((g) => g?.hasChannels)
.sort((a, b) => (a?.name || '').localeCompare(b?.name || ''));
groupsArr.forEach((g) => {
opts.push({ value: String(g.id), label: g.name });
});
return opts;
}, [channelGroups, allowAllGroups]);
// Create profile options for dropdown
const profileOptions = useMemo(() => getProfileOptions(profiles), [profiles]);
// Clear all filters
const clearFilters = () => {
setSearchQuery('');
setSelectedGroupId('all');
setSelectedProfileId('all');
};
// Handle group selection changes, ensuring null becomes 'all'
const handleGroupChange = (value) => {
setSelectedGroupId(value || 'all');
};
// Handle profile selection changes, ensuring null becomes 'all'
const handleProfileChange = (value) => {
setSelectedProfileId(value || 'all');
};
const handleClearSearchQuery = () => {
setSearchQuery('');
};
const handleChangeSearchQuery = (e) => {
setSearchQuery(e.target.value);
};
return (
<Box
ref={tvGuideRef}
className="tv-guide"
style={{
overflow: 'hidden',
}}
w={'100%'}
h={'100%'}
c="#ffffff"
ff={'Roboto, sans-serif'}
>
{/* Sticky top bar */}
<Flex
direction="column"
style={{
zIndex: 1000,
position: 'sticky',
}}
c="#ffffff"
p={'12px 20px'}
top={0}
>
{/* Title and current time */}
<Flex justify="space-between" align="center" mb={12}>
<Title order={3} fw={'bold'}>
TV Guide
</Title>
<Flex align="center" gap="md">
<Text>
{format(now, `dddd, ${dateFormat}, YYYY • ${timeFormat}`)}
</Text>
<Tooltip label="Jump to current time">
<ActionIcon
onClick={scrollToNow}
variant="filled"
size="md"
radius="xl"
color="teal"
>
<Clock size={16} />
</ActionIcon>
</Tooltip>
</Flex>
</Flex>
{/* Filter controls */}
<Flex gap="md" align="center">
<TextInput
placeholder="Search channels..."
value={searchQuery}
onChange={handleChangeSearchQuery}
w={'250px'} // Reduced width from flex: 1
leftSection={<Search size={16} />}
rightSection={
searchQuery ? (
<ActionIcon
onClick={handleClearSearchQuery}
variant="subtle"
color="gray"
size="sm"
>
<X size={14} />
</ActionIcon>
) : null
}
/>
<Select
placeholder="Filter by group"
data={groupOptions}
value={selectedGroupId}
onChange={handleGroupChange} // Use the new handler
w={'220px'}
clearable={allowAllGroups} // Allow clearing the selection
/>
<Select
placeholder="Filter by profile"
data={profileOptions}
value={selectedProfileId}
onChange={handleProfileChange} // Use the new handler
w={'180px'}
clearable={true} // Allow clearing the selection
/>
{(searchQuery !== '' ||
selectedGroupId !== 'all' ||
selectedProfileId !== 'all') && (
<Button variant="subtle" onClick={clearFilters} size="sm">
Clear Filters
</Button>
)}
<Button
variant="filled"
size="sm"
onClick={openRules}
style={{
backgroundColor: '#245043',
}}
bd={'1px solid #3BA882'}
color="#FFFFFF"
>
Series Rules
</Button>
<Text size="sm" c="dimmed">
{filteredChannels.length}{' '}
{filteredChannels.length === 1 ? 'channel' : 'channels'}
</Text>
</Flex>
</Flex>
{/* Guide container with headers and scrollable content */}
<Box
style={{
flexDirection: 'column',
}}
display={'flex'}
h={'calc(100vh - 120px)'}
>
{/* Logo header - Sticky, non-scrollable */}
<Box
style={{
zIndex: 100,
position: 'sticky',
}}
display={'flex'}
top={0}
>
{/* Logo header cell - sticky in both directions */}
<Box
style={{
flexShrink: 0,
backgroundColor: '#18181B',
borderBottom: '1px solid #27272A',
borderRight: '1px solid #27272A', // Increased border width
zIndex: 200,
}}
w={CHANNEL_WIDTH}
miw={CHANNEL_WIDTH}
h={'40px'}
pos="sticky"
left={0}
/>
{/* Timeline header with its own scrollbar */}
<Box
style={{
flex: 1,
overflow: 'hidden',
}}
pos="relative"
>
<Box
ref={timelineRef}
style={{
overflowX: 'auto',
overflowY: 'hidden',
}}
pos="relative"
onScroll={handleTimelineScroll}
onWheel={handleTimelineWheel} // Add wheel event handler
>
<Box
style={{
backgroundColor: '#1E2A27',
borderBottom: '1px solid #27272A',
}}
display={'flex'}
w={hourTimeline.length * HOUR_WIDTH}
pos="relative"
>
<HourTimeline
hourTimeline={hourTimeline}
timeFormat={timeFormat}
formatDayLabel={formatDayLabel}
handleTimeClick={handleTimeClick}
/>
</Box>
</Box>
</Box>
</Box>
{/* Main scrollable container for program content */}
<Box
ref={guideContainerRef}
style={{
flex: 1,
overflow: 'hidden',
}}
pos="relative"
>
<LoadingOverlay visible={isProgramsLoading || isChannelsLoading} />
{nowPosition >= 0 && (
<Box
ref={nowLineRef}
style={{
backgroundColor: '#38b2ac',
zIndex: 15,
pointerEvents: 'none',
left: `${((Date.now() - timelineStartMs) / 60000 / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH + CHANNEL_WIDTH - guideScrollLeftRef.current}px`,
}}
pos="absolute"
top={0}
bottom={0}
w={'2px'}
>
{/* Now-marker triangle at top of line */}
<Box
pos="absolute"
top={-1}
style={{
transform: 'translateX(-6px)',
width: 0,
height: 0,
borderLeft: '7px solid transparent',
borderRight: '7px solid transparent',
borderTop: '9px solid #38b2ac',
filter: 'drop-shadow(0 1px 3px rgba(56, 178, 172, 0.5))',
}}
/>
</Box>
)}
{filteredChannels.length > 0 ? (
<VariableSizeList
className="guide-list-outer"
height={virtualizedHeight}
width={virtualizedWidth}
itemCount={filteredChannels.length}
itemSize={getItemSize}
estimatedItemSize={PROGRAM_HEIGHT}
itemKey={itemKey}
itemData={listData}
ref={listRef}
outerRef={guideRef}
overscanCount={3}
>
{GuideRow}
</VariableSizeList>
) : (
<Box p={'30px'} ta="center" color="#a0aec0">
<Text size="lg">No channels match your filters</Text>
<Button variant="subtle" onClick={clearFilters} mt={10}>
Clear Filters
</Button>
</Box>
)}
</Box>
</Box>
{/* Record choice modal */}
{recordChoiceOpen && recordChoiceProgram && (
<ErrorBoundary>
<Suspense fallback={<LoadingOverlay />}>
<ProgramRecordingModal
opened={recordChoiceOpen}
onClose={() => setRecordChoiceOpen(false)}
program={recordChoiceProgram}
recording={recordingForProgram}
existingRuleMode={existingRuleMode}
existingRule={existingRule}
onRecordOne={() =>
recordOne(recordChoiceProgram, recordChoiceChannel)
}
onRecordSeriesAll={() =>
saveSeriesRule(recordChoiceProgram, 'all')
}
onRecordSeriesNew={() =>
saveSeriesRule(recordChoiceProgram, 'new')
}
onExistingRuleModeChange={setExistingRuleMode}
/>
</Suspense>
</ErrorBoundary>
)}
{/* Series rules modal */}
{rulesOpen && (
<ErrorBoundary>
<Suspense fallback={<LoadingOverlay />}>
<SeriesRecordingModal
opened={rulesOpen}
onClose={() => setRulesOpen(false)}
rules={rules}
onRulesUpdate={setRules}
/>
</Suspense>
</ErrorBoundary>
)}
{/* Program detail modal */}
{selectedProgram && (
<ErrorBoundary>
<Suspense fallback={<LoadingOverlay />}>
<ProgramDetailModal
program={selectedProgram}
channel={selectedChannel}
recording={recordingForProgram}
opened={!!selectedProgram}
onClose={handleCloseModal}
onRecord={(program) => openRecordChoice(program, selectedChannel)}
/>
</Suspense>
</ErrorBoundary>
)}
</Box>
);
}