import React, { useState, useEffect, useRef } from 'react'; import { useRemoteControl } from '../hooks/useRemoteControl'; import { fetchChannels, fetchChannelAirings } from '../api'; // Hours to show in the EPG and width configs 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' }); } export default function Guide({ onClose, onSelectChannel }) { const [channels, setChannels] = useState([]); const [airings, setAirings] = useState({}); // { channelId: [airing, ...] } const [selectedIndex, setSelectedIndex] = useState(0); const [now, setNow] = useState(Date.now()); const scrollRef = useRef(null); // Time anchor: align to previous 30-min boundary for clean axis const anchorTime = useRef(new Date(Math.floor(Date.now() / 1800000) * 1800000).getTime()); useRemoteControl({ onUp: () => setSelectedIndex(prev => (prev - 1 + channels.length) % channels.length), onDown: () => setSelectedIndex(prev => (prev + 1) % channels.length), onSelect: () => channels[selectedIndex] && onSelectChannel(channels[selectedIndex].id), onBack: onClose, }); // Clock tick useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 15000); // 15s refresh return () => clearInterval(timer); }, []); // Fetch channels and airings useEffect(() => { fetchChannels() .then(data => { if (!data || data.length === 0) { setChannels([{ id: 99, channel_number: 99, name: 'No Channels Found' }]); return; } setChannels(data); // Fetch overlapping data for timeline 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' }]); }); }, []); // Auto-scroll the timeline to keep the playhead visible but leave // ~0.5 hours of padding on the left useEffect(() => { if (scrollRef.current) { const msOffset = now - anchorTime.current; const pxOffset = msOffset * PX_PER_MS; // scroll left to (current time - 30 mins) const targetScroll = Math.max(0, pxOffset - (HOUR_WIDTH_PX / 2)); scrollRef.current.scrollLeft = targetScroll; } }, [now, airings]); const currentTimeStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const playheadPx = (now - anchorTime.current) * PX_PER_MS; // Generate grid time slots (half-hour chunks) 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) }; }); // Background pattern width const containerWidthPx = (EPG_HOURS * HOUR_WIDTH_PX) + HOUR_WIDTH_PX; return (

PYTV Guide

{currentTimeStr}
{/* Left fixed column for Channels */}
{channels.map((chan, idx) => (
{chan.channel_number}
{chan.name}
))}
{/* Scrollable Timeline */}
{/* Time Axis Row */}
{timeSlots.map((slot, i) => (
{slot.label}
))}
{/* Live Playhead Line */} {playheadPx > 0 && playheadPx < containerWidthPx && (
)} {/* Grid Rows for Airings */}
{channels.map((chan, idx) => { const chanAirings = airings[chan.id]; const isLoading = !chanAirings && chan.id !== 99; return (
{isLoading &&
Loading...
} {!isLoading && chanAirings?.length === 0 && (
No scheduled programs
)} {chanAirings && chanAirings.map(a => { const sTs = new Date(a.starts_at).getTime(); const eTs = new Date(a.ends_at).getTime(); // Filter anything that ended before our timeline anchor if (eTs <= anchorTime.current) return null; // Calculate block dimensions 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'; if (now >= eTs) stateClass = 'past'; return (
onSelectChannel(chan.id)} >
{a.media_item_title}
{fmtTime(a.starts_at)} - {fmtTime(a.ends_at)}
); })}
); })}
Press Enter to tune · ↑↓ to navigate channels · Escape to close
); }