From be819353a7d3fe057eb002f043fc993150e048c1 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Sun, 31 May 2026 19:28:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(playout):=20redesigned=20MCR=20screen=20?= =?UTF-8?q?=E2=80=94=20design=20polish=20+=20real=20API=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/web-ui/public/screens-playout.jsx | 787 ++++++++++++++++----- 1 file changed, 600 insertions(+), 187 deletions(-) diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx index d0dd9d6..6a007bb 100644 --- a/services/web-ui/public/screens-playout.jsx +++ b/services/web-ui/public/screens-playout.jsx @@ -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 ( + {h}:{m}:{s} + ); +} + +// ── 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 }) { ); } - // srt / rtmp return (
@@ -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 ( ); } -// ── 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 ( -
- - - - - -
- ); -} - -// ── 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 ( + + ); } -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 ( -
-
- {onAir ? '● ON AIR' : '○ OFF'} - {channel.output_type?.toUpperCase()} · {channel.video_format} -
-
+
+ {/* Screen */} +
-
- {engine && engine.currentClip - ? {engine.currentClip} - : {onAir ? 'Idle' : 'Stopped'}} - {engine && engine.currentIndex >= 0 && ( - - - {fmtElapsed(elapsed)} - - clip {engine.currentIndex + 1}/{engine.playlistLength || 0} - {engine.loop && } - - )} - {engine && engine.lastError && ( - + + {/* Clip progress bar */} +
+
+
+ + {/* Monitor footer */} +
+ + {engine && engine.currentClip + ? engine.currentClip + : onAir ? 'Idle' : 'Stopped'} + + + {engine && engine.currentIndex >= 0 && ( + + + {engine.currentIndex + 1}/{engine.playlistLength || 0} + + {timeRemaining > 0 && ( + + -{fmtDuration(timeRemaining)} + + )} + {engine.loop && } + {engine.lastError && ( + + )} + + )} + +
+
+ ); +} + +// ── 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 ( +
+
+ + + + + +
+
+ ); +} + +// ── 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 ( +
+
+ SCTE-35 Break + stub +
+
+
+ + + +
+
+ + +
+ {lastFired && ( +
+ Last: {lastFired.type} {lastFired.duration > 0 ? lastFired.duration + 's' : ''} @ {fmt(lastFired.ts)} +
)}
); } -// ── 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 ( +
+
+ Now Playing +
+
Nothing playing
+
+ ); + } + + 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 ( +
+
+ Now Playing + + {engine.currentIndex + 1} / {engine.playlistLength || items.length} + +
+
{engine.currentClip || '—'}
+
+
+
+
+
+ {fmtDuration(elapsed)} + -{fmtDuration(timeRemaining)} +
+
+ {nextItem && ( +
+ Up next + {nextItem.clip_name || nextItem.asset_id} +
+ )} +
+ ); +} + +// ── 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 ( +
+
+ Timeline +
+
Add clips to the playlist to see the timeline.
+
+ ); + } + + // 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 ( +
+
+ Timeline + {fmtDuration(totalSecs)} total +
+
+ {/* Playhead */} + {activeIndex >= 0 && ( +
+ )} +
+ {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 ( +
+ {it.clip_name || it.asset_id} + {fmtDuration(dur)} + {it.media_status === 'staging' && ( + + )} + {it.media_status === 'error' && ( + + )} +
+ ); + })} +
+ {/* Time ruler (rough marks) */} +
+ {totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => ( + + {fmtDuration((totalSecs * i) / 4)} + + ))} +
+
+
+ ); +} + +// ── 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 ( -
-
As-Run Log
- {rows.length === 0 - ?
No as-run entries yet.
- : ( - - - - - - {rows.slice(0, 50).map((r) => ( - - - - - - - ))} - -
TimeClipDurationResult
{fmtTime(r.started_at)}{r.clip_name || r.item_id || '—'}{r.duration_s != null ? fmtDuration(Number(r.duration_s)) : (r.ended_at ? '—' : 'on air')}{r.result || 'played'}
- )} -
+ + {/* Backdrop */} + {open &&
} +
+
+ As-Run Log + {rows.length} entries + +
+
+ {rows.length === 0 + ?
No as-run entries yet.
+ : ( + + + + + + {rows.slice(0, 200).map((r) => ( + + + + + + + ))} + +
TimeClipDurationResult
{fmtTime(r.started_at)}{r.clip_name || r.item_id || '—'} + {r.duration_s != null + ? fmtDuration(Number(r.duration_s)) + : (r.ended_at ? '—' : 'on air')} + + {r.result || 'played'} +
+ )} +
+
+ ); } +// ── 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 ( -
-
-
-

{ch.name}

- {ch.output_type?.toUpperCase()} · {ch.video_format} · {ch.status} +
+ {/* ── Top rail: monitor + right panel ── */} +
+ {/* PGM monitor + transport */} +
+ +
-
- {ch.status === 'running' - ? - : } - {ch.status !== 'running' && ( - - )} + + {/* Right rail */} +
+ {/* Channel controls */} +
+
+ Channel +
+ {ch.status === 'running' + ? + : } + {ch.status !== 'running' && ( + + )} +
+
+
+ {ch.output_type?.toUpperCase()} · {ch.video_format} + {ch.restart_count > 0 && ( + + ↺{ch.restart_count} + + )} +
+ {ch.error_message &&
{ch.error_message}
} +
+ + {/* Now playing */} + + + {/* SCTE-35 */} + + + {/* Quick actions */} +
+ + +
- {ch.error_message &&
{ch.error_message}
} -
- + {/* Media bin (collapsible, below top rail) */} + {binOpen && ( -
- - loadItems()} /> + )} + {/* Playlist */} {playlistId && ( )} - + {/* Timeline */} + + + {/* As-run drawer */} + setAsRunOpen(false)} + />
); } -// ── 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 (
-
- Playout — Master Control - Schedule and play assets to SDI, NDI, SRT or RTMP. + {/* ── Page header with channel tabs + clock ── */} +
+
+ Master Control +
+ {(channels || []).map(c => ( + + ))} + +
+
+
+
{err &&
{err}
} -
- {(channels || []).map(c => ( - - ))} - -
{channels === null &&
Loading channels…
} {channels !== null && channels.length === 0 && (

No playout channels yet.

- +
)} - {selected && } + {selected && ( + + )}
{showCreate && ( setShowCreate(false)} - onCreated={(ch) => { setShowCreate(false); setChannels(cs => [...(cs || []), ch]); setSelectedId(ch.id); }} + onCreated={(ch) => { + setShowCreate(false); + setChannels(cs => [...(cs || []), ch]); + setSelectedId(ch.id); + }} /> )}