diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx
index e62afea..44ee642 100644
--- a/services/web-ui/public/screens-ingest.jsx
+++ b/services/web-ui/public/screens-ingest.jsx
@@ -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 }) {
{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}
)}
+
@@ -135,7 +151,7 @@ function Recorders({ navigate, onNew }) {
) : (
- {recorders.map(r => { 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 => )}
)}
@@ -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 }) {
{recorder.codec}·
{recorder.res}
+ {err && {err}
}
@@ -185,8 +212,12 @@ function RecorderRow({ recorder, onRefresh }) {
{isRec
- ?
- : }
+ ?
+ : }
@@ -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 }) {
Capture
DeckLink SDI ingest
-
+
@@ -247,7 +279,7 @@ function Capture({ navigate }) {
Capture
DeckLink SDI ingest — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster
-
+
@@ -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 }) {
-
- {feeds.map((f, i) =>
)}
- {feeds.length === 0 && (
-
No active feeds. Start a recorder to see live video here.
- )}
-
+ {feeds.length === 0 ? (
+
No active feeds. Start a recorder to see live video here.
+ ) : (
+
+ {feeds.map((f, i) => )}
+
+ )}
);
}
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 (
@@ -320,8 +375,8 @@ function MonitorTile({ feed, seed }) {
LIVE
@@ -330,14 +385,25 @@ function MonitorTile({ feed, seed }) {
);
}
+
return (
+ {isLive && (
+
+ )}
- {feed.status === 'recording' && REC}
- {feed.status === 'stopped' && IDLE}
- {feed.status === 'error' && ERR}
+ {isLive && REC}
+ {feed.status === 'stopped' && IDLE}
+ {feed.status === 'idle' && IDLE}
+ {feed.status === 'error' && ERR}
+ {isLive && (
+
+ )}
{feed.name}
{feed.elapsed && feed.elapsed !== '—' && {feed.elapsed}}