forked from Mirrors/Dispatcharr
1665 lines
53 KiB
JavaScript
1665 lines
53 KiB
JavaScript
// frontend/src/pages/Guide.js
|
|
import React, {
|
|
useMemo,
|
|
useState,
|
|
useEffect,
|
|
useRef,
|
|
useCallback,
|
|
useContext,
|
|
} from 'react';
|
|
import dayjs from 'dayjs';
|
|
import API from '../api';
|
|
import useChannelsStore from '../store/channels';
|
|
import useLogosStore from '../store/logos';
|
|
import logo from '../images/logo.png';
|
|
import useVideoStore from '../store/useVideoStore'; // NEW import
|
|
import { notifications } from '@mantine/notifications';
|
|
import useSettingsStore from '../store/settings';
|
|
import {
|
|
Title,
|
|
Box,
|
|
Flex,
|
|
Button,
|
|
Text,
|
|
Paper,
|
|
Group,
|
|
TextInput,
|
|
Select,
|
|
ActionIcon,
|
|
Tooltip,
|
|
Transition,
|
|
Modal,
|
|
Stack,
|
|
} 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
|
|
const PROGRAM_HEIGHT = 90; // Height of each channel row
|
|
const EXPANDED_PROGRAM_HEIGHT = 180; // Height for expanded program rows
|
|
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 (
|
|
<div
|
|
ref={ref}
|
|
style={{
|
|
...style,
|
|
width: contentWidth,
|
|
position: 'relative',
|
|
}}
|
|
{...rest}
|
|
>
|
|
{nowPosition >= 0 && (
|
|
<Box
|
|
style={{
|
|
position: 'absolute',
|
|
left: nowPosition + CHANNEL_WIDTH,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: '2px',
|
|
backgroundColor: '#38b2ac',
|
|
zIndex: 15,
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
)}
|
|
{children}
|
|
</div>
|
|
);
|
|
});
|
|
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 (
|
|
<div style={{ ...style, width: contentWidth }}>
|
|
<Box
|
|
style={{
|
|
display: 'flex',
|
|
height: rowHeight,
|
|
borderBottom: '0px solid #27272A',
|
|
transition: 'height 0.2s ease',
|
|
position: 'relative',
|
|
overflow: 'visible',
|
|
}}
|
|
>
|
|
<Box
|
|
className="channel-logo"
|
|
style={{
|
|
width: CHANNEL_WIDTH,
|
|
minWidth: CHANNEL_WIDTH,
|
|
flexShrink: 0,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: '#18181B',
|
|
borderRight: '1px solid #27272A',
|
|
borderBottom: '1px solid #27272A',
|
|
boxShadow: '2px 0 5px rgba(0,0,0,0.2)',
|
|
left: 0,
|
|
zIndex: 30,
|
|
height: rowHeight,
|
|
transition: 'height 0.2s ease',
|
|
cursor: 'pointer',
|
|
position: 'relative',
|
|
}}
|
|
onClick={(event) => handleLogoClick(channel, event)}
|
|
onMouseEnter={() => setHoveredChannelId(channel.id)}
|
|
onMouseLeave={() => setHoveredChannelId(null)}
|
|
>
|
|
{hoveredChannelId === channel.id && (
|
|
<Flex
|
|
align="center"
|
|
justify="center"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
|
zIndex: 10,
|
|
animation: 'fadeIn 0.2s',
|
|
}}
|
|
>
|
|
<Play size={32} color="#fff" fill="#fff" />
|
|
</Flex>
|
|
)}
|
|
|
|
<Flex
|
|
direction="column"
|
|
align="center"
|
|
justify="space-between"
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
padding: '4px',
|
|
boxSizing: 'border-box',
|
|
zIndex: 5,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
width: '100%',
|
|
height: `${rowHeight - 32}px`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
padding: '4px',
|
|
marginBottom: '4px',
|
|
}}
|
|
>
|
|
<img
|
|
src={logos[channel.logo_id]?.cache_url || logo}
|
|
alt={channel.name}
|
|
style={{
|
|
maxWidth: '100%',
|
|
maxHeight: '100%',
|
|
objectFit: 'contain',
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
<Text
|
|
size="sm"
|
|
weight={600}
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: '4px',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
backgroundColor: '#18181B',
|
|
padding: '2px 8px',
|
|
borderRadius: 4,
|
|
fontSize: '0.85em',
|
|
border: '1px solid #27272A',
|
|
height: '24px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
minWidth: '36px',
|
|
}}
|
|
>
|
|
{channel.channel_number || '-'}
|
|
</Text>
|
|
</Flex>
|
|
</Box>
|
|
|
|
<Box
|
|
style={{
|
|
flex: 1,
|
|
position: 'relative',
|
|
height: rowHeight,
|
|
transition: 'height 0.2s ease',
|
|
paddingLeft: 0,
|
|
}}
|
|
>
|
|
{channelPrograms.length > 0 ? (
|
|
channelPrograms.map((program) => (
|
|
<div key={`${channel.id}-${program.id}-${program.start_time}`}>
|
|
{renderProgram(program, start)}
|
|
</div>
|
|
))
|
|
) : (
|
|
<>
|
|
{Array.from({ length: Math.ceil(hourTimeline.length / 2) }).map(
|
|
(_, placeholderIndex) => (
|
|
<Box
|
|
key={`placeholder-${channel.id}-${placeholderIndex}`}
|
|
style={{
|
|
position: 'absolute',
|
|
left: placeholderIndex * (HOUR_WIDTH * 2),
|
|
top: 0,
|
|
width: HOUR_WIDTH * 2,
|
|
height: rowHeight - 4,
|
|
border: '1px dashed #2D3748',
|
|
borderRadius: '4px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: '#4A5568',
|
|
}}
|
|
>
|
|
<Text size="sm">No program data</Text>
|
|
</Box>
|
|
)
|
|
)}
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</div>
|
|
);
|
|
});
|
|
GuideRow.displayName = 'GuideRow';
|
|
|
|
export default function TVChannelGuide({ startDate, endDate }) {
|
|
const channels = useChannelsStore((s) => s.channels);
|
|
const recordings = useChannelsStore((s) => s.recordings);
|
|
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
|
const profiles = useChannelsStore((s) => s.profiles);
|
|
const logos = useLogosStore((s) => s.logos);
|
|
|
|
const tvgsById = useEPGsStore((s) => s.tvgsById);
|
|
|
|
const [programs, setPrograms] = useState([]);
|
|
const [guideChannels, setGuideChannels] = useState([]);
|
|
const [filteredChannels, setFilteredChannels] = useState([]);
|
|
const [now, setNow] = useState(dayjs());
|
|
const [expandedProgramId, setExpandedProgramId] = useState(null); // Track expanded program
|
|
const [recordingForProgram, setRecordingForProgram] = useState(null);
|
|
const [recordChoiceOpen, setRecordChoiceOpen] = useState(false);
|
|
const [recordChoiceProgram, setRecordChoiceProgram] = useState(null);
|
|
const [existingRuleMode, setExistingRuleMode] = 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 isSyncingScroll = useRef(false);
|
|
const {
|
|
ref: guideContainerRef,
|
|
width: guideWidth,
|
|
height: guideHeight,
|
|
} = useElementSize();
|
|
|
|
// Add new state to track hovered logo
|
|
const [hoveredChannelId, setHoveredChannelId] = useState(null);
|
|
|
|
// Load program data once
|
|
useEffect(() => {
|
|
if (!Object.keys(channels).length === 0) {
|
|
console.warn('No channels provided or empty channels array');
|
|
notifications.show({ title: 'No channels available', color: 'red.5' });
|
|
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`);
|
|
|
|
// Include ALL channels, sorted by channel number - don't filter by EPG data
|
|
const sortedChannels = Object.values(channels).sort(
|
|
(a, b) =>
|
|
(a.channel_number || Infinity) - (b.channel_number || Infinity)
|
|
);
|
|
|
|
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);
|
|
};
|
|
|
|
fetchPrograms();
|
|
}, [channels]);
|
|
|
|
// Apply filters when search, group, or profile changes
|
|
useEffect(() => {
|
|
if (!guideChannels.length) return;
|
|
|
|
let result = [...guideChannels];
|
|
|
|
// Apply search filter
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
result = result.filter((channel) =>
|
|
channel.name.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
// Apply channel group filter
|
|
if (selectedGroupId !== 'all') {
|
|
result = result.filter(
|
|
(channel) => channel.channel_group_id === parseInt(selectedGroupId)
|
|
);
|
|
}
|
|
|
|
// Apply profile filter
|
|
if (selectedProfileId !== 'all') {
|
|
// Get the profile's enabled channels
|
|
const profileChannels = profiles[selectedProfileId]?.channels || [];
|
|
// Check if channels is a Set (from the error message, it likely is)
|
|
const enabledChannelIds = Array.isArray(profileChannels)
|
|
? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
|
|
: profiles[selectedProfileId]?.channels instanceof Set
|
|
? Array.from(profiles[selectedProfileId].channels)
|
|
: [];
|
|
|
|
result = result.filter((channel) =>
|
|
enabledChannelIds.includes(channel.id)
|
|
);
|
|
}
|
|
|
|
setFilteredChannels(result);
|
|
}, [
|
|
searchQuery,
|
|
selectedGroupId,
|
|
selectedProfileId,
|
|
guideChannels,
|
|
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');
|
|
|
|
// Expand timeline if needed based on actual earliest/ latest program
|
|
const earliestProgramStart = useMemo(() => {
|
|
if (!programs.length) return defaultStart;
|
|
return programs.reduce((acc, p) => {
|
|
const s = dayjs(p.start_time);
|
|
return s.isBefore(acc) ? s : acc;
|
|
}, defaultStart);
|
|
}, [programs, defaultStart]);
|
|
|
|
const latestProgramEnd = useMemo(() => {
|
|
if (!programs.length) return defaultEnd;
|
|
return programs.reduce((acc, p) => {
|
|
const e = dayjs(p.end_time);
|
|
return e.isAfter(acc) ? e : acc;
|
|
}, defaultEnd);
|
|
}, [programs, defaultEnd]);
|
|
|
|
const start = earliestProgramStart.isBefore(defaultStart)
|
|
? earliestProgramStart
|
|
: defaultStart;
|
|
const end = latestProgramEnd.isAfter(defaultEnd)
|
|
? latestProgramEnd
|
|
: defaultEnd;
|
|
|
|
// Format day label using relative terms when possible (Today, Tomorrow, etc)
|
|
const formatDayLabel = useCallback((time) => {
|
|
const today = dayjs().startOf('day');
|
|
const tomorrow = today.add(1, 'day');
|
|
const weekLater = today.add(7, 'day');
|
|
|
|
const day = time.startOf('day');
|
|
|
|
if (day.isSame(today, 'day')) {
|
|
return 'Today';
|
|
} else if (day.isSame(tomorrow, 'day')) {
|
|
return 'Tomorrow';
|
|
} else if (day.isBefore(weekLater)) {
|
|
// Within a week, show day name
|
|
return time.format('dddd');
|
|
} else {
|
|
// Beyond a week, show month and day
|
|
return time.format(dateFormat);
|
|
}
|
|
}, [dateFormat]);
|
|
|
|
// Hourly marks with day labels
|
|
const hourTimeline = useMemo(() => {
|
|
const hours = [];
|
|
let current = start;
|
|
let currentDay = null;
|
|
|
|
while (current.isBefore(end)) {
|
|
// Check if we're entering a new day
|
|
const day = current.startOf('day');
|
|
const isNewDay = !currentDay || !day.isSame(currentDay, 'day');
|
|
|
|
if (isNewDay) {
|
|
currentDay = day;
|
|
}
|
|
|
|
// Add day information to our hour object
|
|
hours.push({
|
|
time: current,
|
|
isNewDay,
|
|
dayLabel: formatDayLabel(current),
|
|
});
|
|
|
|
current = current.add(1, 'hour');
|
|
}
|
|
return hours;
|
|
}, [start, end, formatDayLabel]);
|
|
|
|
// Scroll to the nearest half-hour mark ONLY on initial load
|
|
useEffect(() => {
|
|
if (
|
|
guideRef.current &&
|
|
timelineRef.current &&
|
|
programs.length > 0 &&
|
|
!initialScrollComplete
|
|
) {
|
|
// Round the current time to the nearest half-hour mark
|
|
const roundedNow =
|
|
now.minute() < 30
|
|
? now.startOf('hour')
|
|
: now.startOf('hour').add(30, 'minute');
|
|
const nowOffset = roundedNow.diff(start, 'minute');
|
|
const scrollPosition =
|
|
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
|
|
MINUTE_BLOCK_WIDTH;
|
|
|
|
const scrollPos = Math.max(scrollPosition, 0);
|
|
guideRef.current.scrollLeft = scrollPos;
|
|
timelineRef.current.scrollLeft = scrollPos; // Sync timeline scroll
|
|
|
|
// Mark initial scroll as complete
|
|
setInitialScrollComplete(true);
|
|
}
|
|
}, [programs, start, now, initialScrollComplete]);
|
|
|
|
// Update “now” every second
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setNow(dayjs());
|
|
}, 1000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
// Pixel offset for the “now” vertical line
|
|
const nowPosition = useMemo(() => {
|
|
if (now.isBefore(start) || now.isAfter(end)) return -1;
|
|
const minutesSinceStart = now.diff(start, 'minute');
|
|
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]
|
|
);
|
|
|
|
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 = 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,
|
|
});
|
|
await API.evaluateSeriesRules(program.tvg_id);
|
|
try {
|
|
await useChannelsStore.getState().fetchRecordings();
|
|
} catch (error) {
|
|
console.warn('Failed to refresh recordings after saving series rule', error);
|
|
}
|
|
notifications.show({
|
|
title: mode === 'new' ? 'Record new episodes' : 'Record all episodes',
|
|
});
|
|
}, []);
|
|
|
|
const openRules = useCallback(async () => {
|
|
setRulesOpen(true);
|
|
try {
|
|
const r = await API.listSeriesRules();
|
|
setRules(r);
|
|
} catch (error) {
|
|
console.warn('Failed to fetch series rules', error);
|
|
}
|
|
}, []);
|
|
|
|
// 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}`;
|
|
}
|
|
|
|
showVideo(vidUrl);
|
|
},
|
|
[env_mode, findChannelByTvgId, showVideo]
|
|
);
|
|
|
|
// Function to handle logo click to play channel
|
|
const handleLogoClick = useCallback(
|
|
(channel, event) => {
|
|
event.stopPropagation();
|
|
|
|
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]
|
|
);
|
|
|
|
// On program click, toggle the expanded state
|
|
const handleProgramClick = useCallback(
|
|
(program, event) => {
|
|
event.stopPropagation();
|
|
|
|
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);
|
|
|
|
if (expandedProgramId === program.id) {
|
|
setExpandedProgramId(null);
|
|
setRecordingForProgram(null);
|
|
return;
|
|
}
|
|
|
|
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',
|
|
});
|
|
}
|
|
}
|
|
},
|
|
[expandedProgramId, guideRef, recordingsByProgramId, start, timelineRef]
|
|
);
|
|
|
|
// Close the expanded program when clicking elsewhere
|
|
const handleClickOutside = useCallback(() => {
|
|
if (expandedProgramId) {
|
|
setExpandedProgramId(null);
|
|
setRecordingForProgram(null);
|
|
}
|
|
}, [expandedProgramId]);
|
|
|
|
// Function to scroll to current time - matches initial loading position
|
|
const scrollToNow = useCallback(() => {
|
|
if (guideRef.current && timelineRef.current && nowPosition >= 0) {
|
|
const roundedNow =
|
|
now.minute() < 30
|
|
? now.startOf('hour')
|
|
: now.startOf('hour').add(30, 'minute');
|
|
const nowOffset = roundedNow.diff(start, 'minute');
|
|
const scrollPosition =
|
|
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
|
|
MINUTE_BLOCK_WIDTH;
|
|
|
|
const scrollPos = Math.max(scrollPosition, 0);
|
|
guideRef.current.scrollLeft = scrollPos;
|
|
timelineRef.current.scrollLeft = scrollPos;
|
|
}
|
|
}, [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]);
|
|
|
|
// 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;
|
|
timelineRef.current.scrollLeft +=
|
|
event.deltaY > 0 ? scrollAmount : -scrollAmount;
|
|
handleTimelineScroll();
|
|
},
|
|
[handleTimelineScroll]
|
|
);
|
|
|
|
// Function to handle timeline time clicks with 15-minute snapping
|
|
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;
|
|
const percentageAcross = clickPositionX / rect.width;
|
|
|
|
const minuteWithinHour = Math.floor(percentageAcross * 60);
|
|
|
|
let snappedMinute;
|
|
if (minuteWithinHour < 7.5) {
|
|
snappedMinute = 0;
|
|
} else if (minuteWithinHour < 22.5) {
|
|
snappedMinute = 15;
|
|
} else if (minuteWithinHour < 37.5) {
|
|
snappedMinute = 30;
|
|
} else if (minuteWithinHour < 52.5) {
|
|
snappedMinute = 45;
|
|
} else {
|
|
snappedMinute = 0;
|
|
clickedTime = clickedTime.add(1, 'hour');
|
|
}
|
|
|
|
const snappedTime = clickedTime.minute(snappedMinute);
|
|
const snappedOffset = snappedTime.diff(start, 'minute');
|
|
const scrollPosition =
|
|
(snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
|
|
|
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;
|
|
|
|
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);
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
className="guide-program-container"
|
|
key={programKey}
|
|
style={{
|
|
position: 'absolute',
|
|
left: leftPx + gapSize,
|
|
top: 0,
|
|
width: isExpanded ? expandedWidthPx : widthPx,
|
|
height: rowHeight - 4,
|
|
cursor: 'pointer',
|
|
zIndex: isExpanded ? 25 : 5,
|
|
transition: isExpanded
|
|
? 'height 0.2s ease, width 0.2s ease'
|
|
: 'height 0.2s ease',
|
|
}}
|
|
onClick={(event) => handleProgramClick(program, event)}
|
|
>
|
|
<Paper
|
|
elevation={isExpanded ? 4 : 2}
|
|
className={`guide-program ${isLive ? 'live' : isPast ? 'past' : 'not-live'} ${isExpanded ? 'expanded' : ''}`}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: isExpanded ? 'flex-start' : 'space-between',
|
|
padding: isExpanded ? '12px' : '8px',
|
|
backgroundColor: isExpanded
|
|
? isLive
|
|
? '#1a365d'
|
|
: isPast
|
|
? '#18181B'
|
|
: '#1e40af'
|
|
: isLive
|
|
? '#18181B'
|
|
: isPast
|
|
? '#27272A'
|
|
: '#2c5282',
|
|
color: isPast ? '#a0aec0' : '#fff',
|
|
boxShadow: isExpanded ? '0 4px 8px rgba(0,0,0,0.4)' : 'none',
|
|
transition: 'all 0.2s ease',
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
transform: `translateX(${textOffsetLeft}px)`,
|
|
transition: 'transform 0.1s ease-out',
|
|
}}
|
|
>
|
|
<Text
|
|
component="div"
|
|
size={isExpanded ? 'lg' : 'md'}
|
|
style={{
|
|
fontWeight: 'bold',
|
|
whiteSpace: 'nowrap',
|
|
textOverflow: 'ellipsis',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Group gap="xs">
|
|
{recording && (
|
|
<div
|
|
style={{
|
|
borderRadius: '50%',
|
|
width: '10px',
|
|
height: '10px',
|
|
display: 'flex',
|
|
backgroundColor: 'red',
|
|
}}
|
|
></div>
|
|
)}
|
|
{program.title}
|
|
</Group>
|
|
</Text>
|
|
<Text
|
|
size="sm"
|
|
style={{
|
|
whiteSpace: 'nowrap',
|
|
textOverflow: 'ellipsis',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{programStart.format(timeFormat)} - {programEnd.format(timeFormat)}
|
|
</Text>
|
|
</Box>
|
|
{program.description && (
|
|
<Box
|
|
style={{
|
|
transform: `translateX(${textOffsetLeft}px)`,
|
|
transition: 'transform 0.1s ease-out',
|
|
}}
|
|
>
|
|
<Text
|
|
size="xs"
|
|
style={{
|
|
marginTop: '4px',
|
|
whiteSpace: isExpanded ? 'normal' : 'nowrap',
|
|
textOverflow: isExpanded ? 'clip' : 'ellipsis',
|
|
overflow: isExpanded ? 'auto' : 'hidden',
|
|
color: isPast ? '#718096' : '#cbd5e0',
|
|
maxHeight: isExpanded ? '80px' : 'unset',
|
|
}}
|
|
>
|
|
{program.description}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
{isExpanded && (
|
|
<Box style={{ marginTop: 'auto' }}>
|
|
<Flex gap="md" justify="flex-end" mt={8}>
|
|
{!isPast && (
|
|
<Button
|
|
leftSection={<Calendar size={14} />}
|
|
variant="filled"
|
|
color="red"
|
|
size="xs"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
openRecordChoice(program);
|
|
}}
|
|
>
|
|
Record
|
|
</Button>
|
|
)}
|
|
|
|
{isLive && (
|
|
<Button
|
|
leftSection={<Video size={14} />}
|
|
variant="filled"
|
|
color="blue"
|
|
size="xs"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
handleWatchStream(program);
|
|
}}
|
|
>
|
|
Watch Now
|
|
</Button>
|
|
)}
|
|
</Flex>
|
|
</Box>
|
|
)}
|
|
</Paper>
|
|
</Box>
|
|
);
|
|
},
|
|
[
|
|
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(() => {
|
|
const options = [{ value: 'all', label: 'All Channel Groups' }];
|
|
|
|
if (channelGroups && guideChannels.length > 0) {
|
|
// Get unique channel group IDs from the channels that have program data
|
|
const usedGroupIds = new Set();
|
|
guideChannels.forEach((channel) => {
|
|
if (channel.channel_group_id) {
|
|
usedGroupIds.add(channel.channel_group_id);
|
|
}
|
|
});
|
|
// Only add groups that are actually used by channels in the guide
|
|
Object.values(channelGroups)
|
|
.filter((group) => usedGroupIds.has(group.id))
|
|
.sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
|
|
.forEach((group) => {
|
|
options.push({
|
|
value: group.id.toString(),
|
|
label: group.name,
|
|
});
|
|
});
|
|
}
|
|
return options;
|
|
}, [channelGroups, guideChannels]);
|
|
|
|
// Create profile options for dropdown
|
|
const profileOptions = useMemo(() => {
|
|
const options = [{ value: 'all', label: 'All Profiles' }];
|
|
|
|
if (profiles) {
|
|
Object.values(profiles).forEach((profile) => {
|
|
if (profile.id !== '0') {
|
|
// Skip the 'All' default profile
|
|
options.push({
|
|
value: profile.id.toString(),
|
|
label: profile.name,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return options;
|
|
}, [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');
|
|
};
|
|
|
|
// Handle date-time formats
|
|
const [timeFormatSetting] = useLocalStorage('time-format', '12h');
|
|
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
|
|
const timeFormat = timeFormatSetting === '12h' ? 'h:mm A' : 'HH:mm';
|
|
const dateFormat = dateFormatSetting === 'mdy' ? 'MMMM D' : 'D MMMM';
|
|
|
|
return (
|
|
<Box
|
|
className="tv-guide"
|
|
style={{
|
|
overflow: 'hidden',
|
|
width: '100%',
|
|
height: '100%',
|
|
// backgroundColor: 'rgb(39, 39, 42)',
|
|
color: '#fff',
|
|
fontFamily: 'Roboto, sans-serif',
|
|
}}
|
|
onClick={handleClickOutside} // Close expanded program when clicking outside
|
|
>
|
|
{/* Sticky top bar */}
|
|
<Flex
|
|
direction="column"
|
|
style={{
|
|
// backgroundColor: '#424242',
|
|
color: '#fff',
|
|
padding: '12px 20px',
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 1000,
|
|
}}
|
|
>
|
|
{/* Title and current time */}
|
|
<Flex justify="space-between" align="center" mb={12}>
|
|
<Title order={3} style={{ fontWeight: 'bold' }}>
|
|
TV Guide
|
|
</Title>
|
|
<Flex align="center" gap="md">
|
|
<Text>
|
|
{now.format(`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={(e) => setSearchQuery(e.target.value)}
|
|
style={{ width: '250px' }} // Reduced width from flex: 1
|
|
leftSection={<Search size={16} />}
|
|
rightSection={
|
|
searchQuery ? (
|
|
<ActionIcon
|
|
onClick={() => setSearchQuery('')}
|
|
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
|
|
style={{ width: '220px' }}
|
|
clearable={true} // Allow clearing the selection
|
|
/>
|
|
|
|
<Select
|
|
placeholder="Filter by profile"
|
|
data={profileOptions}
|
|
value={selectedProfileId}
|
|
onChange={handleProfileChange} // Use the new handler
|
|
style={{ width: '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',
|
|
border: '1px solid #3BA882',
|
|
color: '#FFFFFF',
|
|
}}
|
|
>
|
|
Series Rules
|
|
</Button>
|
|
|
|
<Text size="sm" color="dimmed">
|
|
{filteredChannels.length}{' '}
|
|
{filteredChannels.length === 1 ? 'channel' : 'channels'}
|
|
</Text>
|
|
</Flex>
|
|
</Flex>
|
|
|
|
{/* Guide container with headers and scrollable content */}
|
|
<Box
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: 'calc(100vh - 120px)',
|
|
}}
|
|
>
|
|
{/* Logo header - Sticky, non-scrollable */}
|
|
<Box
|
|
style={{
|
|
display: 'flex',
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 100,
|
|
}}
|
|
>
|
|
{/* Logo header cell - sticky in both directions */}
|
|
<Box
|
|
style={{
|
|
width: CHANNEL_WIDTH,
|
|
minWidth: CHANNEL_WIDTH,
|
|
flexShrink: 0,
|
|
height: '40px',
|
|
backgroundColor: '#18181B',
|
|
borderBottom: '1px solid #27272A',
|
|
borderRight: '1px solid #27272A', // Increased border width
|
|
position: 'sticky',
|
|
left: 0,
|
|
zIndex: 200,
|
|
}}
|
|
/>
|
|
|
|
{/* Timeline header with its own scrollbar */}
|
|
<Box
|
|
style={{
|
|
flex: 1,
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Box
|
|
ref={timelineRef}
|
|
style={{
|
|
overflowX: 'auto',
|
|
overflowY: 'hidden',
|
|
position: 'relative',
|
|
}}
|
|
onScroll={handleTimelineScroll}
|
|
onWheel={handleTimelineWheel} // Add wheel event handler
|
|
>
|
|
<Box
|
|
style={{
|
|
display: 'flex',
|
|
backgroundColor: '#1E2A27',
|
|
borderBottom: '1px solid #27272A',
|
|
width: hourTimeline.length * HOUR_WIDTH,
|
|
}}
|
|
>
|
|
{' '}
|
|
{hourTimeline.map((hourData) => {
|
|
const { time, isNewDay } = hourData;
|
|
|
|
return (
|
|
<Box
|
|
key={time.format()}
|
|
style={{
|
|
width: HOUR_WIDTH,
|
|
height: '40px',
|
|
position: 'relative',
|
|
color: '#a0aec0',
|
|
borderRight: '1px solid #8DAFAA',
|
|
cursor: 'pointer',
|
|
borderLeft: isNewDay ? '2px solid #3BA882' : 'none', // Highlight day boundaries
|
|
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421', // Subtle background for new days
|
|
}}
|
|
onClick={(e) => handleTimeClick(time, e)}
|
|
>
|
|
{/* Remove the special day label for new days since we'll show day for all hours */}
|
|
|
|
{/* Position time label at the left border of each hour block */}
|
|
<Text
|
|
size="sm"
|
|
style={{
|
|
position: 'absolute',
|
|
top: '8px', // Consistent positioning for all hours
|
|
left: '4px',
|
|
transform: 'none',
|
|
borderRadius: '2px',
|
|
lineHeight: 1.2,
|
|
textAlign: 'left',
|
|
}}
|
|
>
|
|
{/* Show day above time for every hour using the same format */}
|
|
<Text
|
|
span
|
|
size="xs"
|
|
style={{
|
|
display: 'block',
|
|
opacity: 0.7,
|
|
fontWeight: isNewDay ? 600 : 400, // Still emphasize day transitions
|
|
color: isNewDay ? '#3BA882' : undefined,
|
|
}}
|
|
>
|
|
{formatDayLabel(time)}{' '}
|
|
{/* Use same formatDayLabel function for all hours */}
|
|
</Text>
|
|
{time.format(timeFormat)}
|
|
<Text span size="xs" ml={1} opacity={0.7}>
|
|
{/*time.format('A')*/}
|
|
</Text>
|
|
</Text>
|
|
|
|
{/* Hour boundary marker - more visible */}
|
|
<Box
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: '1px',
|
|
backgroundColor: '#27272A',
|
|
zIndex: 10,
|
|
}}
|
|
/>
|
|
|
|
{/* Quarter hour tick marks */}
|
|
<Box
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
width: '100%',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
padding: '0 1px',
|
|
}}
|
|
>
|
|
{[15, 30, 45].map((minute) => (
|
|
<Box
|
|
key={minute}
|
|
style={{
|
|
width: '1px',
|
|
height: '8px',
|
|
backgroundColor: '#718096',
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: `${(minute / 60) * 100}%`,
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Main scrollable container for program content */}
|
|
<Box
|
|
ref={guideContainerRef}
|
|
style={{
|
|
flex: 1,
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{filteredChannels.length > 0 ? (
|
|
<GuideVirtualizedContext.Provider
|
|
value={{ contentWidth, nowPosition }}
|
|
>
|
|
<VariableSizeList
|
|
height={virtualizedHeight}
|
|
width={virtualizedWidth}
|
|
itemCount={filteredChannels.length}
|
|
itemSize={getItemSize}
|
|
estimatedItemSize={PROGRAM_HEIGHT}
|
|
outerRef={guideRef}
|
|
innerElementType={GuideInnerElement}
|
|
itemKey={itemKey}
|
|
itemData={listData}
|
|
ref={listRef}
|
|
overscanCount={5}
|
|
>
|
|
{GuideRow}
|
|
</VariableSizeList>
|
|
</GuideVirtualizedContext.Provider>
|
|
) : (
|
|
<Box
|
|
style={{
|
|
padding: '30px',
|
|
textAlign: '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 && (
|
|
<Modal
|
|
opened={recordChoiceOpen}
|
|
onClose={() => setRecordChoiceOpen(false)}
|
|
title={`Record: ${recordChoiceProgram.title}`}
|
|
centered
|
|
radius="md"
|
|
zIndex={9999}
|
|
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
|
|
styles={{
|
|
content: { backgroundColor: '#18181B', color: 'white' },
|
|
header: { backgroundColor: '#18181B', color: 'white' },
|
|
title: { color: 'white' },
|
|
}}
|
|
>
|
|
<Flex direction="column" gap="sm">
|
|
<Button onClick={() => { recordOne(recordChoiceProgram); setRecordChoiceOpen(false); }}>Just this one</Button>
|
|
<Button variant="light" onClick={() => { saveSeriesRule(recordChoiceProgram, 'all'); setRecordChoiceOpen(false); }}>Every episode</Button>
|
|
<Button variant="light" onClick={() => { saveSeriesRule(recordChoiceProgram, 'new'); setRecordChoiceOpen(false); }}>New episodes only</Button>
|
|
{recordingForProgram && (
|
|
<>
|
|
<Button color="orange" variant="light" onClick={async () => {
|
|
try {
|
|
await API.deleteRecording(recordingForProgram.id);
|
|
} catch (error) {
|
|
console.warn('Failed to delete recording', error);
|
|
}
|
|
try {
|
|
await useChannelsStore.getState().fetchRecordings();
|
|
} catch (error) {
|
|
console.warn('Failed to refresh recordings after delete', error);
|
|
}
|
|
setRecordChoiceOpen(false);
|
|
}}>Remove this recording</Button>
|
|
<Button color="red" variant="light" onClick={async () => {
|
|
try {
|
|
await API.bulkRemoveSeriesRecordings({ tvg_id: recordChoiceProgram.tvg_id, title: recordChoiceProgram.title, scope: 'title' });
|
|
} catch (error) {
|
|
console.warn('Failed to bulk remove series recordings', error);
|
|
}
|
|
try {
|
|
await API.deleteSeriesRule(recordChoiceProgram.tvg_id);
|
|
} catch (error) {
|
|
console.warn('Failed to delete series rule', error);
|
|
}
|
|
try {
|
|
await useChannelsStore.getState().fetchRecordings();
|
|
} catch (error) {
|
|
console.warn('Failed to refresh recordings after bulk delete', error);
|
|
}
|
|
setRecordChoiceOpen(false);
|
|
}}>Remove this series (scheduled)</Button>
|
|
</>
|
|
)}
|
|
{existingRuleMode && (
|
|
<Button
|
|
color="red"
|
|
variant="subtle"
|
|
onClick={async () => {
|
|
try {
|
|
await API.deleteSeriesRule(recordChoiceProgram.tvg_id);
|
|
} catch (error) {
|
|
console.warn('Failed to delete series rule', error);
|
|
}
|
|
setExistingRuleMode(null);
|
|
setRecordChoiceOpen(false);
|
|
}}
|
|
>
|
|
Remove series rule ({existingRuleMode})
|
|
</Button>
|
|
)}
|
|
</Flex>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Series rules modal */}
|
|
{rulesOpen && (
|
|
<Modal
|
|
opened={rulesOpen}
|
|
onClose={() => setRulesOpen(false)}
|
|
title="Series Recording Rules"
|
|
centered
|
|
radius="md"
|
|
zIndex={9999}
|
|
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
|
|
styles={{
|
|
content: { backgroundColor: '#18181B', color: 'white' },
|
|
header: { backgroundColor: '#18181B', color: 'white' },
|
|
title: { color: 'white' },
|
|
}}
|
|
>
|
|
<Stack gap="sm">
|
|
{(!rules || rules.length === 0) && (
|
|
<Text size="sm" c="dimmed">No series rules configured</Text>
|
|
)}
|
|
{rules && rules.map((r) => (
|
|
<Flex key={`${r.tvg_id}-${r.mode}`} justify="space-between" align="center">
|
|
<Text size="sm">{r.title || r.tvg_id} — {r.mode === 'new' ? 'New episodes' : 'Every episode'}</Text>
|
|
<Group gap="xs">
|
|
<Button size="xs" variant="subtle" onClick={async () => {
|
|
await API.evaluateSeriesRules(r.tvg_id);
|
|
try {
|
|
await useChannelsStore.getState().fetchRecordings();
|
|
} catch (error) {
|
|
console.warn('Failed to refresh recordings after evaluation', error);
|
|
}
|
|
notifications.show({ title: 'Evaluated', message: 'Checked for episodes' });
|
|
}}>Evaluate Now</Button>
|
|
<Button size="xs" variant="light" color="orange" onClick={async () => {
|
|
await API.bulkRemoveSeriesRecordings({ tvg_id: r.tvg_id, title: r.title, scope: 'title' });
|
|
try {
|
|
await API.deleteSeriesRule(r.tvg_id);
|
|
} catch (error) {
|
|
console.warn('Failed to delete series rule', error);
|
|
}
|
|
try {
|
|
await useChannelsStore.getState().fetchRecordings();
|
|
} catch (error) {
|
|
console.warn('Failed to refresh recordings after removing series', error);
|
|
}
|
|
const updated = await API.listSeriesRules();
|
|
setRules(updated);
|
|
}}>Remove this series (scheduled)</Button>
|
|
</Group>
|
|
</Flex>
|
|
))}
|
|
</Stack>
|
|
</Modal>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|