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