feat(main): commit

This commit is contained in:
2026-03-08 16:48:58 -04:00
parent 567766eaed
commit f37382d2b8
29 changed files with 3735 additions and 223 deletions

View File

@@ -1,69 +1,195 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useRemoteControl } from '../hooks/useRemoteControl';
import { fetchChannels } from '../api';
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' });
}
export default function Guide({ onClose, onSelectChannel }) {
const [channels, setChannels] = useState([]);
const [airings, setAirings] = useState({}); // { channelId: [airing, ...] }
const [selectedIndex, setSelectedIndex] = useState(0);
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());
useRemoteControl({
onUp: () => setSelectedIndex(prev => (prev - 1 + channels.length) % channels.length),
onDown: () => setSelectedIndex(prev => (prev + 1) % channels.length),
onSelect: () => onSelectChannel(channels[selectedIndex].id),
onBack: onClose
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,
});
const [currentTime, setCurrentTime] = useState(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
// Clock tick
useEffect(() => {
fetchChannels().then(data => {
// Map channels securely, providing a fallback block if properties are missing
const mapped = data.map(ch => ({
...ch,
currentlyPlaying: ch.currentlyPlaying || { title: 'Live Broadcast', time: 'Now Playing' }
}));
if (mapped.length > 0) {
setChannels(mapped);
} else {
setChannels([{id: 99, channel_number: '99', name: 'No Channels Found', currentlyPlaying: {title: 'Empty Database', time: '--'}}]);
}
}).catch(err => {
console.error(err);
setChannels([{id: 99, channel_number: '99', name: 'Network Error', currentlyPlaying: {title: 'Could not reach PyTV server', time: '--'}}]);
});
}, []);
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
}, 60000);
const timer = setInterval(() => setNow(Date.now()), 15000); // 15s refresh
return () => clearInterval(timer);
}, []);
// Fetch channels and airings
useEffect(() => {
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' }]);
});
}, []);
// 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;
return (
<div className="guide-container open">
<div className="guide-header">
<h1>PYTV Guide</h1>
<div className="guide-clock">{currentTime}</div>
<div className="guide-clock">{currentTimeStr}</div>
</div>
<div className="guide-grid">
{channels.length === 0 ? <p style={{color: 'white'}}>Loading TV Guide...</p> :
channels.map((chan, idx) => (
<div key={chan.id} className={`guide-row ${idx === selectedIndex ? 'active' : ''}`}>
<div className="guide-ch-col">
{chan.channel_number}
<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>
))}
</div>
<div className="guide-prog-col">
<div className="guide-prog-title">{chan.name} - {chan.currentlyPlaying.title}</div>
<div className="guide-prog-time">{chan.currentlyPlaying.time}</div>
{/* 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>
);
})}
</div>
</div>
))}
</div>
</div>
<div style={{ marginTop: '2rem', color: 'var(--pytv-text-dim)', textAlign: 'center' }}>
Press <span style={{color: '#fff'}}>Enter</span> to tune to the selected channel. Press <span style={{color: '#fff'}}>Escape</span> to exit guide.
<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
</div>
</div>
);