// 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. // // Talks to /api/v1/playout via window.ZAMPP_API.fetch. Native HTML5 drag-drop, // no extra library. Components are plain globals (esbuild bundle:false). 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); } // ── Output-config sub-form (varies by output type) ─────────────────────────── 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)} />
); } // srt / rtmp 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: assets draggable into the playlist ──────────────────────────── 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 || ''}
))}
); } const MEDIA_STATUS_BADGE = { ready: 'success', staging: 'warn', pending: 'neutral', error: 'error', }; // ── Playlist: ordered, drag-drop reorder, drop-target for bin assets ───────── function Playlist({ channel, playlistId, items, onReload }) { const [dragIndex, setDragIndex] = 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(); // Asset dropped from the bin → append. const assetRaw = e.dataTransfer.getData('application/x-df-asset'); if (assetRaw) { const asset = JSON.parse(assetRaw); await poFetch('/playlists/' + playlistId + '/items', { method: 'POST', body: JSON.stringify({ asset_id: asset.id }), }); onReload(); return; } // Reorder within the playlist. 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); await poFetch('/playlists/' + playlistId + '/reorder', { method: 'PUT', body: JSON.stringify({ order }), }); onReload(); }; // Dropping onto empty area appends. const onContainerDrop = async (e) => { const assetRaw = e.dataTransfer.getData('application/x-df-asset'); if (!assetRaw) return; e.preventDefault(); const asset = JSON.parse(assetRaw); await poFetch('/playlists/' + playlistId + '/items', { method: 'POST', body: JSON.stringify({ asset_id: asset.id }), }); onReload(); }; const removeItem = async (id) => { await poFetch('/items/' + id, { method: 'DELETE' }); onReload(); }; const restage = async (id) => { await poFetch('/items/' + id + '/stage', { method: 'POST' }); onReload(); }; return (
e.preventDefault()} onDrop={onContainerDrop}>
Playlist
{items.length === 0 && (
Drag clips here to build the playlist.
)} {items.map((it, index) => (
onItemDragStart(e, index)} onDragOver={onItemDragOver} onDrop={e => onItemDrop(e, index)}> {index + 1} {it.clip_name || it.asset_id} {it.media_status} {it.media_status === 'error' && ( )}
))}
); } // ── Transport bar ──────────────────────────────────────────────────────────── function Transport({ channel, playlistId, 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 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 (
); } // ── Program monitor ────────────────────────────────────────────────────────── function ProgramMonitor({ channel, engine }) { const onAir = channel.status === 'running'; return (
{onAir ? '● ON AIR' : '○ OFF'} {channel.output_type?.toUpperCase()} · {channel.video_format}
{engine && engine.currentClip ?
{engine.currentClip}
:
{onAir ? 'Idle — no clip playing' : 'Channel stopped'}
}
{engine && (
clip {engine.currentIndex >= 0 ? engine.currentIndex + 1 : '–'} / {engine.playlistLength || 0} {engine.loop ? ' · loop' : ''}
)}
); } // ── Channel detail (monitors + bin + playlist + transport) ─────────────────── 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); 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) { // 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' }), }); 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]); React.useEffect(() => { loadPlaylists(); }, [channel.id]); React.useEffect(() => { loadItems(); }, [playlistId]); // Poll engine status + item staging while live. 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 (_) {} t = setTimeout(poll, 4000); }; poll(); return () => clearTimeout(t); }, [channel.id, playlistId]); const startChannel = async () => { const updated = await poFetch('/channels/' + channel.id + '/start', { method: 'POST' }); setCh(updated); onChannelChange(updated); }; const stopChannel = async () => { const updated = await poFetch('/channels/' + channel.id + '/stop', { method: 'POST' }); setCh(updated); onChannelChange(updated); }; return (

{ch.name}

{ch.output_type?.toUpperCase()} · {ch.video_format} · {ch.status}
{ch.status === 'running' ? : }
{ch.error_message &&
{ch.error_message}
}
loadItems()} /> {playlistId && ( )}
); } // ── 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) => { setChannels(cs => (cs || []).map(c => c.id === updated.id ? updated : c)); }; return (
Playout — Master Control Schedule and play assets to SDI, NDI, SRT or RTMP.
{err &&
{err}
}
{(channels || []).map(c => ( ))}
{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;