feat(main): main

This commit is contained in:
2026-03-09 13:29:23 -04:00
parent f14454b4c8
commit b1a93161c0
22 changed files with 719 additions and 192 deletions

View File

@@ -1,10 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useRemoteControl } from '../hooks/useRemoteControl';
import { fetchChannels, fetchChannelAirings } from '../api';
// Hours to show in the EPG and width configs
const EPG_HOURS = 4;
const HOUR_WIDTH_PX = 360;
const HOUR_WIDTH_PX = 360;
const PX_PER_MS = HOUR_WIDTH_PX / (60 * 60 * 1000);
function fmtTime(iso) {
@@ -12,26 +11,88 @@ function fmtTime(iso) {
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({}); // { channelId: [airing, ...] }
const [selectedIndex, setSelectedIndex] = useState(0);
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);
// Time anchor: align to previous 30-min boundary for clean axis
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: () => setSelectedIndex(prev => (prev - 1 + channels.length) % channels.length),
onDown: () => setSelectedIndex(prev => (prev + 1) % channels.length),
onSelect: () => channels[selectedIndex] && onSelectChannel(channels[selectedIndex].id),
onBack: onClose,
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); // 15s refresh
const timer = setInterval(() => setNow(Date.now()), 15000);
return () => clearInterval(timer);
}, []);
@@ -39,13 +100,17 @@ export default function Guide({ onClose, onSelectChannel }) {
useEffect(() => {
fetchChannels()
.then(data => {
if (!data || data.length === 0) {
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(data);
setChannels(sorted);
// Fetch overlapping data for timeline
Promise.allSettled(
data.map(ch =>
fetchChannelAirings(ch.id, EPG_HOURS)
@@ -55,61 +120,77 @@ export default function Guide({ onClose, onSelectChannel }) {
).then(results => {
const map = {};
for (const r of results) {
if (r.status === 'fulfilled') {
map[r.value.channelId] = r.value.list;
}
if (r.status === 'fulfilled') map[r.value.channelId] = r.value.list;
}
setAirings(map);
});
})
.catch((err) => {
console.error("Guide fetch error:", err);
.catch(err => {
console.error('Guide fetch error:', err);
setChannels([{ id: 99, channel_number: 99, name: 'Network Error' }]);
});
}, []);
// Auto-scroll the timeline to keep the playhead visible but leave
// ~0.5 hours of padding on the left
// Initial scroll to now
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;
scrollRef.current.scrollLeft = Math.max(0, pxOffset - HOUR_WIDTH_PX / 2);
}
}, [now, airings]);
}, [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;
// 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));
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)
left: i * (HOUR_WIDTH_PX / 2),
};
});
// Background pattern width
const containerWidthPx = (EPG_HOURS * HOUR_WIDTH_PX) + HOUR_WIDTH_PX;
// 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">
<div className="guide-container open" style={{ paddingBottom: 0 }}>
{/* Header */}
<div className="guide-header">
<h1>PYTV Guide</h1>
<div className="guide-clock">{currentTimeStr}</div>
</div>
<div className="epg-wrapper">
{/* Left fixed column for Channels */}
<div className="epg-channels-col">
<div className="epg-corner"></div>
<div className="epg-grid-rows">
{/* 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 === selectedIndex ? 'active' : ''}`}>
<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>
@@ -117,13 +198,13 @@ export default function Guide({ onClose, onSelectChannel }) {
</div>
</div>
{/* Scrollable Timeline */}
{/* Scrollable timeline */}
<div className="epg-timeline-scroll" ref={scrollRef}>
<div
className="epg-timeline-container"
<div
className="epg-timeline-container"
style={{ width: `${containerWidthPx}px`, backgroundSize: `${HOUR_WIDTH_PX / 2}px 100%` }}
>
{/* Time Axis Row */}
{/* Time axis */}
<div className="epg-time-axis">
{timeSlots.map((slot, i) => (
<div key={i} className="epg-time-slot" style={{ left: `${slot.left}px` }}>
@@ -132,32 +213,31 @@ export default function Guide({ onClose, onSelectChannel }) {
))}
</div>
{/* Live Playhead Line */}
{/* Live playhead */}
{playheadPx > 0 && playheadPx < containerWidthPx && (
<div className="epg-playhead" style={{ left: `${playheadPx}px` }} />
)}
{/* Grid Rows for Airings */}
{/* Program rows */}
<div className="epg-grid-rows">
{channels.map((chan, idx) => {
const chanAirings = airings[chan.id];
const isLoading = !chanAirings && chan.id !== 99;
{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 ${idx === selectedIndex ? 'active' : ''}`}>
{isLoading && <div className="epg-loading">Loading...</div>}
{!isLoading && chanAirings?.length === 0 && (
<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>
)}
{chanAirings && chanAirings.map(a => {
{rowAirings.map((a, progIdx) => {
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);
@@ -165,17 +245,31 @@ export default function Guide({ onClose, onSelectChannel }) {
let stateClass = 'future';
if (now >= sTs && now < eTs) stateClass = 'current';
if (now >= eTs) stateClass = 'past';
else if (now >= eTs) stateClass = 'past';
const isFocused = isActiveRow && progIdx === selectedProgram;
return (
<div
key={a.id}
className={`epg-program ${stateClass}`}
<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={() => onSelectChannel(chan.id)}
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 className="epg-program-time">{fmtTime(a.starts_at)} {fmtTime(a.ends_at)}</div>
</div>
);
})}
@@ -185,11 +279,54 @@ export default function Guide({ onClose, onSelectChannel }) {
</div>
</div>
</div>
</div>
<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
{/* ── 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>
);