334 lines
14 KiB
JavaScript
334 lines
14 KiB
JavaScript
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 <kbd>←→</kbd> Program <kbd>Enter</kbd> Watch <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 <kbd>←→</kbd> Program <kbd>Enter</kbd> Watch</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|