// 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 (