Files
PYTV/frontend/src/components/Guide.jsx
2026-03-09 13:29:23 -04:00

334 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="guide-container open" style={{ paddingBottom: 0 }}>
{/* Header */}
<div className="guide-header">
<h1>PYTV Guide</h1>
<div className="guide-clock">{currentTimeStr}</div>
</div>
{/* 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 }}>
{channels.map((chan, idx) => (
<div
key={chan.id}
className={`epg-row ${idx === selectedRow ? 'active' : ''}`}
onClick={() => { setSelectedRow(idx); setSelectedProgram(0); }}
>
<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 */}
<div className="epg-time-axis">
{timeSlots.map((slot, i) => (
<div key={i} className="epg-time-slot" style={{ left: `${slot.left}px` }}>
{slot.label}
</div>
))}
</div>
{/* Live playhead */}
{playheadPx > 0 && playheadPx < containerWidthPx && (
<div className="epg-playhead" style={{ left: `${playheadPx}px` }} />
)}
{/* Program rows */}
<div className="epg-grid-rows">
{channels.map((chan, rowIdx) => {
const rowAirings = getAiringsForRow(chan.id);
const isLoading = !airings[chan.id] && chan.id !== 99;
const isActiveRow = rowIdx === selectedRow;
return (
<div
key={chan.id}
className={`epg-row ${isActiveRow ? 'active' : ''}`}
>
{isLoading && <div className="epg-loading">Loading</div>}
{!isLoading && rowAirings.length === 0 && (
<div className="epg-empty">No scheduled programs</div>
)}
{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 (
<div
key={a.id}
ref={el => {
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);
}}
>
<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>
);
})}
</div>
</div>
</div>
</div>
{/* ── 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>
)}
</div>
</div>
);
}