dragonflight/services/web-ui/public/screens-playout.jsx
Zac Gaetano f7cf56ae0d fix(playout): silent-audio staging crash, home tiles, channel delete
- playout-stage: skip loudnorm pass 2 when measured_I=-inf (silent or
  no-audio clip); fall back to plain AAC transcode so staging completes
  instead of erroring out
- screens-home: add Playout tile; replace Premiere panel tile with
  Downloads tile opening a combined modal (Premiere panel releases +
  Dragon-ISO link to forge.wilddragon.net/WildDragonLLC/dragon-iso)
- screens-playout: add Delete channel button (visible only when stopped);
  removes channel from list and selects next on confirm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:03:20 -04:00

578 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmtDuration(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 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);
}
// ── 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 (
<div className="field">
<label className="field-label">DeckLink device index</label>
<input className="field-input" type="number" min="1" value={config.device_index || 1}
onChange={e => set('device_index', parseInt(e.target.value, 10) || 1)} />
</div>
);
}
if (type === 'ndi') {
return (
<div className="field">
<label className="field-label">NDI source name</label>
<input className="field-input" value={config.ndi_name || ''} placeholder="DRAGONFLIGHT CH1"
onChange={e => set('ndi_name', e.target.value)} />
</div>
);
}
// srt / rtmp
return (
<React.Fragment>
<div className="field">
<label className="field-label">{type.toUpperCase()} URL</label>
<input className="field-input mono" value={config.url || ''}
placeholder={type === 'srt' ? 'srt://host:9000' : 'rtmp://host/live'}
onChange={e => set('url', e.target.value)} />
</div>
{type === 'rtmp' && (
<div className="field">
<label className="field-label">Stream key</label>
<input className="field-input mono" value={config.key || ''}
onChange={e => set('key', e.target.value)} />
</div>
)}
{type === 'srt' && (
<div className="field">
<label className="field-label">Latency (ms)</label>
<input className="field-input" type="number" value={config.latency || 200}
onChange={e => set('latency', parseInt(e.target.value, 10) || 200)} />
</div>
)}
</React.Fragment>
);
}
// ── 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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 460 }}>
<div className="modal-header"><h3>New Playout Channel</h3></div>
<div className="modal-body">
<div className="field">
<label className="field-label">Name</label>
<input className="field-input" value={name} autoFocus
onChange={e => setName(e.target.value)} placeholder="Channel 1" />
</div>
<div className="field">
<label className="field-label">Output</label>
<select className="field-input" value={outputType}
onChange={e => { setOutputType(e.target.value); setConfig({}); }}>
{PO_OUTPUTS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<OutputConfigFields type={outputType} config={config} onChange={setConfig} />
<div className="field">
<label className="field-label">Video format</label>
<select className="field-input" value={videoFormat} onChange={e => setVideoFormat(e.target.value)}>
{PO_FORMATS.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div className="field">
<label className="field-label">Project (RBAC scope)</label>
<select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}>
<option value=""> admin only </option>
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{err && <div className="alert error">{err}</div>}
</div>
<div className="modal-footer">
<button className="btn ghost" onClick={onClose}>Cancel</button>
<button className="btn primary" disabled={busy || !name} onClick={submit}>
{busy ? 'Creating…' : 'Create'}
</button>
</div>
</div>
</div>
);
}
// ── 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 (
<div className="panel po-bin">
<div className="po-bin-head">
<span className="po-section-label">Media Bin</span>
<input className="field-input sm" placeholder="Filter…" value={q}
onChange={e => setQ(e.target.value)} style={{ maxWidth: 160 }} />
</div>
<div className="po-bin-list">
{filtered.length === 0 && <div className="muted" style={{ padding: 12 }}>No assets.</div>}
{filtered.map(a => (
<div key={a.id} className="po-bin-item" draggable
onDragStart={e => onDragStart(e, a)} title="Drag into the playlist">
<span className="po-bin-name">{a.name}</span>
<span className="mono muted" style={{ fontSize: 11 }}>{a.duration || ''}</span>
</div>
))}
</div>
</div>
);
}
// ── Staging progress bar ──────────────────────────────────────────────────────
function StagingBar({ status }) {
return (
<div className={'po-staging-bar po-staging-bar--' + (status || 'pending')} aria-hidden="true" />
);
}
// ── Playlist: ordered, drag-drop reorder, drop-target for bin assets ─────────
function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
const [dragIndex, setDragIndex] = React.useState(null);
const [dropErr, setDropErr] = 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();
e.stopPropagation(); // prevent bubble to onContainerDrop
setDropErr(null);
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
if (assetRaw) {
try {
const asset = JSON.parse(assetRaw);
await poFetch('/playlists/' + playlistId + '/items', {
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
});
onReload();
} catch (err) { setDropErr(err.message || 'Failed to add clip'); }
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);
try {
await poFetch('/playlists/' + playlistId + '/reorder', {
method: 'PUT', body: JSON.stringify({ order }),
});
onReload();
} catch (err) { setDropErr(err.message || 'Failed to reorder'); }
};
const onContainerDrop = async (e) => {
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
if (!assetRaw) return;
e.preventDefault();
setDropErr(null);
try {
const asset = JSON.parse(assetRaw);
await poFetch('/playlists/' + playlistId + '/items', {
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
});
onReload();
} catch (err) { setDropErr(err.message || 'Failed to add clip'); }
};
const removeItem = async (id) => {
try { await poFetch('/items/' + id, { method: 'DELETE' }); onReload(); }
catch (err) { setDropErr(err.message || 'Failed to remove'); }
};
const restage = async (id) => {
try { await poFetch('/items/' + id + '/stage', { method: 'POST' }); onReload(); }
catch (err) { setDropErr(err.message || 'Failed to restage'); }
};
const totalSecs = items.reduce((sum, it) => sum + itemEffectiveDuration(it), 0);
return (
<div className="panel po-playlist" onDragOver={e => e.preventDefault()} onDrop={onContainerDrop}>
<div className="po-playlist-head">
<span className="po-section-label">Playlist</span>
{dropErr && <span className="po-drop-err">{dropErr}</span>}
</div>
{items.length === 0 && (
<div className="muted po-playlist-empty">Drag clips here to build the playlist.</div>
)}
{items.map((it, index) => {
const isActive = index === activeIndex;
const dur = itemEffectiveDuration(it);
return (
<div key={it.id}
className={'po-pl-item' + (isActive ? ' po-pl-item--active' : '')}
draggable
onDragStart={e => onItemDragStart(e, index)}
onDragOver={onItemDragOver}
onDrop={e => onItemDrop(e, index)}>
<span className="po-pl-index">
{isActive ? <span className="po-pl-onair"></span> : index + 1}
</span>
<span className="po-pl-name">{it.clip_name || it.asset_id}</span>
<span className="mono po-pl-dur">{fmtDuration(dur)}</span>
<span className={'badge po-pl-badge ' + (it.media_status === 'ready' ? 'success' : it.media_status === 'staging' ? 'warn' : it.media_status === 'error' ? 'error' : 'neutral')}>
{it.media_status}
</span>
{it.media_status === 'error' && (
<button className="btn ghost xs" onClick={() => restage(it.id)}>Retry</button>
)}
<button className="btn ghost xs" onClick={() => removeItem(it.id)}></button>
<StagingBar status={it.media_status} />
</div>
);
})}
{items.length > 0 && (
<div className="po-playlist-footer">
<span className="mono muted">{items.length} clip{items.length !== 1 ? 's' : ''}</span>
<span className="mono po-pl-total">{fmtDuration(totalSecs)} total</span>
</div>
)}
</div>
);
}
// ── Transport bar ────────────────────────────────────────────────────────────
function Transport({ channel, playlistId, items, 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 notReady = items.filter(i => i.media_status !== 'ready').length;
const canPlay = channel.status === 'running' && !busy && !!playlistId && notReady === 0;
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 (
<div className="po-transport">
<button className="btn primary" disabled={!canPlay} onClick={play} title={notReady > 0 ? notReady + ' clip(s) still staging' : ''}>
{notReady > 0 && live ? '⏳ ' + notReady + ' staging' : '▶ Play'}
</button>
<button className="btn ghost" disabled={!live || busy} onClick={pause}> Pause</button>
<button className="btn ghost" disabled={!live || busy} onClick={resume}> Resume</button>
<button className="btn ghost" disabled={!live || busy} onClick={skip}> Skip</button>
<button className="btn danger ghost" disabled={!live || busy} onClick={stopPb}> Stop</button>
</div>
);
}
// ── Program monitor ──────────────────────────────────────────────────────────
function ProgramMonitor({ channel, engine }) {
const videoRef = React.useRef(null);
const hlsRef = React.useRef(null);
const onAir = channel.status === 'running';
const previewUrl = `/media/live/${channel.id}/index.m3u8`;
React.useEffect(() => {
const vid = videoRef.current;
if (!vid) return;
// Tear down any previous HLS instance before re-evaluating.
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
if (!onAir) { vid.src = ''; return; }
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls({ liveSyncDurationCount: 3, liveMaxLatencyDurationCount: 6 });
hlsRef.current = hls;
hls.loadSource(previewUrl);
hls.attachMedia(vid);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
} else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS (Safari).
vid.src = previewUrl;
vid.play().catch(() => {});
}
return () => {
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
};
}, [onAir, channel.id]);
return (
<div className="po-monitor">
<div className="po-monitor-head">
<span className={'po-onair ' + (onAir ? 'live' : '')}>{onAir ? '● ON AIR' : '○ OFF'}</span>
<span className="mono muted">{channel.output_type?.toUpperCase()} · {channel.video_format}</span>
</div>
<div className="po-monitor-screen">
<video ref={videoRef} className="po-monitor-video" muted playsInline autoPlay />
{!onAir && (
<div className="po-monitor-overlay muted">Channel stopped</div>
)}
</div>
{engine && (
<div className="po-monitor-foot mono muted">
{engine.currentClip && <span className="po-monitor-clip-name">{engine.currentClip}</span>}
<span>clip {engine.currentIndex >= 0 ? engine.currentIndex + 1 : ''} / {engine.playlistLength || 0}</span>
{engine.loop && <span> · loop</span>}
</div>
)}
</div>
);
}
// ── 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);
};
const deleteChannel = async () => {
if (!window.confirm('Delete channel "' + ch.name + '"? This cannot be undone.')) return;
try {
await poFetch('/channels/' + ch.id, { method: 'DELETE' });
onChannelChange({ ...ch, _deleted: true });
} catch (e) { alert(e.message); }
};
// engine.currentIndex maps directly to the sorted item position.
const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1;
return (
<div className="po-detail">
<div className="po-detail-head">
<div>
<h3 style={{ margin: 0 }}>{ch.name}</h3>
<span className="mono muted">{ch.output_type?.toUpperCase()} · {ch.video_format} · {ch.status}</span>
</div>
<div className="po-detail-actions">
{ch.status === 'running'
? <button className="btn danger" onClick={stopChannel}>Stop channel</button>
: <button className="btn primary" onClick={startChannel}>Start channel</button>}
{ch.status !== 'running' && (
<button className="btn ghost danger sm" onClick={deleteChannel} title="Delete this channel">Delete</button>
)}
</div>
</div>
{ch.error_message && <div className="alert error">{ch.error_message}</div>}
<div className="po-grid">
<ProgramMonitor channel={ch} engine={engine} />
<MediaBin projectId={ch.project_id} />
</div>
<Transport channel={ch} playlistId={playlistId} items={items} onStatus={() => loadItems()} />
{playlistId && (
<Playlist
channel={ch}
playlistId={playlistId}
items={items}
activeIndex={activeIndex}
onReload={loadItems}
/>
)}
</div>
);
}
// ── 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) => {
if (updated._deleted) {
setChannels(cs => {
const next = (cs || []).filter(c => c.id !== updated.id);
setSelectedId(next.length ? next[0].id : null);
return next;
});
return;
}
setChannels(cs => (cs || []).map(c => c.id === updated.id ? updated : c));
};
return (
<div className="page">
<div className="page-header">
<span className="title">Playout Master Control</span>
<span className="subtitle">Schedule and play assets to SDI, NDI, SRT or RTMP.</span>
</div>
<div className="page-body po-page">
{err && <div className="alert error">{err}</div>}
<div className="po-channels-bar">
{(channels || []).map(c => (
<button key={c.id}
className={'po-chan-tab ' + (c.id === selectedId ? 'active' : '')}
onClick={() => setSelectedId(c.id)}>
<span className={'po-chan-dot ' + (c.status === 'running' ? 'live' : '')} />
{c.name}
</button>
))}
<button className="btn ghost sm" onClick={() => setShowCreate(true)}>+ Channel</button>
</div>
{channels === null && <div className="muted">Loading channels</div>}
{channels !== null && channels.length === 0 && (
<div className="po-empty">
<p className="muted">No playout channels yet.</p>
<button className="btn primary" onClick={() => setShowCreate(true)}>Create your first channel</button>
</div>
)}
{selected && <ChannelDetail key={selected.id} channel={selected} onChannelChange={onChannelChange} />}
</div>
{showCreate && (
<ChannelCreate
onClose={() => setShowCreate(false)}
onCreated={(ch) => { setShowCreate(false); setChannels(cs => [...(cs || []), ch]); setSelectedId(ch.id); }}
/>
)}
</div>
);
}
window.Playout = Playout;