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 (
{/* Header */}

PYTV Guide

{currentTimeStr}
{/* EPG grid — takes available space between header and detail panel */}
{/* Left sticky channel column */}
{channels.map((chan, idx) => (
{ setSelectedRow(idx); setSelectedProgram(0); }} >
{chan.channel_number}
{chan.name}
))}
{/* Scrollable timeline */}
{/* Time axis */}
{timeSlots.map((slot, i) => (
{slot.label}
))}
{/* Live playhead */} {playheadPx > 0 && playheadPx < containerWidthPx && (
)} {/* Program rows */}
{channels.map((chan, rowIdx) => { const rowAirings = getAiringsForRow(chan.id); const isLoading = !airings[chan.id] && chan.id !== 99; const isActiveRow = rowIdx === selectedRow; return (
{isLoading &&
Loading…
} {!isLoading && rowAirings.length === 0 && (
No scheduled programs
)} {rowAirings.map((a, progIdx) => { const sTs = new Date(a.starts_at).getTime(); const eTs = new Date(a.ends_at).getTime(); const startPx = Math.max(0, (sTs - anchorTime.current) * PX_PER_MS); const rawEndPx = (eTs - anchorTime.current) * PX_PER_MS; const endPx = Math.min(containerWidthPx, rawEndPx); const widthPx = Math.max(2, endPx - startPx); let stateClass = 'future'; if (now >= sTs && now < eTs) stateClass = 'current'; else if (now >= eTs) stateClass = 'past'; const isFocused = isActiveRow && progIdx === selectedProgram; return (
{ const key = `${rowIdx}-${progIdx}`; if (el) programRefs.current[key] = el; }} className={`epg-program ${stateClass}${isFocused ? ' epg-program-focused' : ''}`} style={{ left: `${startPx}px`, width: `${widthPx}px` }} onClick={() => { setSelectedRow(rowIdx); setSelectedProgram(progIdx); onSelectChannel(chan.id); }} onMouseEnter={() => { setSelectedRow(rowIdx); setSelectedProgram(progIdx); }} >
{a.media_item_title}
{fmtTime(a.starts_at)} – {fmtTime(a.ends_at)}
); })}
); })}
{/* ── Detail panel ─────────────────────────────────────────────────── */}
{focusedAiring ? ( <>
{isLive && ● LIVE}
{focusedAiring.media_item_title || '—'}
{fmtTime(focusedAiring.starts_at)} – {fmtTime(focusedAiring.ends_at)} · {fmtDuration(focusedAiring.starts_at, focusedAiring.ends_at)} {focusedChannel && ( <> · CH {focusedChannel.channel_number} · {focusedChannel.name} )}
{isLive && (
)}
↑↓ Channel   ←→ Program   Enter Watch   Esc Close
{isLive && ( )}
) : focusedChannel ? (
{focusedChannel.name}
No programs scheduled — ↑↓ to navigate channels
) : (
↑↓ Channel   ←→ Program   Enter Watch
)}
); }