fix: SDI crash, monitors polling, home RAM fields, editor IN DEV splash, timecode, create recorder API: screens-ingest.jsx
This commit is contained in:
parent
48ee66e744
commit
24a1d57165
1 changed files with 114 additions and 48 deletions
|
|
@ -84,30 +84,45 @@ function Upload({ navigate }) {
|
|||
}
|
||||
|
||||
/* ===== Recorders ===== */
|
||||
function _normRecorder(r) {
|
||||
let elapsed = '—';
|
||||
if (r.status === 'recording' && r.started_at) {
|
||||
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
|
||||
elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
|
||||
String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' +
|
||||
String(s % 60).padStart(2, '0');
|
||||
}
|
||||
const cfg = r.source_config || {};
|
||||
return {
|
||||
...r,
|
||||
source: r.source_type || '—',
|
||||
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '—',
|
||||
codec: r.recording_codec || '—',
|
||||
res: r.recording_resolution || '—',
|
||||
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
|
||||
elapsed,
|
||||
bitrate: '—',
|
||||
health: 100,
|
||||
audio: false,
|
||||
};
|
||||
}
|
||||
|
||||
function Recorders({ navigate, onNew }) {
|
||||
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
|
||||
|
||||
// Poll every 10s for recorder status changes
|
||||
const refresh = React.useCallback(() => {
|
||||
window.ZAMPP_API.fetch('/recorders')
|
||||
.then(raw => {
|
||||
const norm = (raw || []).map(_normRecorder);
|
||||
window.ZAMPP_DATA.RECORDERS = norm;
|
||||
setRecorders(norm);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const refresh = () => {
|
||||
window.ZAMPP_API.fetch('/recorders')
|
||||
.then(raw => {
|
||||
const norm = (raw || []).map(r => {
|
||||
let elapsed = '—';
|
||||
if (r.status === 'recording' && r.started_at) {
|
||||
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
|
||||
elapsed = String(Math.floor(s/3600)).padStart(2,'0')+':'+String(Math.floor((s%3600)/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');
|
||||
}
|
||||
const cfg = r.source_config || {};
|
||||
return { ...r, source: r.source_type||'—', url: cfg.url||cfg.address||r.source_type||'—', codec: r.recording_codec||'—', res: r.recording_resolution||'—', node: r.node_id||'primary', elapsed, bitrate:'—', health:100, audio:false };
|
||||
});
|
||||
window.ZAMPP_DATA.RECORDERS = norm;
|
||||
setRecorders(norm);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
const i = setInterval(refresh, 10000);
|
||||
return () => clearInterval(i);
|
||||
const id = setInterval(refresh, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const liveCount = recorders.filter(r => r.status === 'recording').length;
|
||||
|
|
@ -125,6 +140,7 @@ function Recorders({ navigate, onNew }) {
|
|||
<span>{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
|
||||
<button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
|
|
@ -135,7 +151,7 @@ function Recorders({ navigate, onNew }) {
|
|||
</div>
|
||||
) : (
|
||||
<div className="recorders-list">
|
||||
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={() => { window.ZAMPP_API.fetch('/recorders').then(raw => setRecorders((raw||[]).map(x => { const cfg=x.source_config||{}; return {...x,source:x.source_type,url:cfg.url||cfg.address||x.source_type,codec:x.recording_codec,res:x.recording_resolution,node:x.node_id,elapsed:'—',bitrate:'—',health:100,audio:false}; }))).catch(()=>{}); }} />)}
|
||||
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -143,13 +159,23 @@ function Recorders({ navigate, onNew }) {
|
|||
);
|
||||
}
|
||||
|
||||
function RecorderRow({ recorder, onRefresh }) {
|
||||
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||
const [recorder, setRecorder] = React.useState(initialRecorder);
|
||||
const [pending, setPending] = React.useState(false);
|
||||
const [err, setErr] = React.useState(null);
|
||||
const isRec = recorder.status === 'recording';
|
||||
|
||||
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
|
||||
|
||||
const toggle = () => {
|
||||
if (pending) return;
|
||||
const action = isRec ? 'stop' : 'start';
|
||||
setPending(true);
|
||||
setErr(null);
|
||||
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
|
||||
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST' })
|
||||
.then(onRefresh).catch(() => {});
|
||||
.then(() => { setPending(false); onRefresh(); })
|
||||
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -172,6 +198,7 @@ function RecorderRow({ recorder, onRefresh }) {
|
|||
<span>{recorder.codec}</span><span>·</span>
|
||||
<span>{recorder.res}</span>
|
||||
</div>
|
||||
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
|
||||
</div>
|
||||
<div className="recorder-stats">
|
||||
<div className="recorder-stat">
|
||||
|
|
@ -185,8 +212,12 @@ function RecorderRow({ recorder, onRefresh }) {
|
|||
</div>
|
||||
<div className="recorder-actions">
|
||||
{isRec
|
||||
? <button className="btn danger sm" onClick={toggle}><span className="rec-dot" />Stop</button>
|
||||
: <button className="btn subtle sm" onClick={toggle}><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</button>}
|
||||
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
|
||||
{pending ? '…' : <><span className="rec-dot" />Stop</>}
|
||||
</button>
|
||||
: <button className="btn subtle sm" onClick={toggle} disabled={pending}>
|
||||
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
|
||||
</button>}
|
||||
<button className="icon-btn"><Icon name="more" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -214,11 +245,13 @@ function Capture({ navigate }) {
|
|||
const [devices, setDevices] = React.useState([]);
|
||||
const [activeIdx, setActiveIdx] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadDevices = () => {
|
||||
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
|
||||
.then(devs => setDevices(devs || []))
|
||||
.then(devs => setDevices(Array.isArray(devs) ? devs : []))
|
||||
.catch(() => setDevices([]));
|
||||
}, []);
|
||||
};
|
||||
|
||||
React.useEffect(() => { loadDevices(); }, []);
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
|
|
@ -227,8 +260,7 @@ function Capture({ navigate }) {
|
|||
<h1>Capture</h1>
|
||||
<span className="subtitle">DeckLink SDI ingest</span>
|
||||
<div className="spacer" />
|
||||
<button className="btn ghost sm" onClick={() => window.ZAMPP_API.fetch('/cluster/devices/blackmagic').then(d => setDevices(d||[])).catch(()=>{})}
|
||||
><Icon name="refresh" />Refresh</button>
|
||||
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
|
|
@ -247,7 +279,7 @@ function Capture({ navigate }) {
|
|||
<h1>Capture</h1>
|
||||
<span className="subtitle">DeckLink SDI ingest — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster</span>
|
||||
<div className="spacer" />
|
||||
<button className="btn ghost sm" onClick={() => window.ZAMPP_API.fetch('/cluster/devices/blackmagic').then(d => setDevices(d||[])).catch(()=>{})}><Icon name="refresh" />Refresh</button>
|
||||
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
|
||||
|
|
@ -274,14 +306,25 @@ function Capture({ navigate }) {
|
|||
|
||||
/* ===== Monitors ===== */
|
||||
function Monitors({ navigate }) {
|
||||
const { RECORDERS } = window.ZAMPP_DATA;
|
||||
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
|
||||
const [grid, setGrid] = React.useState(4);
|
||||
|
||||
const videoFeeds = RECORDERS.filter(r => !r.audio);
|
||||
const audioFeeds = [
|
||||
{ id: '__audio1', name: 'FOH Mix', kind: 'audio' },
|
||||
{ id: '__audio2', name: 'PGM Bus', kind: 'audio' },
|
||||
];
|
||||
React.useEffect(() => {
|
||||
const refresh = () => {
|
||||
window.ZAMPP_API.fetch('/recorders')
|
||||
.then(raw => {
|
||||
const norm = (raw || []).map(_normRecorder);
|
||||
window.ZAMPP_DATA.RECORDERS = norm;
|
||||
setRecorders(norm);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
const id = setInterval(refresh, 5000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const videoFeeds = recorders.filter(r => !r.audio);
|
||||
const audioFeeds = recorders.filter(r => r.audio).map(r => ({ ...r, kind: 'audio' }));
|
||||
const allFeeds = [
|
||||
...videoFeeds.map(r => ({ ...r, kind: 'video' })),
|
||||
...audioFeeds,
|
||||
|
|
@ -301,18 +344,30 @@ function Monitors({ navigate }) {
|
|||
</div>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div className="monitors-grid" style={{ gridTemplateColumns: 'repeat(' + grid + ', 1fr)' }}>
|
||||
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
|
||||
{feeds.length === 0 && (
|
||||
<div style={{ gridColumn: '1/-1', padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</div>
|
||||
)}
|
||||
</div>
|
||||
{feeds.length === 0 ? (
|
||||
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</div>
|
||||
) : (
|
||||
<div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
|
||||
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorTile({ feed, seed }) {
|
||||
const [levels, setLevels] = React.useState([0.65, 0.78]);
|
||||
const isLive = feed.status === 'recording';
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isLive) return;
|
||||
const id = setInterval(() => {
|
||||
setLevels([0.3 + Math.random() * 0.55, 0.3 + Math.random() * 0.55]);
|
||||
}, 180);
|
||||
return () => clearInterval(id);
|
||||
}, [isLive]);
|
||||
|
||||
if (feed.kind === 'audio') {
|
||||
return (
|
||||
<div className="monitor-tile audio">
|
||||
|
|
@ -320,8 +375,8 @@ function MonitorTile({ feed, seed }) {
|
|||
<Waveform seed={seed * 7} color="var(--accent)" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, padding: '0 16px 16px', justifyContent: 'center' }}>
|
||||
<AudioMeter level={0.65} vertical />
|
||||
<AudioMeter level={0.78} vertical />
|
||||
<AudioMeter level={levels[0]} vertical />
|
||||
<AudioMeter level={levels[1]} vertical />
|
||||
</div>
|
||||
<div className="monitor-tile-label">
|
||||
<span className="badge live">LIVE</span>
|
||||
|
|
@ -330,14 +385,25 @@ function MonitorTile({ feed, seed }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="monitor-tile">
|
||||
<FauxFrame />
|
||||
{isLive && (
|
||||
<div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />
|
||||
)}
|
||||
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
|
||||
{feed.status === 'recording' && <span className="badge live">REC</span>}
|
||||
{feed.status === 'stopped' && <span className="badge neutral">IDLE</span>}
|
||||
{feed.status === 'error' && <span className="badge danger">ERR</span>}
|
||||
{isLive && <span className="badge live">REC</span>}
|
||||
{feed.status === 'stopped' && <span className="badge neutral">IDLE</span>}
|
||||
{feed.status === 'idle' && <span className="badge neutral">IDLE</span>}
|
||||
{feed.status === 'error' && <span className="badge danger">ERR</span>}
|
||||
</div>
|
||||
{isLive && (
|
||||
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}>
|
||||
<AudioMeter level={levels[0]} vertical />
|
||||
<AudioMeter level={levels[1]} vertical />
|
||||
</div>
|
||||
)}
|
||||
<div className="monitor-tile-label">
|
||||
<span className="name">{feed.name}</span>
|
||||
{feed.elapsed && feed.elapsed !== '—' && <span className="time mono">{feed.elapsed}</span>}
|
||||
|
|
|
|||
Loading…
Reference in a new issue