import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useRemoteControl } from '../hooks/useRemoteControl'; import { fetchChannels, fetchChannelAirings } from '../api'; const EPG_HOURS = 4; const HOUR_WIDTH_PX = 360; const PX_PER_MS = HOUR_WIDTH_PX / (60 * 60 * 1000); function fmtTime(iso) { if (!iso) return ''; return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); } function fmtDuration(starts_at, ends_at) { if (!starts_at || !ends_at) return ''; const mins = Math.round((new Date(ends_at) - new Date(starts_at)) / 60000); if (mins < 60) return `${mins}m`; const h = Math.floor(mins / 60), m = mins % 60; return m ? `${h}h ${m}m` : `${h}h`; } export default function Guide({ onClose, onSelectChannel }) { const [channels, setChannels] = useState([]); const [airings, setAirings] = useState({}); const [selectedRow, setSelectedRow] = useState(0); const [selectedProgram, setSelectedProgram] = useState(0); const [now, setNow] = useState(Date.now()); const scrollRef = useRef(null); const programRefs = useRef({}); // { `${rowIdx}-${progIdx}`: el } const rowsScrollRef = useRef(null); const anchorTime = useRef(new Date(Math.floor(Date.now() / 1800000) * 1800000).getTime()); // Build filtered + sorted airing list for a channel const getAiringsForRow = useCallback((chanId) => { const list = airings[chanId] || []; return list .filter(a => new Date(a.ends_at).getTime() > anchorTime.current) .sort((a, b) => new Date(a.starts_at) - new Date(b.starts_at)); }, [airings]); // Clamp selectedProgram when row changes useEffect(() => { const ch = channels[selectedRow]; if (!ch) return; const rowAirings = getAiringsForRow(ch.id); setSelectedProgram(prev => Math.min(prev, Math.max(0, rowAirings.length - 1))); }, [selectedRow, channels, getAiringsForRow]); // Auto-scroll focused program into view (timeline scroll) useEffect(() => { const key = `${selectedRow}-${selectedProgram}`; const el = programRefs.current[key]; if (el && scrollRef.current) { const containerRect = scrollRef.current.getBoundingClientRect(); const elRect = el.getBoundingClientRect(); const relLeft = elRect.left - containerRect.left + scrollRef.current.scrollLeft; const targetScroll = relLeft - containerRect.width / 4; scrollRef.current.scrollTo({ left: Math.max(0, targetScroll), behavior: 'smooth' }); } }, [selectedRow, selectedProgram]); // Auto-scroll focused row into view (rows column scroll) useEffect(() => { if (rowsScrollRef.current) { const rowHeight = 80; const visibleHeight = rowsScrollRef.current.clientHeight; const rowTop = selectedRow * rowHeight; const rowBottom = rowTop + rowHeight; const currentScroll = rowsScrollRef.current.scrollTop; if (rowTop < currentScroll) { rowsScrollRef.current.scrollTop = rowTop; } else if (rowBottom > currentScroll + visibleHeight) { rowsScrollRef.current.scrollTop = rowBottom - visibleHeight; } } }, [selectedRow]); useRemoteControl({ onUp: () => setSelectedRow(prev => Math.max(0, prev - 1)), onDown: () => setSelectedRow(prev => Math.min(channels.length - 1, prev + 1)), onLeft: () => setSelectedProgram(prev => Math.max(0, prev - 1)), onRight: () => { const ch = channels[selectedRow]; if (!ch) return; const rowAirings = getAiringsForRow(ch.id); setSelectedProgram(prev => Math.min(rowAirings.length - 1, prev + 1)); }, onSelect: () => channels[selectedRow] && onSelectChannel(channels[selectedRow].id), onBack: onClose, }); // Clock tick useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 15000); return () => clearInterval(timer); }, []); // Fetch channels and airings useEffect(() => { fetchChannels() .then(data => { const sorted = [...data].sort((a, b) => { if (a.channel_number == null) return 1; if (b.channel_number == null) return -1; return a.channel_number - b.channel_number; }); if (sorted.length === 0) { setChannels([{ id: 99, channel_number: 99, name: 'No Channels Found' }]); return; } setChannels(sorted); Promise.allSettled( data.map(ch => fetchChannelAirings(ch.id, EPG_HOURS) .then(list => ({ channelId: ch.id, list })) .catch(() => ({ channelId: ch.id, list: [] })) ) ).then(results => { const map = {}; for (const r of results) { if (r.status === 'fulfilled') map[r.value.channelId] = r.value.list; } setAirings(map); }); }) .catch(err => { console.error('Guide fetch error:', err); setChannels([{ id: 99, channel_number: 99, name: 'Network Error' }]); }); }, []); // Initial scroll to now useEffect(() => { if (scrollRef.current) { const msOffset = now - anchorTime.current; const pxOffset = msOffset * PX_PER_MS; scrollRef.current.scrollLeft = Math.max(0, pxOffset - HOUR_WIDTH_PX / 2); } }, [airings]); // only run once airings load const currentTimeStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const playheadPx = (now - anchorTime.current) * PX_PER_MS; const containerWidthPx = EPG_HOURS * HOUR_WIDTH_PX + HOUR_WIDTH_PX; const timeSlots = Array.from({ length: EPG_HOURS * 2 + 2 }).map((_, i) => { const ts = new Date(anchorTime.current + i * 30 * 60 * 1000); return { label: ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), left: i * (HOUR_WIDTH_PX / 2), }; }); // Focused program info for the detail panel const focusedChannel = channels[selectedRow]; const focusedAirings = focusedChannel ? getAiringsForRow(focusedChannel.id) : []; const focusedAiring = focusedAirings[selectedProgram] || null; const nowStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const isLive = focusedAiring ? now >= new Date(focusedAiring.starts_at).getTime() && now < new Date(focusedAiring.ends_at).getTime() : false; // Progress % for live program const liveProgress = isLive && focusedAiring ? Math.min(100, Math.round( (now - new Date(focusedAiring.starts_at).getTime()) / (new Date(focusedAiring.ends_at).getTime() - new Date(focusedAiring.starts_at).getTime()) * 100 )) : 0; return (