+ {/* Screen */}
+
+
+ {/* ON AIR badge */}
+ {onAir && (
+
ON AIR
+ )}
+
{!onAir && (
-
Channel stopped
+
+
+ Channel stopped
+
)}
+
+ {/* Timecode overlay */}
+ {onAir && (
+
{fmtTimecode(elapsed)}
+ )}
+
+ {/* Audio meters */}
+
-
- {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 (
+
+
+
+ ⏹
+
+ 0 ? notReady + ' clip(s) still staging' : 'Play playlist'}>
+ {notReady > 0 && live
+ ? ⏳ {notReady} staging
+ : ▶ Play }
+
+
+ ⏸
+
+
+ ⏵
+
+
+ ⏭
+
+
+
+ );
+}
+
+// ── 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
+
+
+
+
+ 30s Break
+
+
+ 60s Break
+
+
+ 2m Break
+
+
+
+
+ Splice In
+
+
+ Splice Out
+
+
+ {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.
- : (
-
-
- Time Clip Duration Result
-
-
- {rows.slice(0, 50).map((r) => (
-
- {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.
+ : (
+
+
+ Time Clip Duration Result
+
+
+ {rows.slice(0, 200).map((r) => (
+
+ {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'
- ?
Stop channel
- :
Start channel }
- {ch.status !== 'running' && (
-
Delete
- )}
+
+ {/* Right rail */}
+
+ {/* Channel controls */}
+
+
+
Channel
+
+ {ch.status === 'running'
+ ? Stop
+ : Start }
+ {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 */}
+
+ setBinOpen(b => !b)}>
+ {binOpen ? '▸ Hide' : '▾ Media Bin'}
+
+ setAsRunOpen(true)}>
+ As-Run Log
+
+
- {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 => (
+ setSelectedId(c.id)}>
+
+ {c.name}
+
+ ))}
+ setShowCreate(true)}>+ Channel
+
+
+
+
{err &&
{err}
}
-
- {(channels || []).map(c => (
- setSelectedId(c.id)}>
-
- {c.name}
-
- ))}
- setShowCreate(true)}>+ Channel
-
{channels === null &&
Loading channels…
}
{channels !== null && channels.length === 0 && (
No playout channels yet.
-
setShowCreate(true)}>Create your first channel
+
setShowCreate(true)}>
+ Create your first channel
+
)}
- {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);
+ }}
/>
)}