// screens-playout.jsx — Master Control (MCR) playout page.
//
// Redesigned (timeline-centric): styled PGM monitor with audio meters +
// timecode, transport bar, SCTE-35 break panel, now-playing + up-next, a
// horizontal timeline, and a slide-in as-run drawer — all wired to the real
// /api/v1/playout backend (no mock data).
//
// 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; carries scteActive)
// - HLS preview: GET /channels/:id/hls/index.m3u8 (hls.js + error recovery)
// - 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: GET/POST /channels/:id/scte, POST /channels/:id/scte/trigger,
// DELETE /channels/:id/scte/:id
//
// esbuild bundle:false + jsx:transform — no import statements.
// Globals: React, Icon, window.ZAMPP_API, window.ZAMPP_DATA, window.Hls,
// window.useConfirm/ConfirmModal.
const PO_OUTPUTS = [
{ value: 'srt', label: 'SRT' },
{ value: 'rtmp', label: 'RTMP' },
{ value: 'ndi', label: 'NDI' },
{ value: 'decklink', label: 'SDI (DeckLink)' },
];
const PO_FORMATS = ['1080p5994', '1080i5994', '1080p2997', '720p5994', '1080i50', '1080p25'];
async function poFetch(path, opts) {
return window.ZAMPP_API.fetch('/playout' + path, opts);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function playoutFmtDur(secs) {
if (!secs || secs < 0) return '—';
const s = Math.floor(secs);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = s % 60;
const mm = String(m).padStart(2, '0');
const ssStr = String(ss).padStart(2, '0');
return h > 0 ? `${h}:${mm}:${ssStr}` : `${m}:${ssStr}`;
}
function playoutFmtTC(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;
const outPt = it.out_point != null ? Number(it.out_point) : total;
return Math.max(0, outPt - inPt);
}
// ── 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');
return (
{pad(time.getHours())}:{pad(time.getMinutes())}:{pad(time.getSeconds())}
);
}
// ── 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') {
return (
set('device_index', parseInt(e.target.value, 10) || 1)} />
);
}
if (type === 'ndi') {
return (
set('ndi_name', e.target.value)} />
);
}
return (
set('url', e.target.value)} />
{type === 'rtmp' && (
set('key', e.target.value)} />
)}
{type === 'srt' && (
set('latency', parseInt(e.target.value, 10) || 200)} />
)}
);
}
// ── Channel create modal ──────────────────────────────────────────────────────
function ChannelCreate({ onClose, onCreated }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [name, setName] = React.useState('');
const [outputType, setOutputType] = React.useState('srt');
const [config, setConfig] = React.useState({});
const [videoFormat, setVideoFormat] = React.useState('1080i5994');
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const submit = async () => {
setBusy(true); setErr(null);
try {
const ch = await poFetch('/channels', {
method: 'POST',
body: JSON.stringify({
name, output_type: outputType, output_config: config,
video_format: videoFormat, project_id: projectId || null,
}),
});
onCreated(ch);
} catch (e) { setErr(e.message || 'Failed to create channel'); }
finally { setBusy(false); }
};
return (
e.stopPropagation()} style={{ maxWidth: 460 }}>
New Playout Channel
setName(e.target.value)} placeholder="Channel 1" />
{err &&
{err}
}
);
}
// ── Media bin ─────────────────────────────────────────────────────────────────
function MediaBin({ projectId }) {
const ASSETS = (window.ZAMPP_DATA?.ASSETS || []).filter(a =>
!projectId || a.project_id === projectId);
const [q, setQ] = React.useState('');
const filtered = ASSETS.filter(a => !q || (a.name || '').toLowerCase().includes(q.toLowerCase()));
const onDragStart = (e, asset) => {
e.dataTransfer.setData('application/x-df-asset', JSON.stringify({ id: asset.id, name: asset.name }));
e.dataTransfer.effectAllowed = 'copy';
};
return (
Media Bin
setQ(e.target.value)} style={{ maxWidth: 160 }} />
{filtered.length === 0 &&
No assets.
}
{filtered.map(a => (
onDragStart(e, a)} title="Drag into the playlist">
{a.name}
{a.duration || ''}
))}
);
}
// ── Staging bar ───────────────────────────────────────────────────────────────
function StagingBar({ status }) {
return (
);
}
// ── Playlist ──────────────────────────────────────────────────────────────────
function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
const [dragIndex, setDragIndex] = React.useState(null);
const [dropErr, setDropErr] = React.useState(null);
const onItemDragStart = (e, index) => {
setDragIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const onItemDragOver = (e) => { e.preventDefault(); };
const onItemDrop = async (e, index) => {
e.preventDefault();
e.stopPropagation();
setDropErr(null);
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
if (assetRaw) {
try {
const asset = JSON.parse(assetRaw);
await poFetch('/playlists/' + playlistId + '/items', {
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
});
onReload();
} catch (err) { setDropErr(err.message || 'Failed to add clip'); }
return;
}
if (dragIndex === null || dragIndex === index) return;
const order = items.map(i => i.id);
const [moved] = order.splice(dragIndex, 1);
order.splice(index, 0, moved);
setDragIndex(null);
try {
await poFetch('/playlists/' + playlistId + '/reorder', {
method: 'PUT', body: JSON.stringify({ order }),
});
onReload();
} catch (err) { setDropErr(err.message || 'Failed to reorder'); }
};
const onContainerDrop = async (e) => {
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
if (!assetRaw) return;
e.preventDefault();
setDropErr(null);
try {
const asset = JSON.parse(assetRaw);
await poFetch('/playlists/' + playlistId + '/items', {
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
});
onReload();
} catch (err) { setDropErr(err.message || 'Failed to add clip'); }
};
const removeItem = async (id) => {
try { await poFetch('/items/' + id, { method: 'DELETE' }); onReload(); }
catch (err) { setDropErr(err.message || 'Failed to remove'); }
};
const restage = async (id) => {
try { await poFetch('/items/' + id + '/stage', { method: 'POST' }); onReload(); }
catch (err) { setDropErr(err.message || 'Failed to restage'); }
};
const totalSecs = items.reduce((sum, it) => sum + itemEffectiveDuration(it), 0);
return (
e.preventDefault()} onDrop={onContainerDrop}>
Playlist
{items.length} clip{items.length !== 1 ? 's' : ''} · {playoutFmtDur(totalSecs)}
{dropErr && {dropErr}}
{items.length === 0 && (
Drag clips here to build the playlist.
)}
{items.map((it, index) => {
const isActive = index === activeIndex;
const dur = itemEffectiveDuration(it);
return (
onItemDragStart(e, index)}
onDragOver={onItemDragOver}
onDrop={e => onItemDrop(e, index)}>
{isActive ? ▶ : index + 1}
{it.clip_name || it.asset_id}
{playoutFmtDur(dur)}
{it.media_status}
{it.media_status === 'error' && (
)}
);
})}
);
}
// ── 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. (Named PoAudioMeter
// to avoid colliding with the global AudioMeter from visuals.jsx.)
function PoAudioMeter({ onAir }) {
const canvasRef = React.useRef(null);
const rafRef = React.useRef(null);
React.useEffect(() => {
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) {
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);
ctx.fillStyle = 'rgba(255,255,255,0.05)';
ctx.fillRect(x, 0, barW, h);
const greenH = Math.min(fillH, Math.floor(h * 0.7));
ctx.fillStyle = '#22c55e';
ctx.fillRect(x, h - greenH, barW, greenH);
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);
}
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);
}
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 (
);
}
// ── PGM monitor ───────────────────────────────────────────────────────────────
function ProgramMonitor({ channel, engine, elapsed }) {
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.
const previewUrl = `/api/v1/playout/channels/${channel.id}/hls/index.m3u8`;
const scte = engine && engine.scteActive;
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,
// Keep hls.js pinned to the live edge. The preview is a CPU-encoded
// confidence monitor whose live-edge segment may still be mid-write
// when first fetched; a small back-buffer + tolerant stall handling
// lets the player skip transient gaps instead of freezing.
backBufferLength: 8,
maxBufferLength: 10,
liveDurationInfinity: true,
highBufferWatchdogPeriod: 1,
nudgeMaxRetry: 10,
// 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;
hls.loadSource(previewUrl);
hls.attachMedia(vid);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
// Resilient recovery. Without this, the FIRST fatal hls.js error (a
// buffer stall on the live edge, a media/decode error, or a transient
// fragment/playlist load error against the rewinding live playlist)
// permanently halts playback and the monitor goes black — exactly the
// "flashes a frame then stays black" symptom. We distinguish error types
// and recover in place rather than tearing down.
let recoverCount = 0;
hls.on(window.Hls.Events.ERROR, (_evt, data) => {
if (!data.fatal) {
if (data.details === window.Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
try { hls.startLoad(); } catch (_) {}
}
return;
}
switch (data.type) {
case window.Hls.ErrorTypes.NETWORK_ERROR:
try { hls.startLoad(); } catch (_) {}
break;
case window.Hls.ErrorTypes.MEDIA_ERROR:
recoverCount += 1;
if (recoverCount <= 3) {
try { hls.recoverMediaError(); } catch (_) {}
} else {
recoverCount = 0;
try { hls.destroy(); } catch (_) {}
if (hlsRef.current === hls) hlsRef.current = null;
}
break;
default:
try { hls.destroy(); } catch (_) {}
if (hlsRef.current === hls) hlsRef.current = null;
}
});
hls.on(window.Hls.Events.FRAG_BUFFERED, () => {
if (vid.paused) vid.play().catch(() => {});
});
} else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
vid.src = previewUrl;
vid.play().catch(() => {});
}
return () => {
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
};
}, [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;
// SCTE break countdown (seconds remaining in the active break).
const breakRemain = scte && scte.endsAt
? Math.max(0, (new Date(scte.endsAt).getTime() - Date.now()) / 1000)
: 0;
return (
{/* ON AIR / SCTE BREAK badge */}
{onAir && scte && (
SCTE BREAK{breakRemain > 0 ? ' ' + Math.ceil(breakRemain) + 's' : ''}
)}
{onAir && !scte &&
ON AIR
}
{!onAir && (
Channel stopped
)}
{onAir && (
{playoutFmtTC(elapsed)}
)}
{/* 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 && (
-{playoutFmtDur(timeRemaining)}
)}
{engine.loop && ↺}
{engine.lastError && (
⚠
)}
)}
);
}
// ── Transport bar ─────────────────────────────────────────────────────────────
function Transport({ channel, playlistId, items, onStatus, onError }) {
const [busy, setBusy] = React.useState(false);
const act = async (fn) => {
setBusy(true);
try { await fn(); } catch (e) { onError && onError(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 ─────────────────────────────────────────────────────────────
// Wired to the real backend:
// POST /channels/:id/scte/trigger — splice now (immediate ad break)
// POST /channels/:id/scte — schedule a break (at a playlist pos)
// GET /channels/:id/scte — recent breaks
// The active break (with countdown) comes from engine.scteActive on /status.
function Scte35Panel({ channel, engine, breaks, onReload, onError }) {
const [busy, setBusy] = React.useState(false);
const live = channel.status === 'running';
const scte = engine && engine.scteActive;
const triggerNow = (durationS, type) => async () => {
setBusy(true);
try {
await poFetch('/channels/' + channel.id + '/scte/trigger', {
method: 'POST', body: JSON.stringify({ type: type || 'immediate', duration_s: durationS }),
});
onReload && onReload();
} catch (e) { onError && onError('SCTE-35: ' + e.message); }
finally { setBusy(false); }
};
const scheduleAfterCurrent = (durationS) => async () => {
setBusy(true);
try {
const pos = (engine && engine.currentIndex >= 0) ? engine.currentIndex : 0;
await poFetch('/channels/' + channel.id + '/scte', {
method: 'POST',
body: JSON.stringify({ type: 'splice_insert', duration_s: durationS, playlist_pos: pos }),
});
onReload && onReload();
} catch (e) { onError && onError('SCTE-35: ' + e.message); }
finally { setBusy(false); }
};
const lastFired = (breaks || []).find(b => b.fired_at) || null;
const pending = (breaks || []).filter(b => b.status === 'pending');
const breakRemain = scte && scte.endsAt
? Math.max(0, Math.ceil((new Date(scte.endsAt).getTime() - Date.now()) / 1000))
: 0;
return (
SCTE-35 Break
{scte
? ● ON AIR
: pending.length > 0
? {pending.length} queued
: null}
{scte && (
In break · {scte.type} · {breakRemain > 0 ? breakRemain + 's left' : 'awaiting return'}
)}
Trigger now
Schedule after current clip
{lastFired && (
Last: {lastFired.type} {lastFired.duration_s > 0 ? lastFired.duration_s + 's' : ''} (event {lastFired.event_id})
)}
);
}
// ── 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 || '—'}
{playoutFmtDur(elapsed)}
-{playoutFmtDur(timeRemaining)}
{nextItem && (
Up next
{nextItem.clip_name || nextItem.asset_id}
)}
);
}
// ── Timeline ──────────────────────────────────────────────────────────────────
function Timeline({ items, activeIndex, elapsed, breaks }) {
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.
);
}
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;
}
// Pending position-based breaks → markers at the end of their playlist_pos clip.
const breakMarkers = [];
if (totalSecs > 0) {
for (const b of (breaks || [])) {
if (b.status !== 'pending' || b.playlist_pos == null) continue;
const pos = Math.min(b.playlist_pos, items.length - 1);
const offsetSecs = items.slice(0, pos + 1).reduce((s, it) => s + itemEffectiveDuration(it), 0);
breakMarkers.push({ id: b.id, pct: (offsetSecs / totalSecs) * 100, dur: b.duration_s });
}
}
const COLORS = ['#3b82f6','#8b5cf6','#06b6d4','#10b981','#f59e0b','#ec4899','#6366f1','#14b8a6'];
return (
Timeline
{playoutFmtDur(totalSecs)} total
{activeIndex >= 0 && (
)}
{breakMarkers.map(m => (
))}
{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}
{playoutFmtDur(dur)}
{it.media_status === 'staging' && (
)}
{it.media_status === 'error' && (
)}
);
})}
{totalSecs > 0 && Array.from({ length: 5 }).map((_, i) => (
{playoutFmtDur((totalSecs * i) / 4)}
))}
);
}
// ── As-run drawer ─────────────────────────────────────────────────────────────
function AsRunDrawer({ channel, refreshKey, open, onClose }) {
const [rows, setRows] = React.useState([]);
React.useEffect(() => {
if (!open) return;
let alive = true;
let t;
const poll = async () => {
try {
const r = await poFetch('/channels/' + channel.id + '/asrun');
if (alive) setRows(Array.isArray(r) ? r : []);
} catch (_) {}
t = setTimeout(poll, 5000);
};
poll();
return () => { alive = false; clearTimeout(t); };
}, [channel.id, refreshKey, open]);
const fmtTime = (ts) => {
if (!ts) return '—';
const d = new Date(ts);
return isNaN(d) ? '—' : d.toLocaleTimeString();
};
return (
{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
? playoutFmtDur(Number(r.duration_s))
: (r.ended_at ? '—' : 'on air')}
|
{r.result || 'played'}
|
))}
)}
);
}
// ── Channel detail ────────────────────────────────────────────────────────────
function ChannelDetail({ channel, onChannelChange }) {
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 [asRunOpen, setAsRunOpen] = React.useState(false);
const [binOpen, setBinOpen] = React.useState(false);
const [breaks, setBreaks] = React.useState([]);
const [actionErr, setActionErr] = React.useState(null);
const [confirm, confirmModal] = window.useConfirm();
React.useEffect(() => { setCh(channel); }, [channel.id]);
const loadPlaylists = React.useCallback(async () => {
const pls = await poFetch('/playlists?channel_id=' + channel.id);
setPlaylists(pls);
if (pls.length && !playlistId) setPlaylistId(pls[0].id);
if (!pls.length) {
const created = await poFetch('/playlists', {
method: 'POST', body: JSON.stringify({ channel_id: channel.id, name: 'Main' }),
});
setPlaylists([created]); setPlaylistId(created.id);
}
}, [channel.id]);
const loadItems = React.useCallback(async () => {
if (!playlistId) return;
const its = await poFetch('/playlists/' + playlistId + '/items');
setItems(its);
}, [playlistId]);
const loadBreaks = React.useCallback(async () => {
try { setBreaks(await poFetch('/channels/' + channel.id + '/scte')); }
catch (_) { /* table may be empty / migration pending */ }
}, [channel.id]);
React.useEffect(() => { loadPlaylists(); loadBreaks(); }, [channel.id]);
React.useEffect(() => { loadItems(); }, [playlistId]);
// Poll engine status + item staging + SCTE breaks.
React.useEffect(() => {
let t;
const poll = async () => {
try {
const s = await poFetch('/channels/' + channel.id + '/status');
setEngine(s.engine || null);
} catch (_) {}
try { await loadItems(); } catch (_) {}
try { await loadBreaks(); } catch (_) {}
t = setTimeout(poll, 4000);
};
poll();
return () => clearTimeout(t);
}, [channel.id, playlistId]);
const startChannel = async () => {
try {
const updated = await poFetch('/channels/' + channel.id + '/start', { method: 'POST' });
setCh(updated); onChannelChange(updated);
} catch (e) { setActionErr(e.message); }
};
const stopChannel = async () => {
try {
const updated = await poFetch('/channels/' + channel.id + '/stop', { method: 'POST' });
setCh(updated); onChannelChange(updated);
} catch (e) { setActionErr(e.message); }
};
const deleteChannel = async () => {
if (!(await confirm({
title: 'Delete channel?',
message: 'Delete channel "' + ch.name + '"? This cannot be undone.',
confirmLabel: 'Delete', danger: true,
}))) return;
try {
await poFetch('/channels/' + ch.id, { method: 'DELETE' });
onChannelChange({ ...ch, _deleted: true });
} catch (e) { setActionErr(e.message); }
};
const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1;
const elapsed = useElapsed(engine && engine.currentItemStartedAt);
return (
{confirmModal}
{/* ── Top rail: monitor + right panel ── */}
{/* 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}
}
{actionErr && (
setActionErr(null)}>
{actionErr}
)}
{binOpen &&
}
{playlistId && (
)}
setAsRunOpen(false)}
/>
);
}
// ── Top-level page ────────────────────────────────────────────────────────────
function Playout() {
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 load = React.useCallback(async () => {
try {
const list = await poFetch('/channels');
setChannels(list);
if (list.length && !selectedId) setSelectedId(list[0].id);
} catch (e) { setErr(e.message); setChannels([]); }
}, [selectedId]);
React.useEffect(() => { load(); }, []);
const selected = (channels || []).find(c => c.id === selectedId) || null;
const onChannelChange = (updated) => {
if (updated._deleted) {
setChannels(cs => {
const next = (cs || []).filter(c => c.id !== updated.id);
setSelectedId(next.length ? next[0].id : null);
return next;
});
return;
}
setChannels(cs => (cs || []).map(c => c.id === updated.id ? updated : c));
};
return (
{/* ── Page header with channel tabs + clock ── */}
Master Control
{(channels || []).map(c => (
))}
⚠ Playout is in testing — not for production use.
{err &&
{err}
}
{channels === null &&
Loading channels…
}
{channels !== null && channels.length === 0 && (
No playout channels yet.
)}
{selected && (
)}
{showCreate && (
setShowCreate(false)}
onCreated={(ch) => {
setShowCreate(false);
setChannels(cs => [...(cs || []), ch]);
setSelectedId(ch.id);
}}
/>
)}
);
}
window.Playout = Playout;