feat(main): initial commit
This commit is contained in:
134
frontend/src/components/ChannelTuner.jsx
Normal file
134
frontend/src/components/ChannelTuner.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useRemoteControl } from '../hooks/useRemoteControl';
|
||||
import { fetchChannels } from '../api';
|
||||
|
||||
const FALLBACK_VIDEOS = [
|
||||
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
||||
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4',
|
||||
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4'
|
||||
];
|
||||
|
||||
export default function ChannelTuner({ onOpenGuide }) {
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [showOSD, setShowOSD] = useState(true);
|
||||
const osdTimerRef = useRef(null);
|
||||
|
||||
// The 3 buffer indices
|
||||
const getPrevIndex = (index) => (index - 1 + channels.length) % channels.length;
|
||||
const getNextIndex = (index) => (index + 1) % channels.length;
|
||||
|
||||
const prevIndex = getPrevIndex(currentIndex);
|
||||
const nextIndex = getNextIndex(currentIndex);
|
||||
|
||||
const triggerOSD = () => {
|
||||
setShowOSD(true);
|
||||
if (osdTimerRef.current) clearTimeout(osdTimerRef.current);
|
||||
osdTimerRef.current = setTimeout(() => setShowOSD(false), 5000);
|
||||
};
|
||||
|
||||
const wrapChannelUp = () => {
|
||||
setCurrentIndex(getNextIndex);
|
||||
triggerOSD();
|
||||
};
|
||||
|
||||
const wrapChannelDown = () => {
|
||||
setCurrentIndex(getPrevIndex);
|
||||
triggerOSD();
|
||||
};
|
||||
|
||||
useRemoteControl({
|
||||
onChannelUp: wrapChannelUp,
|
||||
onChannelDown: wrapChannelDown,
|
||||
onSelect: triggerOSD,
|
||||
onBack: onOpenGuide // Often on TVs 'Menu' or 'Back' opens Guide/App list
|
||||
});
|
||||
|
||||
// Fetch channels from Django API
|
||||
useEffect(() => {
|
||||
fetchChannels().then(data => {
|
||||
// If db gives us channels, pad them with a fallback video stream based on index
|
||||
const mapped = data.map((ch, idx) => ({
|
||||
...ch,
|
||||
file: FALLBACK_VIDEOS[idx % FALLBACK_VIDEOS.length]
|
||||
}));
|
||||
if (mapped.length === 0) {
|
||||
// Fallback if db is completely empty
|
||||
mapped.push({ id: 99, channel_number: '99', name: 'Default Local feed', file: FALLBACK_VIDEOS[0] });
|
||||
}
|
||||
setChannels(mapped);
|
||||
setLoading(false);
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
setChannels([{ id: 99, channel_number: '99', name: 'Error Offline', file: FALLBACK_VIDEOS[0] }]);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Initial OSD hide
|
||||
useEffect(() => {
|
||||
if (!loading) triggerOSD();
|
||||
return () => clearTimeout(osdTimerRef.current);
|
||||
}, [loading]);
|
||||
|
||||
if (loading) {
|
||||
return <div style={{position: 'absolute', top: '50%', left: '50%', color: 'white'}}>Connecting to PYTV Backend...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tuner-container">
|
||||
{/*
|
||||
We map over all channels, but selectively apply 'playing' or 'buffering'
|
||||
classes to only the surrounding 3 elements. The rest are completely unrendered
|
||||
to save immense DOM and memory resources.
|
||||
*/}
|
||||
{channels.map((chan, index) => {
|
||||
const isCurrent = index === currentIndex;
|
||||
const isPrev = index === prevIndex;
|
||||
const isNext = index === nextIndex;
|
||||
|
||||
// Only mount the node if it's one of the 3 active buffers
|
||||
if (!isCurrent && !isPrev && !isNext) return null;
|
||||
|
||||
let stateClass = 'buffering';
|
||||
if (isCurrent) stateClass = 'playing';
|
||||
|
||||
return (
|
||||
<video
|
||||
key={chan.id}
|
||||
src={chan.file}
|
||||
className={`tuner-video ${stateClass}`}
|
||||
autoPlay={true}
|
||||
muted={!isCurrent} // Always mute background buffers instantly
|
||||
loop
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* OSD Layer */}
|
||||
<div className={`osd-overlay ${showOSD ? '' : 'hidden'}`}>
|
||||
<div className="osd-top">
|
||||
<div className="osd-channel-bug">
|
||||
<span className="ch-num">{channels[currentIndex].channel_number}</span>
|
||||
{channels[currentIndex].name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="osd-bottom">
|
||||
<div className="osd-info-box glass-panel">
|
||||
<div className="osd-meta">
|
||||
<span className="osd-badge">LIVE</span>
|
||||
<span>12:00 PM - 2:00 PM</span>
|
||||
</div>
|
||||
<h1 className="osd-title">Sample Movie Feed</h1>
|
||||
<p style={{ color: 'var(--pytv-text-dim)' }}>
|
||||
A classic broadcast playing locally via PYTV. Use Up/Down arrows to switch channels seamlessly.
|
||||
Press Escape to load the EPG Guide.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/Guide.jsx
Normal file
70
frontend/src/components/Guide.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRemoteControl } from '../hooks/useRemoteControl';
|
||||
import { fetchChannels } from '../api';
|
||||
|
||||
export default function Guide({ onClose, onSelectChannel }) {
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useRemoteControl({
|
||||
onUp: () => setSelectedIndex(prev => (prev - 1 + channels.length) % channels.length),
|
||||
onDown: () => setSelectedIndex(prev => (prev + 1) % channels.length),
|
||||
onSelect: () => onSelectChannel(channels[selectedIndex].id),
|
||||
onBack: onClose
|
||||
});
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
|
||||
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);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="guide-container open">
|
||||
<div className="guide-header">
|
||||
<h1>PYTV Guide</h1>
|
||||
<div className="guide-clock">{currentTime}</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>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user