Files
PYTV/frontend/src/components/Guide.jsx

197 lines
7.5 KiB
React
Raw Normal View History

2026-03-08 16:48:58 -04:00
import React, { useState, useEffect, useRef } from 'react';
2026-03-08 11:28:59 -04:00
import { useRemoteControl } from '../hooks/useRemoteControl';
2026-03-08 16:48:58 -04:00
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' });
}
2026-03-08 11:28:59 -04:00
export default function Guide({ onClose, onSelectChannel }) {
const [channels, setChannels] = useState([]);
2026-03-08 16:48:58 -04:00
const [airings, setAirings] = useState({}); // { channelId: [airing, ...] }
2026-03-08 11:28:59 -04:00
const [selectedIndex, setSelectedIndex] = useState(0);
2026-03-08 16:48:58 -04:00
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());
2026-03-08 11:28:59 -04:00
useRemoteControl({
2026-03-08 16:48:58 -04:00
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,
2026-03-08 11:28:59 -04:00
});
2026-03-08 16:48:58 -04:00
// Clock tick
2026-03-08 11:28:59 -04:00
useEffect(() => {
2026-03-08 16:48:58 -04:00
const timer = setInterval(() => setNow(Date.now()), 15000); // 15s refresh
return () => clearInterval(timer);
2026-03-08 11:28:59 -04:00
}, []);
2026-03-08 16:48:58 -04:00
// Fetch channels and airings
2026-03-08 11:28:59 -04:00
useEffect(() => {
2026-03-08 16:48:58 -04:00
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' }]);
});
2026-03-08 11:28:59 -04:00
}, []);
2026-03-08 16:48:58 -04:00
// 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;
2026-03-08 11:28:59 -04:00
return (
<div className="guide-container open">
<div className="guide-header">
<h1>PYTV Guide</h1>
2026-03-08 16:48:58 -04:00
<div className="guide-clock">{currentTimeStr}</div>
2026-03-08 11:28:59 -04:00
</div>
2026-03-08 16:48:58 -04:00
<div className="epg-wrapper">
{/* Left fixed column for Channels */}
<div className="epg-channels-col">
<div className="epg-corner"></div>
<div className="epg-grid-rows">
{channels.map((chan, idx) => (
<div key={chan.id} className={`epg-row ${idx === selectedIndex ? 'active' : ''}`}>
<div className="epg-ch-num">{chan.channel_number}</div>
<div className="epg-ch-name">{chan.name}</div>
</div>
))}
</div>
</div>
{/* Scrollable Timeline */}
<div className="epg-timeline-scroll" ref={scrollRef}>
<div
className="epg-timeline-container"
style={{ width: `${containerWidthPx}px`, backgroundSize: `${HOUR_WIDTH_PX / 2}px 100%` }}
>
{/* Time Axis Row */}
<div className="epg-time-axis">
{timeSlots.map((slot, i) => (
<div key={i} className="epg-time-slot" style={{ left: `${slot.left}px` }}>
{slot.label}
</div>
))}
2026-03-08 11:28:59 -04:00
</div>
2026-03-08 16:48:58 -04:00
{/* Live Playhead Line */}
{playheadPx > 0 && playheadPx < containerWidthPx && (
<div className="epg-playhead" style={{ left: `${playheadPx}px` }} />
)}
{/* Grid Rows for Airings */}
<div className="epg-grid-rows">
{channels.map((chan, idx) => {
const chanAirings = airings[chan.id];
const isLoading = !chanAirings && chan.id !== 99;
return (
<div key={chan.id} className={`epg-row ${idx === selectedIndex ? 'active' : ''}`}>
{isLoading && <div className="epg-loading">Loading...</div>}
{!isLoading && chanAirings?.length === 0 && (
<div className="epg-empty">No scheduled programs</div>
)}
{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 (
<div
key={a.id}
className={`epg-program ${stateClass}`}
style={{ left: `${startPx}px`, width: `${widthPx}px` }}
onClick={() => onSelectChannel(chan.id)}
>
<div className="epg-program-title">{a.media_item_title}</div>
<div className="epg-program-time">{fmtTime(a.starts_at)} - {fmtTime(a.ends_at)}</div>
</div>
);
})}
</div>
);
})}
2026-03-08 11:28:59 -04:00
</div>
</div>
2026-03-08 16:48:58 -04:00
</div>
2026-03-08 11:28:59 -04:00
</div>
2026-03-08 16:48:58 -04:00
<div style={{ padding: '0 3rem', flexShrink: 0, marginTop: '2rem', color: 'var(--pytv-text-dim)', textAlign: 'center' }}>
Press <span style={{ color: '#fff' }}>Enter</span> to tune &middot; <span style={{ color: '#fff' }}>&uarr;&darr;</span> to navigate channels &middot; <span style={{ color: '#fff' }}>Escape</span> to close
2026-03-08 11:28:59 -04:00
</div>
</div>
);
}