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

334 lines
14 KiB
React
Raw Normal View History

2026-03-09 13:29:23 -04:00
import React, { useState, useEffect, useRef, useCallback } 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';
const EPG_HOURS = 4;
2026-03-09 13:29:23 -04:00
const HOUR_WIDTH_PX = 360;
2026-03-08 16:48:58 -04:00
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
2026-03-09 13:29:23 -04:00
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`;
}
2026-03-08 11:28:59 -04:00
export default function Guide({ onClose, onSelectChannel }) {
const [channels, setChannels] = useState([]);
2026-03-09 13:29:23 -04:00
const [airings, setAirings] = useState({});
const [selectedRow, setSelectedRow] = useState(0);
const [selectedProgram, setSelectedProgram] = useState(0);
2026-03-08 16:48:58 -04:00
const [now, setNow] = useState(Date.now());
const scrollRef = useRef(null);
2026-03-09 13:29:23 -04:00
const programRefs = useRef({}); // { `${rowIdx}-${progIdx}`: el }
const rowsScrollRef = useRef(null);
2026-03-08 16:48:58 -04:00
const anchorTime = useRef(new Date(Math.floor(Date.now() / 1800000) * 1800000).getTime());
2026-03-08 11:28:59 -04:00
2026-03-09 13:29:23 -04:00
// 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]);
2026-03-08 11:28:59 -04:00
useRemoteControl({
2026-03-09 13:29:23 -04:00
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,
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-09 13:29:23 -04:00
const timer = setInterval(() => setNow(Date.now()), 15000);
2026-03-08 16:48:58 -04:00
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 => {
2026-03-09 13:29:23 -04:00
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) {
2026-03-08 16:48:58 -04:00
setChannels([{ id: 99, channel_number: 99, name: 'No Channels Found' }]);
return;
}
2026-03-09 13:29:23 -04:00
setChannels(sorted);
2026-03-08 16:48:58 -04:00
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) {
2026-03-09 13:29:23 -04:00
if (r.status === 'fulfilled') map[r.value.channelId] = r.value.list;
2026-03-08 16:48:58 -04:00
}
setAirings(map);
});
})
2026-03-09 13:29:23 -04:00
.catch(err => {
console.error('Guide fetch error:', err);
2026-03-08 16:48:58 -04:00
setChannels([{ id: 99, channel_number: 99, name: 'Network Error' }]);
});
2026-03-08 11:28:59 -04:00
}, []);
2026-03-09 13:29:23 -04:00
// Initial scroll to now
2026-03-08 16:48:58 -04:00
useEffect(() => {
if (scrollRef.current) {
const msOffset = now - anchorTime.current;
const pxOffset = msOffset * PX_PER_MS;
2026-03-09 13:29:23 -04:00
scrollRef.current.scrollLeft = Math.max(0, pxOffset - HOUR_WIDTH_PX / 2);
2026-03-08 16:48:58 -04:00
}
2026-03-09 13:29:23 -04:00
}, [airings]); // only run once airings load
2026-03-08 16:48:58 -04:00
const currentTimeStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const playheadPx = (now - anchorTime.current) * PX_PER_MS;
2026-03-09 13:29:23 -04:00
const containerWidthPx = EPG_HOURS * HOUR_WIDTH_PX + HOUR_WIDTH_PX;
2026-03-08 16:48:58 -04:00
const timeSlots = Array.from({ length: EPG_HOURS * 2 + 2 }).map((_, i) => {
2026-03-09 13:29:23 -04:00
const ts = new Date(anchorTime.current + i * 30 * 60 * 1000);
2026-03-08 16:48:58 -04:00
return {
label: ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
2026-03-09 13:29:23 -04:00
left: i * (HOUR_WIDTH_PX / 2),
2026-03-08 16:48:58 -04:00
};
});
2026-03-09 13:29:23 -04:00
// 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;
2026-03-08 16:48:58 -04:00
2026-03-08 11:28:59 -04:00
return (
2026-03-09 13:29:23 -04:00
<div className="guide-container open" style={{ paddingBottom: 0 }}>
{/* Header */}
2026-03-08 11:28:59 -04:00
<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
2026-03-09 13:29:23 -04:00
{/* EPG grid — takes available space between header and detail panel */}
<div className="epg-wrapper" style={{ flex: 1, minHeight: 0 }}>
{/* Left sticky channel column */}
<div className="epg-channels-col" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div className="epg-corner" />
<div ref={rowsScrollRef} className="epg-grid-rows" style={{ overflowY: 'hidden', flex: 1 }}>
2026-03-08 16:48:58 -04:00
{channels.map((chan, idx) => (
2026-03-09 13:29:23 -04:00
<div
key={chan.id}
className={`epg-row ${idx === selectedRow ? 'active' : ''}`}
onClick={() => { setSelectedRow(idx); setSelectedProgram(0); }}
>
2026-03-08 16:48:58 -04:00
<div className="epg-ch-num">{chan.channel_number}</div>
<div className="epg-ch-name">{chan.name}</div>
</div>
))}
</div>
</div>
2026-03-09 13:29:23 -04:00
{/* Scrollable timeline */}
2026-03-08 16:48:58 -04:00
<div className="epg-timeline-scroll" ref={scrollRef}>
2026-03-09 13:29:23 -04:00
<div
className="epg-timeline-container"
2026-03-08 16:48:58 -04:00
style={{ width: `${containerWidthPx}px`, backgroundSize: `${HOUR_WIDTH_PX / 2}px 100%` }}
>
2026-03-09 13:29:23 -04:00
{/* Time axis */}
2026-03-08 16:48:58 -04:00
<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
2026-03-09 13:29:23 -04:00
{/* Live playhead */}
2026-03-08 16:48:58 -04:00
{playheadPx > 0 && playheadPx < containerWidthPx && (
<div className="epg-playhead" style={{ left: `${playheadPx}px` }} />
)}
2026-03-09 13:29:23 -04:00
{/* Program rows */}
2026-03-08 16:48:58 -04:00
<div className="epg-grid-rows">
2026-03-09 13:29:23 -04:00
{channels.map((chan, rowIdx) => {
const rowAirings = getAiringsForRow(chan.id);
const isLoading = !airings[chan.id] && chan.id !== 99;
const isActiveRow = rowIdx === selectedRow;
2026-03-08 16:48:58 -04:00
return (
2026-03-09 13:29:23 -04:00
<div
key={chan.id}
className={`epg-row ${isActiveRow ? 'active' : ''}`}
>
{isLoading && <div className="epg-loading">Loading</div>}
{!isLoading && rowAirings.length === 0 && (
2026-03-08 16:48:58 -04:00
<div className="epg-empty">No scheduled programs</div>
)}
2026-03-09 13:29:23 -04:00
{rowAirings.map((a, progIdx) => {
2026-03-08 16:48:58 -04:00
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';
2026-03-09 13:29:23 -04:00
else if (now >= eTs) stateClass = 'past';
const isFocused = isActiveRow && progIdx === selectedProgram;
2026-03-08 16:48:58 -04:00
return (
2026-03-09 13:29:23 -04:00
<div
key={a.id}
ref={el => {
const key = `${rowIdx}-${progIdx}`;
if (el) programRefs.current[key] = el;
}}
className={`epg-program ${stateClass}${isFocused ? ' epg-program-focused' : ''}`}
2026-03-08 16:48:58 -04:00
style={{ left: `${startPx}px`, width: `${widthPx}px` }}
2026-03-09 13:29:23 -04:00
onClick={() => {
setSelectedRow(rowIdx);
setSelectedProgram(progIdx);
onSelectChannel(chan.id);
}}
onMouseEnter={() => {
setSelectedRow(rowIdx);
setSelectedProgram(progIdx);
}}
2026-03-08 16:48:58 -04:00
>
<div className="epg-program-title">{a.media_item_title}</div>
2026-03-09 13:29:23 -04:00
<div className="epg-program-time">{fmtTime(a.starts_at)} {fmtTime(a.ends_at)}</div>
2026-03-08 16:48:58 -04:00
</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
2026-03-09 13:29:23 -04:00
{/* ── Detail panel ─────────────────────────────────────────────────── */}
<div className="epg-detail-panel">
{focusedAiring ? (
<>
<div className="epg-detail-left">
{isLive && <span className="epg-live-badge"> LIVE</span>}
<div className="epg-detail-title">{focusedAiring.media_item_title || '—'}</div>
<div className="epg-detail-meta">
<span>{fmtTime(focusedAiring.starts_at)} {fmtTime(focusedAiring.ends_at)}</span>
<span className="epg-detail-dot">·</span>
<span>{fmtDuration(focusedAiring.starts_at, focusedAiring.ends_at)}</span>
{focusedChannel && (
<>
<span className="epg-detail-dot">·</span>
<span>CH {focusedChannel.channel_number} · {focusedChannel.name}</span>
</>
)}
</div>
{isLive && (
<div className="epg-progress-bar">
<div className="epg-progress-fill" style={{ width: `${liveProgress}%` }} />
</div>
)}
</div>
<div className="epg-detail-right">
<div className="epg-detail-hint">
<kbd></kbd> Channel &nbsp; <kbd></kbd> Program &nbsp; <kbd>Enter</kbd> Watch &nbsp; <kbd>Esc</kbd> Close
</div>
{isLive && (
<button className="btn-accent" style={{ fontSize: '0.85rem', padding: '0.4rem 1rem' }}
onClick={() => onSelectChannel(focusedChannel.id)}>
Watch Now
</button>
)}
</div>
</>
) : focusedChannel ? (
<div className="epg-detail-left" style={{ opacity: 0.5 }}>
<div className="epg-detail-title">{focusedChannel.name}</div>
<div className="epg-detail-meta">No programs scheduled <kbd></kbd> to navigate channels</div>
</div>
) : (
<div className="epg-detail-left" style={{ opacity: 0.4 }}>
<div className="epg-detail-meta"><kbd></kbd> Channel &nbsp; <kbd></kbd> Program &nbsp; <kbd>Enter</kbd> Watch</div>
</div>
)}
2026-03-08 11:28:59 -04:00
</div>
</div>
);
}