feat(main): commit
This commit is contained in:
@@ -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 · <span style={{ color: '#fff' }}>↑↓</span> to navigate channels · <span style={{ color: '#fff' }}>Escape</span> to close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user