feat(main): main
This commit is contained in:
@@ -2,11 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRemoteControl } from '../hooks/useRemoteControl';
|
||||
import { fetchChannels, fetchChannelNow } 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'
|
||||
];
|
||||
const TEST_CARD_SRC = '/testcard.svg';
|
||||
|
||||
export default function ChannelTuner({ onOpenGuide }) {
|
||||
const [channels, setChannels] = useState([]);
|
||||
@@ -17,7 +13,6 @@ export default function ChannelTuner({ onOpenGuide }) {
|
||||
const [nowPlaying, setNowPlaying] = useState({}); // { channelId: airingData }
|
||||
const osdTimerRef = useRef(null);
|
||||
|
||||
// The 3 buffer indices
|
||||
const getPrevIndex = (index) => (index - 1 + channels.length) % channels.length;
|
||||
const getNextIndex = (index) => (index + 1) % channels.length;
|
||||
|
||||
@@ -30,107 +25,114 @@ export default function ChannelTuner({ onOpenGuide }) {
|
||||
osdTimerRef.current = setTimeout(() => setShowOSD(false), 5000);
|
||||
}, []);
|
||||
|
||||
const wrapChannelUp = () => {
|
||||
setCurrentIndex(getNextIndex);
|
||||
triggerOSD();
|
||||
};
|
||||
|
||||
const wrapChannelDown = () => {
|
||||
setCurrentIndex(getPrevIndex);
|
||||
triggerOSD();
|
||||
};
|
||||
const wrapChannelUp = () => { setCurrentIndex(getNextIndex); triggerOSD(); };
|
||||
const wrapChannelDown = () => { setCurrentIndex(getPrevIndex); triggerOSD(); };
|
||||
|
||||
useRemoteControl({
|
||||
onChannelUp: wrapChannelUp,
|
||||
onChannelDown: wrapChannelDown,
|
||||
onSelect: triggerOSD,
|
||||
onBack: onOpenGuide
|
||||
onBack: onOpenGuide,
|
||||
});
|
||||
|
||||
// Debug Info Toggle
|
||||
// Debug toggle (i key)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
// Ignore if user is typing in an input
|
||||
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return;
|
||||
if (e.key === 'i' || e.key === 'I') {
|
||||
setShowDebug(prev => !prev);
|
||||
}
|
||||
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
||||
if (e.key === 'i' || e.key === 'I') setShowDebug(prev => !prev);
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Fetch channels from Django API
|
||||
// Fetch channels — always sorted by channel_number (API guarantees this; sort defensively here too)
|
||||
useEffect(() => {
|
||||
fetchChannels().then(data => {
|
||||
const mapped = data.map((ch, idx) => ({
|
||||
...ch,
|
||||
fallbackFile: FALLBACK_VIDEOS[idx % FALLBACK_VIDEOS.length]
|
||||
}));
|
||||
if (mapped.length === 0) {
|
||||
mapped.push({ id: 99, channel_number: '99', name: 'Default Feed', fallbackFile: FALLBACK_VIDEOS[0] });
|
||||
}
|
||||
setChannels(mapped);
|
||||
setLoading(false);
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
setChannels([{ id: 99, channel_number: '99', name: 'Offline', fallbackFile: FALLBACK_VIDEOS[0] }]);
|
||||
setLoading(false);
|
||||
});
|
||||
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;
|
||||
});
|
||||
setChannels(sorted.length > 0
|
||||
? sorted
|
||||
: [{ id: 99, channel_number: 99, name: 'Default Feed' }]
|
||||
);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
setChannels([{ id: 99, channel_number: 99, name: 'Offline' }]);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fetch "Now Playing" metadata for the current channel group whenever currentIndex changes
|
||||
// Fetch "Now Playing" for active triple-buffer
|
||||
useEffect(() => {
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const activeIndices = [currentIndex, prevIndex, nextIndex];
|
||||
activeIndices.forEach(idx => {
|
||||
[currentIndex, prevIndex, nextIndex].forEach(idx => {
|
||||
const chan = channels[idx];
|
||||
fetchChannelNow(chan.id).then(data => {
|
||||
setNowPlaying(prev => ({ ...prev, [chan.id]: data }));
|
||||
}).catch(() => {
|
||||
setNowPlaying(prev => ({ ...prev, [chan.id]: null }));
|
||||
});
|
||||
if (!chan) return;
|
||||
fetchChannelNow(chan.id)
|
||||
.then(data => setNowPlaying(prev => ({ ...prev, [chan.id]: data })))
|
||||
.catch(() => setNowPlaying(prev => ({ ...prev, [chan.id]: null })));
|
||||
});
|
||||
}, [currentIndex, channels, prevIndex, nextIndex]);
|
||||
|
||||
// Initial OSD hide
|
||||
// Initial OSD
|
||||
useEffect(() => {
|
||||
if (!loading) triggerOSD();
|
||||
return () => clearTimeout(osdTimerRef.current);
|
||||
}, [loading, triggerOSD]);
|
||||
|
||||
if (loading || channels.length === 0) {
|
||||
return <div style={{position: 'absolute', top: '50%', left: '50%', color: 'white'}}>Connecting to PYTV Backend...</div>;
|
||||
return (
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<img src={TEST_CARD_SRC} alt="Test Card" style={{ maxHeight: '100%', maxWidth: '100%', objectFit: 'contain' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentChan = channels[currentIndex];
|
||||
const airing = nowPlaying[currentChan.id];
|
||||
|
||||
/** Build the video source for a channel, or null if nothing playable. */
|
||||
function getVideoSrc(chan) {
|
||||
const currentAiring = nowPlaying[chan.id];
|
||||
if (!currentAiring || !currentAiring.media_item_path) return null;
|
||||
const path = currentAiring.media_item_path;
|
||||
if (path.startsWith('http')) return path;
|
||||
const clean = path.replace(/^\/?(?:media)?\/*/,'');
|
||||
return `/media/${clean}`;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="tuner-container">
|
||||
{channels.map((chan, index) => {
|
||||
const isCurrent = index === currentIndex;
|
||||
const isPrev = index === prevIndex;
|
||||
const isNext = index === nextIndex;
|
||||
|
||||
const isPrev = index === prevIndex;
|
||||
const isNext = index === nextIndex;
|
||||
if (!isCurrent && !isPrev && !isNext) return null;
|
||||
|
||||
let stateClass = 'buffering';
|
||||
if (isCurrent) stateClass = 'playing';
|
||||
const stateClass = isCurrent ? 'playing' : 'buffering';
|
||||
const videoSrc = getVideoSrc(chan);
|
||||
|
||||
// Use the current airing's media item file if available, else fallback
|
||||
const currentAiring = nowPlaying[chan.id];
|
||||
let videoSrc = chan.fallbackFile;
|
||||
if (currentAiring && currentAiring.media_item_path) {
|
||||
if (currentAiring.media_item_path.startsWith('http')) {
|
||||
videoSrc = currentAiring.media_item_path;
|
||||
} else {
|
||||
// Django serves cached media at root, Vite proxies /media to root
|
||||
// Remove leading slashes or /media/ to avoid double slashes like /media//mock
|
||||
const cleanPath = currentAiring.media_item_path.replace(/^\/?(media)?\/*/, '');
|
||||
videoSrc = `/media/${cleanPath}`;
|
||||
}
|
||||
// If no video available, show the test card image for this slot
|
||||
if (!videoSrc) {
|
||||
return (
|
||||
<div
|
||||
key={chan.id}
|
||||
className={`tuner-video ${stateClass}`}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#000' }}
|
||||
>
|
||||
<img
|
||||
src={TEST_CARD_SRC}
|
||||
alt="No Signal"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -138,33 +140,30 @@ export default function ChannelTuner({ onOpenGuide }) {
|
||||
key={chan.id}
|
||||
src={videoSrc}
|
||||
className={`tuner-video ${stateClass}`}
|
||||
autoPlay={true}
|
||||
autoPlay
|
||||
muted={!isCurrent}
|
||||
loop
|
||||
playsInline
|
||||
onLoadedMetadata={(e) => {
|
||||
const video = e.target;
|
||||
if (currentAiring && currentAiring.starts_at) {
|
||||
const startTime = new Date(currentAiring.starts_at).getTime();
|
||||
const nowTime = Date.now();
|
||||
|
||||
if (nowTime > startTime) {
|
||||
const offsetSeconds = (nowTime - startTime) / 1000;
|
||||
// If the video is shorter than the offset (e.g. repeating a short clip),
|
||||
// modulo the offset by duration to emulate a continuous loop.
|
||||
if (video.duration && video.duration > 0) {
|
||||
video.currentTime = offsetSeconds % video.duration;
|
||||
} else {
|
||||
video.currentTime = offsetSeconds;
|
||||
}
|
||||
const currentAiring = nowPlaying[chan.id];
|
||||
if (currentAiring?.starts_at) {
|
||||
const offsetSeconds = (Date.now() - new Date(currentAiring.starts_at).getTime()) / 1000;
|
||||
if (offsetSeconds > 0 && video.duration > 0) {
|
||||
video.currentTime = offsetSeconds % video.duration;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
if (e.target.src !== chan.fallbackFile) {
|
||||
console.warn(`Video failed to load: ${e.target.src}, falling back.`);
|
||||
e.target.src = chan.fallbackFile;
|
||||
}
|
||||
// Replace video with test card on error
|
||||
console.warn(`Video failed to load: ${e.target.src} — showing test card`);
|
||||
const parent = e.target.parentNode;
|
||||
if (!parent) return;
|
||||
const img = document.createElement('img');
|
||||
img.src = TEST_CARD_SRC;
|
||||
img.alt = 'No Signal';
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:contain;position:absolute;top:0;left:0;background:#000';
|
||||
e.target.replaceWith(img);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -177,21 +176,15 @@ export default function ChannelTuner({ onOpenGuide }) {
|
||||
<div className="debug-grid">
|
||||
<span className="debug-label">Channel:</span>
|
||||
<span className="debug-value">{currentChan.channel_number} - {currentChan.name} (ID: {currentChan.id})</span>
|
||||
|
||||
|
||||
<span className="debug-label">Airing ID:</span>
|
||||
<span className="debug-value">{airing ? airing.id : 'N/A'}</span>
|
||||
|
||||
|
||||
<span className="debug-label">Media Item:</span>
|
||||
<span className="debug-value">{airing && airing.media_item_title ? airing.media_item_title : 'N/A'}</span>
|
||||
|
||||
<span className="debug-value">{airing?.media_item_title ?? 'N/A'}</span>
|
||||
|
||||
<span className="debug-label">Stream URL:</span>
|
||||
<span className="debug-value debug-url">{(() => {
|
||||
if (airing && airing.media_item_path) {
|
||||
if (airing.media_item_path.startsWith('http')) return airing.media_item_path;
|
||||
return `/media/${airing.media_item_path.replace(/^\/?(media)?\/*/, '')}`;
|
||||
}
|
||||
return currentChan.fallbackFile;
|
||||
})()}</span>
|
||||
<span className="debug-value debug-url">{getVideoSrc(currentChan) ?? '(test card — no video)'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -200,7 +193,7 @@ export default function ChannelTuner({ onOpenGuide }) {
|
||||
<div className={`osd-overlay ${showOSD ? '' : 'hidden'}`}>
|
||||
<div className="osd-top">
|
||||
<div className="osd-channel-bug">
|
||||
<span className="ch-num">{currentChan.channel_number}</span>
|
||||
<span className="ch-num">{currentChan.channel_number}</span>
|
||||
{currentChan.name}
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,20 +201,20 @@ export default function ChannelTuner({ onOpenGuide }) {
|
||||
<div className="osd-info-box glass-panel">
|
||||
<div className="osd-meta">
|
||||
<span className="osd-badge">
|
||||
{airing ? airing.slot_kind.toUpperCase() : 'LIVE'}
|
||||
{airing ? airing.slot_kind?.toUpperCase() : 'NO SIGNAL'}
|
||||
</span>
|
||||
{airing && (
|
||||
<span>
|
||||
{new Date(airing.starts_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} -
|
||||
{new Date(airing.ends_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}
|
||||
{new Date(airing.starts_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} –{' '}
|
||||
{new Date(airing.ends_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="osd-title">
|
||||
{airing ? airing.media_item_title : 'Loading Program...'}
|
||||
{airing ? airing.media_item_title : '— No Programming —'}
|
||||
</h1>
|
||||
<p style={{ color: 'var(--pytv-text-dim)' }}>
|
||||
Watching {currentChan.name}. Press Escape to load the EPG Guide.
|
||||
Watching {currentChan.name}. Press Escape for the EPG Guide.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 · <span style={{ color: '#fff' }}>↑↓</span> to navigate channels · <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 <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>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday,
|
||||
fetchTemplateBlocks, createTemplateBlock, deleteTemplateBlock,
|
||||
fetchSources, createSource, syncSource, deleteSource,
|
||||
fetchLibraries,
|
||||
fetchDownloadStatus, triggerCacheUpcoming, downloadItem,
|
||||
fetchLibraries, fetchCollections,
|
||||
fetchDownloadStatus, triggerCacheUpcoming, downloadItem, fetchDownloadProgress,
|
||||
} from '../api';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────
|
||||
@@ -57,6 +57,12 @@ function EmptyState({ text }) {
|
||||
return <p className="settings-empty">{text}</p>;
|
||||
}
|
||||
|
||||
function timeToPct(timeStr) {
|
||||
if (!timeStr) return 0;
|
||||
const [h, m] = timeStr.split(':').map(Number);
|
||||
return ((h * 60 + m) / 1440) * 100;
|
||||
}
|
||||
|
||||
function IconBtn({ icon, label, kind = 'default', onClick, disabled, title }) {
|
||||
return (
|
||||
<button
|
||||
@@ -151,8 +157,11 @@ function UsersTab() {
|
||||
function ChannelsTab() {
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [sources, setSources] = useState([]);
|
||||
const [collections, setCollections] = useState([]);
|
||||
const [libraries, setLibraries] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [templateBlocks, setTemplateBlocks] = useState({}); // { templateId: [blocks] }
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] }
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -162,8 +171,17 @@ function ChannelsTab() {
|
||||
const [feedback, setFeedback, ok, err] = useFeedback();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([fetchChannels(), fetchSources(), fetchLibraries(), fetchUsers()])
|
||||
.then(([c, s, l, u]) => { setChannels(c); setSources(s); setLibraries(l); setUsers(u); })
|
||||
Promise.all([fetchChannels(), fetchSources(), fetchLibraries(), fetchUsers(), fetchCollections(), fetchTemplates()])
|
||||
.then(([c, s, l, u, col, tmpl]) => {
|
||||
setChannels(c); setSources(s); setLibraries(l); setUsers(u); setCollections(col);
|
||||
setTemplates(tmpl);
|
||||
// Pre-load blocks for each template
|
||||
tmpl.forEach(t => {
|
||||
fetchTemplateBlocks(t.id).then(blocks => {
|
||||
setTemplateBlocks(prev => ({ ...prev, [t.id]: blocks }));
|
||||
}).catch(() => {});
|
||||
});
|
||||
})
|
||||
.catch(() => err('Failed to load channels'));
|
||||
}, []);
|
||||
|
||||
@@ -236,6 +254,14 @@ function ChannelsTab() {
|
||||
finally { setSyncingId(null); }
|
||||
};
|
||||
|
||||
const handleSetFallback = async (ch, collectionId) => {
|
||||
try {
|
||||
const updated = await updateChannel(ch.id, { fallback_collection_id: collectionId ? parseInt(collectionId) : null });
|
||||
setChannels(cs => cs.map(c => c.id === updated.id ? updated : c));
|
||||
ok(collectionId ? 'Fallback collection set.' : 'Fallback collection cleared.');
|
||||
} catch { err('Failed to update fallback collection.'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<Feedback fb={feedback} clear={() => setFeedback(null)} />
|
||||
@@ -309,6 +335,29 @@ function ChannelsTab() {
|
||||
{/* Expanded: source assignment panel */}
|
||||
{isExpanded && (
|
||||
<div className="channel-expand-panel">
|
||||
|
||||
{/* ─── Fallback block selector ───────────────────────── */}
|
||||
<div style={{ marginBottom: '1.25rem', padding: '0.75rem', background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '6px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', fontSize: '0.9rem' }}>
|
||||
<span style={{ whiteSpace: 'nowrap', fontWeight: 600 }}>⛔ Error Fallback Collection</span>
|
||||
<select
|
||||
value={ch.fallback_collection_id ?? ''}
|
||||
onChange={e => handleSetFallback(ch, e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
title="When scheduled programming cannot play, items from this collection will air instead."
|
||||
>
|
||||
<option value="">— None (use block sources) —</option>
|
||||
{collections
|
||||
.filter(col => col.library_id === ch.library_id)
|
||||
.map(col => <option key={col.id} value={col.id}>{col.name} ({col.collection_type})</option>)
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<p style={{ margin: '0.4rem 0 0 0', fontSize: '0.78rem', opacity: 0.65 }}>
|
||||
Items in this collection will air when a scheduled video cannot be played (missing file). If none is set, the scheduler picks a safe video from the block's normal sources.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h4 className="expand-section-title">Assigned Sources</h4>
|
||||
|
||||
{!hasRules && (
|
||||
@@ -369,13 +418,20 @@ function ChannelsTab() {
|
||||
style={{ width: 60 }}
|
||||
title="Weight (higher = more airings)"
|
||||
/>
|
||||
<input
|
||||
placeholder="Target Block Label (Optional)"
|
||||
<select
|
||||
value={assignForm.schedule_block_label}
|
||||
onChange={e => setAssignForm(f => ({ ...f, schedule_block_label: e.target.value }))}
|
||||
style={{ flex: 1 }}
|
||||
title="If set, this source will ONLY play during blocks with this exact name"
|
||||
/>
|
||||
title="If set, this source will ONLY play during blocks with this exact name. Leaving it empty applies to all blocks."
|
||||
>
|
||||
<option value="">— Any Time (Default) —</option>
|
||||
{Array.from(new Set(
|
||||
templates.filter(t => t.channel_id === ch.id)
|
||||
.flatMap(t => (templateBlocks[t.id] || []).map(b => b.name))
|
||||
)).map(name => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add Rule</button>
|
||||
</div>
|
||||
|
||||
@@ -398,7 +454,7 @@ function SourcesTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncingId, setSyncingId] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50 });
|
||||
const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60 });
|
||||
const [feedback, setFeedback, ok, err] = useFeedback();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -411,10 +467,14 @@ function SourcesTab() {
|
||||
e.preventDefault();
|
||||
if (!form.library_id) { err('Please select a library.'); return; }
|
||||
try {
|
||||
const src = await createSource({ ...form, library_id: parseInt(form.library_id) });
|
||||
const src = await createSource({
|
||||
...form,
|
||||
library_id: parseInt(form.library_id),
|
||||
scan_interval_minutes: parseInt(form.scan_interval_minutes) || null
|
||||
});
|
||||
setSources(s => [...s, src]);
|
||||
setShowForm(false);
|
||||
setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50 });
|
||||
setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60 });
|
||||
ok(`Source "${src.name}" registered. Hit Sync to import videos.`);
|
||||
} catch { err('Failed to create source.'); }
|
||||
};
|
||||
@@ -482,6 +542,11 @@ function SourcesTab() {
|
||||
onChange={e => setForm(f => ({ ...f, max_videos: e.target.value }))} />
|
||||
</label>
|
||||
)}
|
||||
<label>Scan Interval (mins)
|
||||
<input type="number" min="15" max="1440" value={form.scan_interval_minutes}
|
||||
onChange={e => setForm(f => ({ ...f, scan_interval_minutes: e.target.value }))}
|
||||
title="How often background workers should fetch new metadata updates" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" className="btn-accent">Register Source</button>
|
||||
</form>
|
||||
@@ -537,6 +602,7 @@ function DownloadsTab() {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [runResult, setRunResult] = useState(null);
|
||||
const [downloadingId, setDownloadingId] = useState(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState(null); // '45.1%'
|
||||
const [filterSource, setFilterSource] = useState('all');
|
||||
const [feedback, setFeedback, ok, err] = useFeedback();
|
||||
|
||||
@@ -565,6 +631,15 @@ function DownloadsTab() {
|
||||
|
||||
const handleDownloadOne = async (item) => {
|
||||
setDownloadingId(item.id);
|
||||
setDownloadProgress('0%');
|
||||
let pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetchDownloadProgress(item.id);
|
||||
if (res.progress) setDownloadProgress(res.progress);
|
||||
if (res.status === 'finished') clearInterval(pollInterval);
|
||||
} catch (e) { console.warn('Progress poll err', e); }
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
await downloadItem(item.id);
|
||||
ok(`Downloaded "${item.title.slice(0, 50)}".`);
|
||||
@@ -575,7 +650,11 @@ function DownloadsTab() {
|
||||
}));
|
||||
} catch (e) {
|
||||
err(`Download failed: ${e?.response?.data?.detail || e.message}`);
|
||||
} finally { setDownloadingId(null); }
|
||||
} finally {
|
||||
clearInterval(pollInterval);
|
||||
setDownloadingId(null);
|
||||
setDownloadProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const sources = status ? [...new Set(status.items.map(i => i.source_name))] : [];
|
||||
@@ -676,13 +755,17 @@ function DownloadsTab() {
|
||||
<button
|
||||
className="btn-sync"
|
||||
onClick={() => handleDownloadOne(item)}
|
||||
disabled={downloadingId === item.id}
|
||||
disabled={downloadingId !== null}
|
||||
title="Download this video now"
|
||||
>
|
||||
{downloadingId === item.id ? '…' : '⬇'}
|
||||
{downloadingId === item.id ? `Downloading ${downloadProgress || '...'}` : '⬇ Download'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Inject a progress bar right under the item if it's currently downloading */}
|
||||
{downloadingId === item.id && downloadProgress && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, height: '3px', background: 'var(--pytv-accent)', width: downloadProgress, transition: 'width 0.3s ease' }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -846,6 +929,33 @@ function SchedulingTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blocks.length > 0 && (
|
||||
<div style={{ position: 'relative', width: '100%', height: '32px', background: '#2f3640', borderRadius: '4px', marginBottom: '1.5rem', overflow: 'hidden', border: '1px solid var(--pytv-glass-border)' }}>
|
||||
{/* Timeline tick marks */}
|
||||
{[0, 6, 12, 18].map(h => (
|
||||
<div key={h} style={{ position: 'absolute', left: `${(h/24)*100}%`, height: '100%', borderLeft: '1px dashed rgba(255,255,255,0.2)', pointerEvents: 'none', paddingLeft: '2px', fontSize: '0.65rem', color: 'rgba(255,255,255,0.4)', paddingTop: '2px' }}>
|
||||
{h}:00
|
||||
</div>
|
||||
))}
|
||||
{blocks.map(b => {
|
||||
const startPct = timeToPct(b.start_local_time);
|
||||
let endPct = timeToPct(b.end_local_time);
|
||||
if (endPct <= startPct) endPct = 100; // spills past midnight visually
|
||||
const width = endPct - startPct;
|
||||
const color = b.block_type === 'OFF_AIR' ? 'rgba(248, 113, 113, 0.8)' : 'rgba(96, 165, 250, 0.8)'; // red vs blue
|
||||
return (
|
||||
<div
|
||||
key={`vis-${b.id}`}
|
||||
style={{ position: 'absolute', left: `${startPct}%`, width: `${width}%`, height: '100%', background: color, borderRight: '1px solid rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.75rem', color: '#fff', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '0 4px' }}
|
||||
title={`${b.name} (${b.start_local_time.slice(0,5)} - ${b.end_local_time.slice(0,5)}) Rating: ${b.target_content_rating || 'Any'}`}
|
||||
>
|
||||
{width > 8 ? b.name : ''}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}>
|
||||
{blocks.map(b => (
|
||||
<div key={b.id} style={{ display: 'flex', gap: '0.5rem', background: '#353b48', padding: '0.5rem', borderRadius: '4px', alignItems: 'center', fontSize: '0.9rem' }}>
|
||||
|
||||
Reference in New Issue
Block a user