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 · <span style={{ color: '#fff' }}>↑↓</span> to navigate channels · <span style={{ color: '#fff' }}>Escape</span> to close
|
2026-03-08 11:28:59 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|