feat(web-ui): MCR page — channels, playlist, transport, preview
screens-playout.jsx + styles-playout.css: program monitor (HLS preview from the sidecar), media bin, drag-drop playlist editor, transport controls. Plain HTML5 drag-drop, no extra library. Talks to /api/v1/playout via ZAMPP_API.fetch. Wired into the shell: "Playout" under Operations, breadcrumb mapping, route case in app.jsx, stylesheet + dist/screens-playout.js script in index.html. Format dropdown defaults to 1080p5994 (matches the new channel default).
This commit is contained in:
parent
5538683d78
commit
793011b78b
5 changed files with 569 additions and 1 deletions
|
|
@ -67,7 +67,7 @@ function App() {
|
||||||
schedule: ['Ingest', 'Schedule'],
|
schedule: ['Ingest', 'Schedule'],
|
||||||
youtube: ['Ingest', 'YouTube'],
|
youtube: ['Ingest', 'YouTube'],
|
||||||
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
|
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
|
||||||
jobs: ['Jobs'], editor: ['Editor'],
|
jobs: ['Jobs'], editor: ['Editor'], playout: ['Operations', 'Playout'],
|
||||||
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
|
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
|
||||||
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
|
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
|
||||||
settings: ['Admin', 'Settings'],
|
settings: ['Admin', 'Settings'],
|
||||||
|
|
@ -120,6 +120,7 @@ function App() {
|
||||||
case 'capture': content = <Capture navigate={navigate} />; break;
|
case 'capture': content = <Capture navigate={navigate} />; break;
|
||||||
case 'monitors': content = <Monitors navigate={navigate} />; break;
|
case 'monitors': content = <Monitors navigate={navigate} />; break;
|
||||||
case 'jobs': content = <Jobs navigate={navigate} />; break;
|
case 'jobs': content = <Jobs navigate={navigate} />; break;
|
||||||
|
case 'playout': content = <Playout navigate={navigate} />; break;
|
||||||
case 'users': content = <Users />; break;
|
case 'users': content = <Users />; break;
|
||||||
case 'tokens': content = <Tokens />; break;
|
case 'tokens': content = <Tokens />; break;
|
||||||
case 'billing': content = <TokensParody />; break;
|
case 'billing': content = <TokensParody />; break;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
<link rel="stylesheet" href="styles-rest.css" />
|
<link rel="stylesheet" href="styles-rest.css" />
|
||||||
<link rel="stylesheet" href="styles-modal.css" />
|
<link rel="stylesheet" href="styles-modal.css" />
|
||||||
<link rel="stylesheet" href="styles-fixes.css" />
|
<link rel="stylesheet" href="styles-fixes.css" />
|
||||||
|
<link rel="stylesheet" href="styles-playout.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
@ -47,6 +48,7 @@
|
||||||
<script src="js/bmd-card.js"></script>
|
<script src="js/bmd-card.js"></script>
|
||||||
<script src="dist/screens-editor.js"></script>
|
<script src="dist/screens-editor.js"></script>
|
||||||
<script src="dist/screens-admin.js"></script>
|
<script src="dist/screens-admin.js"></script>
|
||||||
|
<script src="dist/screens-playout.js"></script>
|
||||||
<script src="dist/modal-new-recorder.js"></script>
|
<script src="dist/modal-new-recorder.js"></script>
|
||||||
<script src="dist/app.js"></script>
|
<script src="dist/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
460
services/web-ui/public/screens-playout.jsx
Normal file
460
services/web-ui/public/screens-playout.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="panel po-playlist" onDragOver={e => e.preventDefault()} onDrop={onContainerDrop}>
|
||||||
|
<div className="po-section-label" style={{ padding: '8px 12px' }}>Playlist</div>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="muted po-playlist-empty">Drag clips here to build the playlist.</div>
|
||||||
|
)}
|
||||||
|
{items.map((it, index) => (
|
||||||
|
<div key={it.id} className="po-pl-item" draggable
|
||||||
|
onDragStart={e => onItemDragStart(e, index)}
|
||||||
|
onDragOver={onItemDragOver}
|
||||||
|
onDrop={e => onItemDrop(e, index)}>
|
||||||
|
<span className="po-pl-index">{index + 1}</span>
|
||||||
|
<span className="po-pl-name">{it.clip_name || it.asset_id}</span>
|
||||||
|
<span className={'badge ' + (MEDIA_STATUS_BADGE[it.media_status] || '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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="po-transport">
|
||||||
|
<button className="btn primary" disabled={!live || busy || !playlistId} onClick={play}>▶ 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 onAir = channel.status === 'running';
|
||||||
|
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">
|
||||||
|
{engine && engine.currentClip
|
||||||
|
? <div className="po-monitor-clip">{engine.currentClip}</div>
|
||||||
|
: <div className="muted">{onAir ? 'Idle — no clip playing' : 'Channel stopped'}</div>}
|
||||||
|
</div>
|
||||||
|
{engine && (
|
||||||
|
<div className="po-monitor-foot mono muted">
|
||||||
|
clip {engine.currentIndex >= 0 ? engine.currentIndex + 1 : '–'} / {engine.playlistLength || 0}
|
||||||
|
{engine.loop ? ' · loop' : ''}
|
||||||
|
</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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>}
|
||||||
|
</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} onStatus={() => loadItems()} />
|
||||||
|
|
||||||
|
{playlistId && (
|
||||||
|
<Playlist channel={ch} playlistId={playlistId} items={items} 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) => {
|
||||||
|
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;
|
||||||
|
|
@ -28,6 +28,7 @@ const NAV_SECTIONS = [
|
||||||
label: "Operations",
|
label: "Operations",
|
||||||
items: [
|
items: [
|
||||||
{ id: "capture", label: "Capture", icon: "capture" },
|
{ id: "capture", label: "Capture", icon: "capture" },
|
||||||
|
{ id: "playout", label: "Playout", icon: "monitor" },
|
||||||
{ id: "jobs", label: "Jobs", icon: "jobs" },
|
{ id: "jobs", label: "Jobs", icon: "jobs" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
104
services/web-ui/public/styles-playout.css
Normal file
104
services/web-ui/public/styles-playout.css
Normal file
|
|
@ -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; }
|
||||||
Loading…
Reference in a new issue