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