diff --git a/services/web-ui/public/app.jsx b/services/web-ui/public/app.jsx
index c595577..1bcf73b 100644
--- a/services/web-ui/public/app.jsx
+++ b/services/web-ui/public/app.jsx
@@ -67,7 +67,7 @@ function App() {
schedule: ['Ingest', 'Schedule'],
youtube: ['Ingest', 'YouTube'],
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
- jobs: ['Jobs'], editor: ['Editor'],
+ jobs: ['Jobs'], editor: ['Editor'], playout: ['Operations', 'Playout'],
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
settings: ['Admin', 'Settings'],
@@ -120,6 +120,7 @@ function App() {
case 'capture': content = ; break;
case 'monitors': content = ; break;
case 'jobs': content = ; break;
+ case 'playout': content = ; break;
case 'users': content = ; break;
case 'tokens': content = ; break;
case 'billing': content = ; break;
diff --git a/services/web-ui/public/index.html b/services/web-ui/public/index.html
index 51c5869..2f29532 100644
--- a/services/web-ui/public/index.html
+++ b/services/web-ui/public/index.html
@@ -21,6 +21,7 @@
+
@@ -47,6 +48,7 @@
+
diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx
new file mode 100644
index 0000000..49f9d23
--- /dev/null
+++ b/services/web-ui/public/screens-playout.jsx
@@ -0,0 +1,460 @@
+// 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;
diff --git a/services/web-ui/public/shell.jsx b/services/web-ui/public/shell.jsx
index f92458c..b6aad71 100644
--- a/services/web-ui/public/shell.jsx
+++ b/services/web-ui/public/shell.jsx
@@ -28,6 +28,7 @@ const NAV_SECTIONS = [
label: "Operations",
items: [
{ id: "capture", label: "Capture", icon: "capture" },
+ { id: "playout", label: "Playout", icon: "monitor" },
{ id: "jobs", label: "Jobs", icon: "jobs" },
],
},
diff --git a/services/web-ui/public/styles-playout.css b/services/web-ui/public/styles-playout.css
new file mode 100644
index 0000000..c4ce6d3
--- /dev/null
+++ b/services/web-ui/public/styles-playout.css
@@ -0,0 +1,104 @@
+/* Playout / Master Control (MCR) page styles. */
+
+.po-page { display: flex; flex-direction: column; gap: 14px; }
+
+/* Channel tab bar */
+.po-channels-bar {
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
+ padding-bottom: 10px; border-bottom: 1px solid var(--border);
+}
+.po-chan-tab {
+ display: inline-flex; align-items: center; gap: 7px;
+ padding: 6px 12px; border-radius: 8px;
+ background: var(--bg-2); border: 1px solid var(--border);
+ color: var(--text-2); font-size: 13px; cursor: pointer;
+}
+.po-chan-tab:hover { background: var(--bg-3); color: var(--text-1); }
+.po-chan-tab.active { background: var(--accent-soft); color: var(--accent-text); border-color: var(--accent-soft-2); }
+.po-chan-dot {
+ width: 8px; height: 8px; border-radius: 50%;
+ background: var(--text-3);
+}
+.po-chan-dot.live { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
+
+.po-empty { text-align: center; padding: 48px 0; display: flex; flex-direction: column; gap: 12px; align-items: center; }
+
+/* Channel detail */
+.po-detail { display: flex; flex-direction: column; gap: 14px; }
+.po-detail-head { display: flex; justify-content: space-between; align-items: flex-start; }
+.po-detail-actions { display: flex; gap: 8px; }
+
+.po-grid {
+ display: grid; grid-template-columns: 1.4fr 1fr; gap: 14px;
+}
+@media (max-width: 900px) { .po-grid { grid-template-columns: 1fr; } }
+
+.po-section-label {
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
+ color: var(--text-3); font-weight: 600;
+}
+
+/* Program monitor */
+.po-monitor {
+ background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px;
+ display: flex; flex-direction: column; overflow: hidden;
+}
+.po-monitor-head {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 10px 12px; border-bottom: 1px solid var(--border);
+}
+.po-onair { font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: 0.04em; }
+.po-onair.live { color: var(--danger); }
+.po-monitor-screen {
+ flex: 1; min-height: 220px; background: #000;
+ display: flex; align-items: center; justify-content: center;
+ color: var(--text-2);
+}
+.po-monitor-clip { font-family: var(--font-mono); font-size: 14px; color: var(--text-1); }
+.po-monitor-foot { padding: 8px 12px; border-top: 1px solid var(--border); font-size: 11px; }
+
+/* Media bin */
+.po-bin {
+ display: flex; flex-direction: column; min-height: 260px; max-height: 360px;
+ border-radius: 12px; overflow: hidden;
+}
+.po-bin-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
+.po-bin-list { overflow-y: auto; flex: 1; }
+.po-bin-item {
+ display: flex; justify-content: space-between; align-items: center; gap: 8px;
+ padding: 8px 12px; border-bottom: 1px solid var(--border);
+ cursor: grab; user-select: none;
+}
+.po-bin-item:hover { background: var(--bg-3); }
+.po-bin-item:active { cursor: grabbing; }
+.po-bin-name { font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* Transport */
+.po-transport {
+ display: flex; gap: 8px; flex-wrap: wrap;
+ padding: 12px; background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px;
+}
+
+/* Playlist */
+.po-playlist {
+ border-radius: 12px; overflow: hidden;
+ min-height: 120px;
+}
+.po-playlist-empty { padding: 28px 12px; text-align: center; }
+.po-pl-item {
+ display: flex; align-items: center; gap: 10px;
+ padding: 9px 12px; border-bottom: 1px solid var(--border);
+ cursor: grab; user-select: none;
+}
+.po-pl-item:hover { background: var(--bg-3); }
+.po-pl-item:active { cursor: grabbing; }
+.po-pl-index {
+ width: 22px; text-align: center; font-family: var(--font-mono);
+ font-size: 12px; color: var(--text-3);
+}
+.po-pl-name { flex: 1; font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* Small button variants reused */
+.btn.xs { padding: 2px 8px; font-size: 11px; }
+.btn.sm { padding: 5px 10px; font-size: 12px; }
+.field-input.sm { padding: 5px 8px; font-size: 12px; }