feat(playout): redesigned MCR screen — design polish + real API wiring
This commit is contained in:
parent
19f0abeabe
commit
be819353a7
1 changed files with 600 additions and 187 deletions
|
|
@ -1,15 +1,22 @@
|
|||
// screens-playout.jsx — Master Control (MCR) playout page.
|
||||
//
|
||||
// Operator workflow (Phase A — playlist player):
|
||||
// 1. Create / pick a channel (output target: SRT / RTMP / NDI / DeckLink).
|
||||
// 2. Start the channel → spawns the CasparCG sidecar, brings up the output.
|
||||
// 3. Drag assets from the media bin into the playlist; reorder by dragging.
|
||||
// Each item stages from S3 to the CasparCG /media volume in the background.
|
||||
// 4. Hit PLAY → the engine walks the playlist gaplessly. PAUSE / SKIP / STOP
|
||||
// transport. As-run log records what aired.
|
||||
// Redesigned: combines the visual design (timeline, styled monitor, SCTE-35
|
||||
// panel, as-run drawer, transport bar, channel header tabs) with the full
|
||||
// real API wiring from the live version.
|
||||
//
|
||||
// Talks to /api/v1/playout via window.ZAMPP_API.fetch. Native HTML5 drag-drop,
|
||||
// no extra library. Components are plain globals (esbuild bundle:false).
|
||||
// API wiring summary:
|
||||
// - Channel CRUD: GET/POST/DELETE /playout/channels
|
||||
// - Channel lifecycle: POST /channels/:id/start|stop
|
||||
// - Engine status: GET /channels/:id/status (polls 4s)
|
||||
// - HLS preview: GET /channels/:id/hls/index.m3u8
|
||||
// - Playlist CRUD: GET/POST /playlists, GET/POST/PUT /playlists/:id/items
|
||||
// - Transport: POST /channels/:id/play|pause|resume|skip|stop-playback
|
||||
// - As-run log: GET /channels/:id/asrun (polls 5s, drawer)
|
||||
// - Staging: POST /items/:id/stage (retry)
|
||||
// - SCTE-35: UI only — no backend endpoint yet (see comment below)
|
||||
//
|
||||
// esbuild bundle:false + jsx:transform — no import statements.
|
||||
// Globals: React, Icon, window.ZAMPP_API, window.ZAMPP_DATA, window.Hls
|
||||
|
||||
const PO_OUTPUTS = [
|
||||
{ value: 'srt', label: 'SRT' },
|
||||
|
|
@ -24,7 +31,7 @@ async function poFetch(path, opts) {
|
|||
return window.ZAMPP_API.fetch('/playout' + path, opts);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDuration(secs) {
|
||||
if (!secs || secs < 0) return '—';
|
||||
|
|
@ -37,6 +44,16 @@ function fmtDuration(secs) {
|
|||
return h > 0 ? `${h}:${mm}:${ssStr}` : `${m}:${ssStr}`;
|
||||
}
|
||||
|
||||
function fmtTimecode(secs) {
|
||||
// HH:MM:SS:FF at 29.97 (rounded)
|
||||
const s = Math.floor(secs);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const ss = s % 60;
|
||||
const ff = Math.floor(((secs - s) * 29.97));
|
||||
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}:${String(ff).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function itemEffectiveDuration(it) {
|
||||
const total = (it.asset_duration_ms || 0) / 1000;
|
||||
const inPt = it.in_point != null ? Number(it.in_point) : 0;
|
||||
|
|
@ -44,7 +61,37 @@ function itemEffectiveDuration(it) {
|
|||
return Math.max(0, outPt - inPt);
|
||||
}
|
||||
|
||||
// ── Output-config sub-form (varies by output type) ───────────────────────────
|
||||
// ── Clock ─────────────────────────────────────────────────────────────────────
|
||||
function LiveClock() {
|
||||
const [time, setTime] = React.useState(new Date());
|
||||
React.useEffect(() => {
|
||||
const id = setInterval(() => setTime(new Date()), 500);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const h = pad(time.getHours());
|
||||
const m = pad(time.getMinutes());
|
||||
const s = pad(time.getSeconds());
|
||||
return (
|
||||
<span className="po-clock mono">{h}:{m}:{s}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Elapsed timer ─────────────────────────────────────────────────────────────
|
||||
function useElapsed(startedAt) {
|
||||
const [elapsed, setElapsed] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
if (!startedAt) { setElapsed(0); return; }
|
||||
const base = new Date(startedAt).getTime();
|
||||
const tick = () => setElapsed(Math.max(0, (Date.now() - base) / 1000));
|
||||
tick();
|
||||
const id = setInterval(tick, 100);
|
||||
return () => clearInterval(id);
|
||||
}, [startedAt]);
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
// ── Output-config sub-form ────────────────────────────────────────────────────
|
||||
function OutputConfigFields({ type, config, onChange }) {
|
||||
const set = (k, v) => onChange({ ...config, [k]: v });
|
||||
if (type === 'decklink') {
|
||||
|
|
@ -65,7 +112,6 @@ function OutputConfigFields({ type, config, onChange }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
// srt / rtmp
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="field">
|
||||
|
|
@ -92,7 +138,7 @@ function OutputConfigFields({ type, config, onChange }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Channel create modal ─────────────────────────────────────────────────────
|
||||
// ── Channel create modal ──────────────────────────────────────────────────────
|
||||
function ChannelCreate({ onClose, onCreated }) {
|
||||
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
|
||||
const [name, setName] = React.useState('');
|
||||
|
|
@ -162,7 +208,7 @@ function ChannelCreate({ onClose, onCreated }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Media bin: assets draggable into the playlist ────────────────────────────
|
||||
// ── Media bin ─────────────────────────────────────────────────────────────────
|
||||
function MediaBin({ projectId }) {
|
||||
const ASSETS = (window.ZAMPP_DATA?.ASSETS || []).filter(a =>
|
||||
!projectId || a.project_id === projectId);
|
||||
|
|
@ -195,14 +241,14 @@ function MediaBin({ projectId }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Staging progress bar ──────────────────────────────────────────────────────
|
||||
// ── Staging bar ───────────────────────────────────────────────────────────────
|
||||
function StagingBar({ status }) {
|
||||
return (
|
||||
<div className={'po-staging-bar po-staging-bar--' + (status || 'pending')} aria-hidden="true" />
|
||||
);
|
||||
}
|
||||
|
||||
// ── Playlist: ordered, drag-drop reorder, drop-target for bin assets ─────────
|
||||
// ── Playlist ──────────────────────────────────────────────────────────────────
|
||||
function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
|
||||
const [dragIndex, setDragIndex] = React.useState(null);
|
||||
const [dropErr, setDropErr] = React.useState(null);
|
||||
|
|
@ -215,7 +261,7 @@ function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
|
|||
|
||||
const onItemDrop = async (e, index) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // prevent bubble to onContainerDrop
|
||||
e.stopPropagation();
|
||||
setDropErr(null);
|
||||
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
|
||||
if (assetRaw) {
|
||||
|
|
@ -228,7 +274,6 @@ function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
|
|||
} catch (err) { setDropErr(err.message || 'Failed to add clip'); }
|
||||
return;
|
||||
}
|
||||
// Reorder within the playlist.
|
||||
if (dragIndex === null || dragIndex === index) return;
|
||||
const order = items.map(i => i.id);
|
||||
const [moved] = order.splice(dragIndex, 1);
|
||||
|
|
@ -271,6 +316,9 @@ function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
|
|||
<div className="panel po-playlist" onDragOver={e => e.preventDefault()} onDrop={onContainerDrop}>
|
||||
<div className="po-playlist-head">
|
||||
<span className="po-section-label">Playlist</span>
|
||||
<span className="mono muted" style={{ fontSize: 11, marginLeft: 'auto' }}>
|
||||
{items.length} clip{items.length !== 1 ? 's' : ''} · {fmtDuration(totalSecs)}
|
||||
</span>
|
||||
{dropErr && <span className="po-drop-err">{dropErr}</span>}
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
|
|
@ -291,7 +339,10 @@ function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
|
|||
</span>
|
||||
<span className="po-pl-name">{it.clip_name || it.asset_id}</span>
|
||||
<span className="mono po-pl-dur">{fmtDuration(dur)}</span>
|
||||
<span className={'badge po-pl-badge ' + (it.media_status === 'ready' ? 'success' : it.media_status === 'staging' ? 'warn' : it.media_status === 'error' ? 'error' : 'neutral')}>
|
||||
<span className={'badge po-pl-badge ' + (
|
||||
it.media_status === 'ready' ? 'success' :
|
||||
it.media_status === 'staging' ? 'warn' :
|
||||
it.media_status === 'error' ? 'error' : 'neutral')}>
|
||||
{it.media_status}
|
||||
</span>
|
||||
{it.media_status === 'error' && (
|
||||
|
|
@ -302,98 +353,109 @@ function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{items.length > 0 && (
|
||||
<div className="po-playlist-footer">
|
||||
<span className="mono muted">{items.length} clip{items.length !== 1 ? 's' : ''}</span>
|
||||
<span className="mono po-pl-total">{fmtDuration(totalSecs)} total</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Transport bar ────────────────────────────────────────────────────────────
|
||||
function Transport({ channel, playlistId, items, onStatus }) {
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const act = async (fn) => { setBusy(true); try { await fn(); } catch (e) { alert(e.message); } finally { setBusy(false); } };
|
||||
// ── Audio meter ───────────────────────────────────────────────────────────────
|
||||
// Simulated VU meter — real values would require a WebAudio analyzer on the
|
||||
// HLS stream. For now, animate a plausible signal when on-air.
|
||||
function AudioMeter({ onAir }) {
|
||||
const canvasRef = React.useRef(null);
|
||||
const rafRef = React.useRef(null);
|
||||
|
||||
const notReady = items.filter(i => i.media_status !== 'ready').length;
|
||||
const canPlay = channel.status === 'running' && !busy && !!playlistId && notReady === 0;
|
||||
|
||||
const play = () => act(async () => {
|
||||
const r = await poFetch('/channels/' + channel.id + '/play', {
|
||||
method: 'POST', body: JSON.stringify({ playlist_id: playlistId }),
|
||||
});
|
||||
onStatus && onStatus(r);
|
||||
});
|
||||
const pause = () => act(() => poFetch('/channels/' + channel.id + '/pause', { method: 'POST' }));
|
||||
const resume = () => act(() => poFetch('/channels/' + channel.id + '/resume', { method: 'POST' }));
|
||||
const skip = () => act(() => poFetch('/channels/' + channel.id + '/skip', { method: 'POST' }));
|
||||
const stopPb = () => act(() => poFetch('/channels/' + channel.id + '/stop-playback', { method: 'POST' }));
|
||||
|
||||
const live = channel.status === 'running';
|
||||
return (
|
||||
<div className="po-transport">
|
||||
<button className="btn primary" disabled={!canPlay} onClick={play} title={notReady > 0 ? notReady + ' clip(s) still staging' : ''}>
|
||||
{notReady > 0 && live ? '⏳ ' + notReady + ' staging' : '▶ Play'}
|
||||
</button>
|
||||
<button className="btn ghost" disabled={!live || busy} onClick={pause}>⏸ Pause</button>
|
||||
<button className="btn ghost" disabled={!live || busy} onClick={resume}>⏵ Resume</button>
|
||||
<button className="btn ghost" disabled={!live || busy} onClick={skip}>⏭ Skip</button>
|
||||
<button className="btn danger ghost" disabled={!live || busy} onClick={stopPb}>⏹ Stop</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Elapsed timer ─────────────────────────────────────────────────────────────
|
||||
function useElapsed(startedAt) {
|
||||
const [elapsed, setElapsed] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
if (!startedAt) { setElapsed(0); return; }
|
||||
const base = new Date(startedAt).getTime();
|
||||
const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - base) / 1000)));
|
||||
tick();
|
||||
const id = setInterval(tick, 500);
|
||||
return () => clearInterval(id);
|
||||
}, [startedAt]);
|
||||
return elapsed;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
let levelL = 0, levelR = 0;
|
||||
let peakL = 0, peakR = 0;
|
||||
let peakHoldL = 0, peakHoldR = 0;
|
||||
let frame = 0;
|
||||
|
||||
const draw = () => {
|
||||
frame++;
|
||||
const w = canvas.width, h = canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (onAir) {
|
||||
// Simulate audio levels with a bit of randomness
|
||||
const target = 0.55 + Math.sin(frame * 0.07) * 0.25 + (Math.random() - 0.5) * 0.15;
|
||||
levelL += (target - levelL) * 0.25;
|
||||
levelR += ((target + (Math.random() - 0.5) * 0.08) - levelR) * 0.25;
|
||||
levelL = Math.max(0, Math.min(1, levelL));
|
||||
levelR = Math.max(0, Math.min(1, levelR));
|
||||
peakL = Math.max(peakL * 0.995, levelL);
|
||||
peakR = Math.max(peakR * 0.995, levelR);
|
||||
if (levelL >= peakHoldL) { peakHoldL = levelL; }
|
||||
else { peakHoldL *= 0.992; }
|
||||
if (levelR >= peakHoldR) { peakHoldR = levelR; }
|
||||
else { peakHoldR *= 0.992; }
|
||||
} else {
|
||||
levelL *= 0.9; levelR *= 0.9;
|
||||
peakHoldL *= 0.9; peakHoldR *= 0.9;
|
||||
}
|
||||
|
||||
const barW = Math.floor((w - 6) / 2);
|
||||
const drawBar = (x, level, peakHold) => {
|
||||
const fillH = Math.floor(level * h);
|
||||
// Background
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.fillRect(x, 0, barW, h);
|
||||
// Green zone (bottom 70%)
|
||||
const greenH = Math.min(fillH, Math.floor(h * 0.7));
|
||||
ctx.fillStyle = '#22c55e';
|
||||
ctx.fillRect(x, h - greenH, barW, greenH);
|
||||
// Yellow zone (70-90%)
|
||||
if (fillH > Math.floor(h * 0.7)) {
|
||||
const yw = Math.min(fillH - Math.floor(h * 0.7), Math.floor(h * 0.2));
|
||||
ctx.fillStyle = '#eab308';
|
||||
ctx.fillRect(x, h - Math.floor(h * 0.7) - yw, barW, yw);
|
||||
}
|
||||
// Red zone (top 10%)
|
||||
if (fillH > Math.floor(h * 0.9)) {
|
||||
const rw = fillH - Math.floor(h * 0.9);
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.fillRect(x, h - Math.floor(h * 0.9) - rw, barW, rw);
|
||||
}
|
||||
// Peak hold
|
||||
const peakY = Math.floor((1 - peakHold) * h);
|
||||
ctx.fillStyle = peakHold > 0.9 ? '#ef4444' : peakHold > 0.7 ? '#eab308' : '#22c55e';
|
||||
ctx.fillRect(x, peakY, barW, 2);
|
||||
};
|
||||
drawBar(0, levelL, peakHoldL);
|
||||
drawBar(barW + 6, levelR, peakHoldR);
|
||||
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||||
}, [onAir]);
|
||||
|
||||
return (
|
||||
<canvas ref={canvasRef} className="po-vu-meter" width={38} height={120}
|
||||
title="L / R audio levels" />
|
||||
);
|
||||
}
|
||||
|
||||
function fmtElapsed(secs) {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
return (h > 0 ? String(h).padStart(2,'0') + ':' : '') +
|
||||
String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
|
||||
}
|
||||
|
||||
// ── Program monitor ──────────────────────────────────────────────────────────
|
||||
function ProgramMonitor({ channel, engine }) {
|
||||
const videoRef = React.useRef(null);
|
||||
const hlsRef = React.useRef(null);
|
||||
const onAir = channel.status === 'running';
|
||||
// Load the playlist through the API (not the static /media/live path): the
|
||||
// public reverse proxy caches the static .m3u8 with a multi-second TTL and
|
||||
// ignores no-store, which starved hls.js's reloads of the live edge and kept
|
||||
// the monitor black. /api/ isn't proxy-cached, so this always returns fresh.
|
||||
// ── PGM monitor ───────────────────────────────────────────────────────────────
|
||||
function ProgramMonitor({ channel, engine, elapsed }) {
|
||||
const videoRef = React.useRef(null);
|
||||
const hlsRef = React.useRef(null);
|
||||
const onAir = channel.status === 'running';
|
||||
const previewUrl = `/api/v1/playout/channels/${channel.id}/hls/index.m3u8`;
|
||||
const elapsed = useElapsed(engine && engine.currentItemStartedAt);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vid = videoRef.current;
|
||||
if (!vid) return;
|
||||
|
||||
// Tear down any previous HLS instance before re-evaluating.
|
||||
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
|
||||
|
||||
if (!onAir) { vid.src = ''; return; }
|
||||
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
const hls = new window.Hls({
|
||||
liveSyncDurationCount: 3,
|
||||
liveMaxLatencyDurationCount: 6,
|
||||
// The playlist is served from /api/ (auth-gated); send the session
|
||||
// cookie so the request authenticates. Segments are static + public.
|
||||
xhrSetup: (xhr) => { xhr.withCredentials = true; },
|
||||
});
|
||||
hlsRef.current = hls;
|
||||
|
|
@ -401,7 +463,6 @@ function ProgramMonitor({ channel, engine }) {
|
|||
hls.attachMedia(vid);
|
||||
hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
|
||||
} else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Native HLS (Safari).
|
||||
vid.src = previewUrl;
|
||||
vid.play().catch(() => {});
|
||||
}
|
||||
|
|
@ -411,44 +472,312 @@ function ProgramMonitor({ channel, engine }) {
|
|||
};
|
||||
}, [onAir, channel.id]);
|
||||
|
||||
const clipDurSecs = engine && engine.currentItemId
|
||||
? (engine.currentItemDurationMs || 0) / 1000
|
||||
: 0;
|
||||
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
|
||||
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
|
||||
|
||||
return (
|
||||
<div className="po-monitor">
|
||||
<div className="po-monitor-head">
|
||||
<span className={'po-onair ' + (onAir ? 'live' : '')}>{onAir ? '● ON AIR' : '○ OFF'}</span>
|
||||
<span className="mono muted">{channel.output_type?.toUpperCase()} · {channel.video_format}</span>
|
||||
</div>
|
||||
<div className="po-monitor-screen">
|
||||
<div className="po-pgm">
|
||||
{/* Screen */}
|
||||
<div className="po-screen">
|
||||
<video ref={videoRef} className="po-monitor-video" muted playsInline autoPlay />
|
||||
|
||||
{/* ON AIR badge */}
|
||||
{onAir && (
|
||||
<div className="po-onair-badge">ON AIR</div>
|
||||
)}
|
||||
|
||||
{!onAir && (
|
||||
<div className="po-monitor-overlay muted">Channel stopped</div>
|
||||
<div className="po-screen-offline">
|
||||
<span className="po-screen-offline-dot" />
|
||||
<span>Channel stopped</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timecode overlay */}
|
||||
{onAir && (
|
||||
<div className="po-tc-overlay mono">{fmtTimecode(elapsed)}</div>
|
||||
)}
|
||||
|
||||
{/* Audio meters */}
|
||||
<div className="po-meters-wrap">
|
||||
<AudioMeter onAir={onAir} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="po-monitor-foot mono muted">
|
||||
{engine && engine.currentClip
|
||||
? <span className="po-monitor-clip-name">{engine.currentClip}</span>
|
||||
: <span>{onAir ? 'Idle' : 'Stopped'}</span>}
|
||||
{engine && engine.currentIndex >= 0 && (
|
||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 10 }}>
|
||||
<span style={{ color: 'var(--success)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{fmtElapsed(elapsed)}
|
||||
</span>
|
||||
<span>clip {engine.currentIndex + 1}/{engine.playlistLength || 0}</span>
|
||||
{engine.loop && <span>↺</span>}
|
||||
</span>
|
||||
)}
|
||||
{engine && engine.lastError && (
|
||||
<span style={{ color: 'var(--warning)', fontSize: 10, marginLeft: 6 }} title={engine.lastError}>⚠</span>
|
||||
|
||||
{/* Clip progress bar */}
|
||||
<div className="po-clip-progress">
|
||||
<div className="po-clip-progress-fill" style={{ width: (progress * 100) + '%' }} />
|
||||
</div>
|
||||
|
||||
{/* Monitor footer */}
|
||||
<div className="po-monitor-meta">
|
||||
<span className="po-monitor-clip-name mono">
|
||||
{engine && engine.currentClip
|
||||
? engine.currentClip
|
||||
: onAir ? 'Idle' : 'Stopped'}
|
||||
</span>
|
||||
<span className="po-monitor-right mono muted">
|
||||
{engine && engine.currentIndex >= 0 && (
|
||||
<React.Fragment>
|
||||
<span className="po-clip-pos">
|
||||
{engine.currentIndex + 1}/{engine.playlistLength || 0}
|
||||
</span>
|
||||
{timeRemaining > 0 && (
|
||||
<span className="po-clip-remain" title="Time remaining in clip">
|
||||
-{fmtDuration(timeRemaining)}
|
||||
</span>
|
||||
)}
|
||||
{engine.loop && <span title="Loop">↺</span>}
|
||||
{engine.lastError && (
|
||||
<span style={{ color: 'var(--warning)' }} title={engine.lastError}>⚠</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Transport bar ─────────────────────────────────────────────────────────────
|
||||
function Transport({ channel, playlistId, items, onStatus }) {
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const act = async (fn) => { setBusy(true); try { await fn(); } catch (e) { alert(e.message); } finally { setBusy(false); } };
|
||||
|
||||
const notReady = items.filter(i => i.media_status !== 'ready').length;
|
||||
const canPlay = channel.status === 'running' && !busy && !!playlistId && notReady === 0;
|
||||
const live = channel.status === 'running';
|
||||
|
||||
const play = () => act(async () => {
|
||||
const r = await poFetch('/channels/' + channel.id + '/play', {
|
||||
method: 'POST', body: JSON.stringify({ playlist_id: playlistId }),
|
||||
});
|
||||
onStatus && onStatus(r);
|
||||
});
|
||||
const pause = () => act(() => poFetch('/channels/' + channel.id + '/pause', { method: 'POST' }));
|
||||
const resume = () => act(() => poFetch('/channels/' + channel.id + '/resume', { method: 'POST' }));
|
||||
const skip = () => act(() => poFetch('/channels/' + channel.id + '/skip', { method: 'POST' }));
|
||||
const stopPb = () => act(() => poFetch('/channels/' + channel.id + '/stop-playback', { method: 'POST' }));
|
||||
|
||||
return (
|
||||
<div className="po-transport">
|
||||
<div className="po-transport-group">
|
||||
<button className="po-trans-btn po-trans-stop" disabled={!live || busy} onClick={stopPb} title="Stop playback">
|
||||
<span className="po-trans-icon">⏹</span>
|
||||
</button>
|
||||
<button className="po-trans-btn po-trans-play" disabled={!canPlay} onClick={play}
|
||||
title={notReady > 0 ? notReady + ' clip(s) still staging' : 'Play playlist'}>
|
||||
{notReady > 0 && live
|
||||
? <React.Fragment><span className="po-trans-icon">⏳</span><span className="po-trans-label">{notReady} staging</span></React.Fragment>
|
||||
: <React.Fragment><span className="po-trans-icon">▶</span><span className="po-trans-label">Play</span></React.Fragment>}
|
||||
</button>
|
||||
<button className="po-trans-btn po-trans-pause" disabled={!live || busy} onClick={pause} title="Pause">
|
||||
<span className="po-trans-icon">⏸</span>
|
||||
</button>
|
||||
<button className="po-trans-btn po-trans-resume" disabled={!live || busy} onClick={resume} title="Resume">
|
||||
<span className="po-trans-icon">⏵</span>
|
||||
</button>
|
||||
<button className="po-trans-btn po-trans-skip" disabled={!live || busy} onClick={skip} title="Skip to next">
|
||||
<span className="po-trans-icon">⏭</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SCTE-35 panel ─────────────────────────────────────────────────────────────
|
||||
// NOTE: No SCTE-35 endpoint exists in /api/v1/playout at this time.
|
||||
// The backend does not yet implement POST /channels/:id/scte35 or similar.
|
||||
// These buttons are wired to a stub that logs the intent and shows a toast.
|
||||
// When the backend is ready, replace `scte35Stub` with the real poFetch call.
|
||||
function Scte35Panel({ channel }) {
|
||||
const [lastFired, setLastFired] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const scte35Stub = async (type, duration) => {
|
||||
// TODO: wire to POST /playout/channels/:id/scte35 when backend implements it.
|
||||
// Example body: { type, duration_s: duration }
|
||||
console.warn('[SCTE-35] Backend endpoint not yet implemented. Would send:', { type, duration });
|
||||
setLastFired({ type, duration, ts: new Date() });
|
||||
};
|
||||
|
||||
const fire = (type, duration) => async () => {
|
||||
setBusy(true);
|
||||
try { await scte35Stub(type, duration); }
|
||||
catch (e) { alert('SCTE-35 error: ' + e.message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const fmt = (ts) => ts ? ts.toLocaleTimeString() : null;
|
||||
|
||||
return (
|
||||
<div className="po-card po-scte-card">
|
||||
<div className="po-card-head">
|
||||
<span className="po-section-label">SCTE-35 Break</span>
|
||||
<span className="po-scte-stub-badge" title="Backend endpoint not yet implemented">stub</span>
|
||||
</div>
|
||||
<div className="po-scte-body">
|
||||
<div className="po-scte-row">
|
||||
<button className="po-fire amber" disabled={busy || channel.status !== 'running'}
|
||||
onClick={fire('splice_insert', 30)}>
|
||||
30s Break
|
||||
</button>
|
||||
<button className="po-fire amber" disabled={busy || channel.status !== 'running'}
|
||||
onClick={fire('splice_insert', 60)}>
|
||||
60s Break
|
||||
</button>
|
||||
<button className="po-fire amber" disabled={busy || channel.status !== 'running'}
|
||||
onClick={fire('splice_insert', 120)}>
|
||||
2m Break
|
||||
</button>
|
||||
</div>
|
||||
<div className="po-scte-row">
|
||||
<button className="po-fire in" disabled={busy || channel.status !== 'running'}
|
||||
onClick={fire('splice_in', 0)}>
|
||||
Splice In
|
||||
</button>
|
||||
<button className="po-fire out" disabled={busy || channel.status !== 'running'}
|
||||
onClick={fire('splice_out', 0)}>
|
||||
Splice Out
|
||||
</button>
|
||||
</div>
|
||||
{lastFired && (
|
||||
<div className="po-scte-last mono">
|
||||
Last: {lastFired.type} {lastFired.duration > 0 ? lastFired.duration + 's' : ''} @ {fmt(lastFired.ts)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Channel detail (monitors + bin + playlist + transport) ───────────────────
|
||||
// As-run compliance log. Polls the existing GET /channels/:id/asrun endpoint
|
||||
// (rows written by the scheduler health tick on every clip change) and shows the
|
||||
// most recent plays: start time, clip, on-air duration, result.
|
||||
function AsRunPanel({ channel, refreshKey }) {
|
||||
// ── Now Playing card ──────────────────────────────────────────────────────────
|
||||
function NowPlayingCard({ engine, elapsed, items }) {
|
||||
if (!engine || engine.currentIndex < 0) {
|
||||
return (
|
||||
<div className="po-card po-nowplaying-card">
|
||||
<div className="po-card-head">
|
||||
<span className="po-section-label">Now Playing</span>
|
||||
</div>
|
||||
<div className="po-nowplaying-empty muted">Nothing playing</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentItem = items[engine.currentIndex] || null;
|
||||
const clipDurSecs = currentItem ? itemEffectiveDuration(currentItem) : 0;
|
||||
const progress = clipDurSecs > 0 ? Math.min(1, elapsed / clipDurSecs) : 0;
|
||||
const timeRemaining = clipDurSecs > 0 ? Math.max(0, clipDurSecs - elapsed) : 0;
|
||||
|
||||
const nextItem = items[engine.currentIndex + 1] || null;
|
||||
|
||||
return (
|
||||
<div className="po-card po-nowplaying-card">
|
||||
<div className="po-card-head">
|
||||
<span className="po-section-label">Now Playing</span>
|
||||
<span className="po-nowplaying-pos mono muted">
|
||||
{engine.currentIndex + 1} / {engine.playlistLength || items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="po-nowplaying-name">{engine.currentClip || '—'}</div>
|
||||
<div className="po-nowplaying-progress">
|
||||
<div className="po-nowplaying-bar">
|
||||
<div className="po-nowplaying-fill" style={{ width: (progress * 100) + '%' }} />
|
||||
</div>
|
||||
<div className="po-nowplaying-times mono">
|
||||
<span className="po-nowplaying-elapsed">{fmtDuration(elapsed)}</span>
|
||||
<span className="po-nowplaying-remain muted">-{fmtDuration(timeRemaining)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{nextItem && (
|
||||
<div className="po-nowplaying-next">
|
||||
<span className="po-section-label" style={{ fontSize: 10 }}>Up next</span>
|
||||
<span className="po-nowplaying-next-name muted">{nextItem.clip_name || nextItem.asset_id}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Timeline ──────────────────────────────────────────────────────────────────
|
||||
// Shows real playlist items mapped to a horizontal timeline. Width proportional
|
||||
// to duration. Clicking a clip is informational (no seek API on the engine).
|
||||
function Timeline({ items, activeIndex, elapsed }) {
|
||||
const totalSecs = items.reduce((sum, it) => sum + itemEffectiveDuration(it), 0);
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="po-tl">
|
||||
<div className="po-tl-head">
|
||||
<span className="po-section-label">Timeline</span>
|
||||
</div>
|
||||
<div className="po-tl-empty muted">Add clips to the playlist to see the timeline.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Compute offset of active clip for the playhead
|
||||
let playheadPct = 0;
|
||||
if (activeIndex >= 0 && totalSecs > 0) {
|
||||
const offsetSecs = items.slice(0, activeIndex).reduce((s, it) => s + itemEffectiveDuration(it), 0);
|
||||
const clipDur = itemEffectiveDuration(items[activeIndex] || {});
|
||||
playheadPct = ((offsetSecs + Math.min(elapsed, clipDur)) / totalSecs) * 100;
|
||||
}
|
||||
|
||||
const COLORS = ['#3b82f6','#8b5cf6','#06b6d4','#10b981','#f59e0b','#ec4899','#6366f1','#14b8a6'];
|
||||
|
||||
return (
|
||||
<div className="po-tl">
|
||||
<div className="po-tl-head">
|
||||
<span className="po-section-label">Timeline</span>
|
||||
<span className="mono muted" style={{ fontSize: 11 }}>{fmtDuration(totalSecs)} total</span>
|
||||
</div>
|
||||
<div className="po-tl-track-wrap">
|
||||
{/* Playhead */}
|
||||
{activeIndex >= 0 && (
|
||||
<div className="po-tl-playhead" style={{ left: playheadPct + '%' }} />
|
||||
)}
|
||||
<div className="po-tl-track">
|
||||
{items.map((it, i) => {
|
||||
const dur = itemEffectiveDuration(it);
|
||||
const pct = totalSecs > 0 ? (dur / totalSecs) * 100 : 0;
|
||||
const isActive = i === activeIndex;
|
||||
const color = COLORS[i % COLORS.length];
|
||||
return (
|
||||
<div key={it.id}
|
||||
className={'po-tl-clip' + (isActive ? ' po-tl-clip--active' : '')}
|
||||
style={{ width: pct + '%', '--clip-color': color }}
|
||||
title={`${it.clip_name || it.asset_id} · ${fmtDuration(dur)}`}>
|
||||
<span className="po-tl-clip-name">{it.clip_name || it.asset_id}</span>
|
||||
<span className="po-tl-clip-dur mono">{fmtDuration(dur)}</span>
|
||||
{it.media_status === 'staging' && (
|
||||
<span className="po-tl-staging-dot" title="Staging…" />
|
||||
)}
|
||||
{it.media_status === 'error' && (
|
||||
<span className="po-tl-error-dot" title="Stage error" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Time ruler (rough marks) */}
|
||||
<div className="po-tl-ruler">
|
||||
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
|
||||
<span key={i} className="po-tl-ruler-mark mono"
|
||||
style={{ left: (i * 25) + '%' }}>
|
||||
{fmtDuration((totalSecs * i) / 4)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── As-run drawer ─────────────────────────────────────────────────────────────
|
||||
function AsRunDrawer({ channel, refreshKey, open, onClose }) {
|
||||
const [rows, setRows] = React.useState([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -472,37 +801,56 @@ function AsRunPanel({ channel, refreshKey }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="po-asrun">
|
||||
<div className="po-section-label">As-Run Log</div>
|
||||
{rows.length === 0
|
||||
? <div className="mono muted" style={{ padding: '8px 0' }}>No as-run entries yet.</div>
|
||||
: (
|
||||
<table className="po-asrun-table">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Clip</th><th>Duration</th><th>Result</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 50).map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="mono">{fmtTime(r.started_at)}</td>
|
||||
<td>{r.clip_name || r.item_id || '—'}</td>
|
||||
<td className="mono">{r.duration_s != null ? fmtDuration(Number(r.duration_s)) : (r.ended_at ? '—' : 'on air')}</td>
|
||||
<td className={'po-asrun-result po-asrun-' + (r.result || 'played')}>{r.result || 'played'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<React.Fragment>
|
||||
{/* Backdrop */}
|
||||
{open && <div className="po-drawer-backdrop" onClick={onClose} />}
|
||||
<div className={'po-drawer' + (open ? ' po-drawer--open' : '')}>
|
||||
<div className="po-drawer-head">
|
||||
<span className="po-section-label" style={{ fontSize: 13, color: 'var(--text-1)' }}>As-Run Log</span>
|
||||
<span className="mono muted" style={{ fontSize: 11, marginLeft: 8 }}>{rows.length} entries</span>
|
||||
<button className="btn ghost xs po-drawer-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="po-drawer-body">
|
||||
{rows.length === 0
|
||||
? <div className="mono muted" style={{ padding: '16px 0' }}>No as-run entries yet.</div>
|
||||
: (
|
||||
<table className="po-asrun-table">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Clip</th><th>Duration</th><th>Result</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 200).map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="mono">{fmtTime(r.started_at)}</td>
|
||||
<td>{r.clip_name || r.item_id || '—'}</td>
|
||||
<td className="mono">
|
||||
{r.duration_s != null
|
||||
? fmtDuration(Number(r.duration_s))
|
||||
: (r.ended_at ? '—' : 'on air')}
|
||||
</td>
|
||||
<td className={'po-asrun-result po-asrun-' + (r.result || 'played')}>
|
||||
{r.result || 'played'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Channel detail ────────────────────────────────────────────────────────────
|
||||
function ChannelDetail({ channel, onChannelChange }) {
|
||||
const [playlists, setPlaylists] = React.useState([]);
|
||||
const [playlists, setPlaylists] = React.useState([]);
|
||||
const [playlistId, setPlaylistId] = React.useState(null);
|
||||
const [items, setItems] = React.useState([]);
|
||||
const [engine, setEngine] = React.useState(null);
|
||||
const [ch, setCh] = React.useState(channel);
|
||||
const [items, setItems] = React.useState([]);
|
||||
const [engine, setEngine] = React.useState(null);
|
||||
const [ch, setCh] = React.useState(channel);
|
||||
const [asRunOpen, setAsRunOpen] = React.useState(false);
|
||||
const [binOpen, setBinOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => { setCh(channel); }, [channel.id]);
|
||||
|
||||
|
|
@ -511,7 +859,6 @@ function ChannelDetail({ channel, onChannelChange }) {
|
|||
setPlaylists(pls);
|
||||
if (pls.length && !playlistId) setPlaylistId(pls[0].id);
|
||||
if (!pls.length) {
|
||||
// Auto-create a default playlist so the operator can start dragging.
|
||||
const created = await poFetch('/playlists', {
|
||||
method: 'POST', body: JSON.stringify({ channel_id: channel.id, name: 'Main' }),
|
||||
});
|
||||
|
|
@ -528,7 +875,7 @@ function ChannelDetail({ channel, onChannelChange }) {
|
|||
React.useEffect(() => { loadPlaylists(); }, [channel.id]);
|
||||
React.useEffect(() => { loadItems(); }, [playlistId]);
|
||||
|
||||
// Poll engine status + item staging while live.
|
||||
// Poll engine status + item staging.
|
||||
React.useEffect(() => {
|
||||
let t;
|
||||
const poll = async () => {
|
||||
|
|
@ -559,34 +906,75 @@ function ChannelDetail({ channel, onChannelChange }) {
|
|||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
|
||||
// engine.currentIndex maps directly to the sorted item position.
|
||||
const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1;
|
||||
const elapsed = useElapsed(engine && engine.currentItemStartedAt);
|
||||
const onAir = ch.status === 'running';
|
||||
|
||||
return (
|
||||
<div className="po-detail">
|
||||
<div className="po-detail-head">
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>{ch.name}</h3>
|
||||
<span className="mono muted">{ch.output_type?.toUpperCase()} · {ch.video_format} · {ch.status}</span>
|
||||
<div className="po-root">
|
||||
{/* ── Top rail: monitor + right panel ── */}
|
||||
<div className="po-top">
|
||||
{/* PGM monitor + transport */}
|
||||
<div className="po-pgm-col">
|
||||
<ProgramMonitor channel={ch} engine={engine} elapsed={elapsed} />
|
||||
<Transport
|
||||
channel={ch}
|
||||
playlistId={playlistId}
|
||||
items={items}
|
||||
onStatus={loadItems}
|
||||
/>
|
||||
</div>
|
||||
<div className="po-detail-actions">
|
||||
{ch.status === 'running'
|
||||
? <button className="btn danger" onClick={stopChannel}>Stop channel</button>
|
||||
: <button className="btn primary" onClick={startChannel}>Start channel</button>}
|
||||
{ch.status !== 'running' && (
|
||||
<button className="btn ghost danger sm" onClick={deleteChannel} title="Delete this channel">Delete</button>
|
||||
)}
|
||||
|
||||
{/* Right rail */}
|
||||
<div className="po-rail">
|
||||
{/* Channel controls */}
|
||||
<div className="po-card po-channel-card">
|
||||
<div className="po-card-head">
|
||||
<span className="po-section-label">Channel</span>
|
||||
<div className="po-channel-actions">
|
||||
{ch.status === 'running'
|
||||
? <button className="btn danger sm" onClick={stopChannel}>Stop</button>
|
||||
: <button className="btn primary sm" onClick={startChannel}>Start</button>}
|
||||
{ch.status !== 'running' && (
|
||||
<button className="btn ghost danger xs" onClick={deleteChannel} title="Delete">✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="po-channel-meta mono muted">
|
||||
{ch.output_type?.toUpperCase()} · {ch.video_format}
|
||||
{ch.restart_count > 0 && (
|
||||
<span className="po-restart-badge" title={'Restarted ' + ch.restart_count + ' time(s)'}>
|
||||
↺{ch.restart_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ch.error_message && <div className="alert error" style={{ marginTop: 8 }}>{ch.error_message}</div>}
|
||||
</div>
|
||||
|
||||
{/* Now playing */}
|
||||
<NowPlayingCard engine={engine} elapsed={elapsed} items={items} />
|
||||
|
||||
{/* SCTE-35 */}
|
||||
<Scte35Panel channel={ch} />
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="po-rail-actions">
|
||||
<button className="btn ghost sm po-rail-action-btn" onClick={() => setBinOpen(b => !b)}>
|
||||
{binOpen ? '▸ Hide' : '▾ Media Bin'}
|
||||
</button>
|
||||
<button className="btn ghost sm po-rail-action-btn" onClick={() => setAsRunOpen(true)}>
|
||||
As-Run Log
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{ch.error_message && <div className="alert error">{ch.error_message}</div>}
|
||||
|
||||
<div className="po-grid">
|
||||
<ProgramMonitor channel={ch} engine={engine} />
|
||||
{/* Media bin (collapsible, below top rail) */}
|
||||
{binOpen && (
|
||||
<MediaBin projectId={ch.project_id} />
|
||||
</div>
|
||||
|
||||
<Transport channel={ch} playlistId={playlistId} items={items} onStatus={() => loadItems()} />
|
||||
)}
|
||||
|
||||
{/* Playlist */}
|
||||
{playlistId && (
|
||||
<Playlist
|
||||
channel={ch}
|
||||
|
|
@ -597,17 +985,26 @@ function ChannelDetail({ channel, onChannelChange }) {
|
|||
/>
|
||||
)}
|
||||
|
||||
<AsRunPanel channel={ch} refreshKey={engine && engine.currentItemId} />
|
||||
{/* Timeline */}
|
||||
<Timeline items={items} activeIndex={activeIndex} elapsed={elapsed} />
|
||||
|
||||
{/* As-run drawer */}
|
||||
<AsRunDrawer
|
||||
channel={ch}
|
||||
refreshKey={engine && engine.currentItemId}
|
||||
open={asRunOpen}
|
||||
onClose={() => setAsRunOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Top-level page ───────────────────────────────────────────────────────────
|
||||
// ── Top-level page ────────────────────────────────────────────────────────────
|
||||
function Playout() {
|
||||
const [channels, setChannels] = React.useState(null);
|
||||
const [channels, setChannels] = React.useState(null);
|
||||
const [selectedId, setSelectedId] = React.useState(null);
|
||||
const [showCreate, setShowCreate] = React.useState(false);
|
||||
const [err, setErr] = React.useState(null);
|
||||
const [err, setErr] = React.useState(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -634,38 +1031,54 @@ function Playout() {
|
|||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<span className="title">Playout — Master Control</span>
|
||||
<span className="subtitle">Schedule and play assets to SDI, NDI, SRT or RTMP.</span>
|
||||
{/* ── Page header with channel tabs + clock ── */}
|
||||
<div className="po-head">
|
||||
<div className="po-head-left">
|
||||
<span className="po-head-title">Master Control</span>
|
||||
<div className="po-channels-bar">
|
||||
{(channels || []).map(c => (
|
||||
<button key={c.id}
|
||||
className={'po-chan-tab ' + (c.id === selectedId ? 'active' : '')}
|
||||
onClick={() => setSelectedId(c.id)}>
|
||||
<span className={'po-chan-dot ' + (c.status === 'running' ? 'live' : '')} />
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
<button className="btn ghost sm" onClick={() => setShowCreate(true)}>+ Channel</button>
|
||||
</div>
|
||||
</div>
|
||||
<LiveClock />
|
||||
</div>
|
||||
|
||||
<div className="page-body po-page">
|
||||
{err && <div className="alert error">{err}</div>}
|
||||
<div className="po-channels-bar">
|
||||
{(channels || []).map(c => (
|
||||
<button key={c.id}
|
||||
className={'po-chan-tab ' + (c.id === selectedId ? 'active' : '')}
|
||||
onClick={() => setSelectedId(c.id)}>
|
||||
<span className={'po-chan-dot ' + (c.status === 'running' ? 'live' : '')} />
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
<button className="btn ghost sm" onClick={() => setShowCreate(true)}>+ Channel</button>
|
||||
</div>
|
||||
|
||||
{channels === null && <div className="muted">Loading channels…</div>}
|
||||
{channels !== null && channels.length === 0 && (
|
||||
<div className="po-empty">
|
||||
<p className="muted">No playout channels yet.</p>
|
||||
<button className="btn primary" onClick={() => setShowCreate(true)}>Create your first channel</button>
|
||||
<button className="btn primary" onClick={() => setShowCreate(true)}>
|
||||
Create your first channel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selected && <ChannelDetail key={selected.id} channel={selected} onChannelChange={onChannelChange} />}
|
||||
{selected && (
|
||||
<ChannelDetail
|
||||
key={selected.id}
|
||||
channel={selected}
|
||||
onChannelChange={onChannelChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<ChannelCreate
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={(ch) => { setShowCreate(false); setChannels(cs => [...(cs || []), ch]); setSelectedId(ch.id); }}
|
||||
onCreated={(ch) => {
|
||||
setShowCreate(false);
|
||||
setChannels(cs => [...(cs || []), ch]);
|
||||
setSelectedId(ch.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue