diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index ce29443..637be9a 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -1056,7 +1056,7 @@ function Monitors({ navigate }) { .catch(() => setChannels([])); }; refresh(); - const id = setInterval(refresh, 5000); + const id = setInterval(refresh, 3000); return () => clearInterval(id); }, []); @@ -1163,6 +1163,49 @@ function MonitorTile({ feed, seed }) { const [levels, setLevels] = React.useState([0.65, 0.78]); const isLive = feed.status === 'recording'; + // Persist the last known asset ID so we can show frozen HLS/thumbnail after + // a recording stops. Cleared only when a new recording starts on this recorder. + const [lastAssetId, setLastAssetId] = React.useState(feed.live_asset_id || null); + const [lastRecorderId, setLastRecorderId] = React.useState(feed.id); + const [frozenThumb, setFrozenThumb] = React.useState(null); + + React.useEffect(() => { + if (feed.live_asset_id) { + setLastAssetId(feed.live_asset_id); + setLastRecorderId(feed.id); + setFrozenThumb(null); // reset frozen thumb — live HLS takes over + } + }, [feed.live_asset_id]); + + // When recording stops, try to fetch a thumbnail for the last asset so we can + // show a frozen frame instead of blank noise. + React.useEffect(() => { + if (isLive || !lastAssetId) return; + if (frozenThumb) return; // already fetched + let cancelled = false; + window.ZAMPP_API.fetch('/assets/' + lastAssetId + '/thumbnail') + .then(data => { + if (!cancelled && data && data.url) setFrozenThumb(data.url); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [isLive, lastAssetId]); + + // Tick elapsed while recording. Seed from feed.started_at (populated by Docker + // inspect on the mam-api side). Falls back to 0 if not yet available. + const [elapsedSecs, setElapsedSecs] = React.useState(0); + React.useEffect(() => { + if (!isLive) { setElapsedSecs(0); return; } + const tick = () => { + if (feed.started_at) { + setElapsedSecs(Math.max(0, Math.floor((Date.now() - new Date(feed.started_at).getTime()) / 1000))); + } + }; + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [isLive, feed.started_at]); + React.useEffect(() => { if (!isLive) return; const id = setInterval(() => { @@ -1171,6 +1214,8 @@ function MonitorTile({ feed, seed }) { return () => clearInterval(id); }, [isLive]); + const displayElapsed = _fmtElapsed(elapsedSecs * 1000); + if (feed.kind === 'audio') { return (
@@ -1189,17 +1234,43 @@ function MonitorTile({ feed, seed }) { ); } + // Content priority: live HLS > frozen HLS briefly after stop > static thumbnail > noise + let tileContent; + if (isLive && feed.live_asset_id) { + tileContent = ; + } else if (!isLive && lastAssetId) { + // After stopping: HlsPreview will naturally degrade as segments expire and + // show its own "connecting…" overlay. Once frozenThumb is loaded it replaces it. + if (frozenThumb) { + tileContent = ( +
+ +
+
+ ); + } else { + // HLS segments may still be hot right after stopping — keep player alive briefly + tileContent = ; + } + } else { + tileContent = ; + } + return (
- {isLive && feed.live_asset_id - ? - : } + {tileContent} {isLive &&
} -
+
{isLive && REC} {feed.status === 'stopped' && IDLE} {feed.status === 'idle' && IDLE} {feed.status === 'error' && ERR} + {feed.capturePort && ( + + {feed.capturePort} + + )}
{isLive && (
@@ -1209,7 +1280,7 @@ function MonitorTile({ feed, seed }) { )}
{feed.name} - {feed.elapsed && feed.elapsed !== '·' && {feed.elapsed}} + {isLive && {displayElapsed}}
);