feat(monitors): always-on tiles with last-frame freeze, elapsed timer, port badge

- MonitorTile now persists lastAssetId in local state so tiles continue
  showing content after a recording stops (frozen HLS, then thumbnail)
- When recording stops: HlsPreview stays alive briefly while segments are
  hot, then fetches /api/v1/assets/{id}/thumbnail for a frozen still
- Idle tiles that never recorded show FauxFrame with IDLE badge as before
- Per-tile elapsed timer ticks every second using feed.started_at
- capturePort badge (Port N / SDI N) visible on each tile
- Monitors poll interval tightened from 5s -> 3s for faster live_asset_id pickup

🤖 Generated with Claude Code
This commit is contained in:
Claude 2026-06-02 11:15:21 +00:00
parent e97a289722
commit d509876444

View file

@ -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 (
<div className="monitor-tile audio">
@ -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 = <HlsPreview assetId={feed.live_asset_id} recorderId={feed.id} />;
} 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 = (
<div style={{ position: 'relative', width: '100%', height: '100%', background: '#000', overflow: 'hidden' }}>
<img src={frozenThumb} alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', opacity: 0.65, filter: 'grayscale(0.3)' }} />
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.25)' }} />
</div>
);
} else {
// HLS segments may still be hot right after stopping keep player alive briefly
tileContent = <HlsPreview assetId={lastAssetId} recorderId={lastRecorderId} />;
}
} else {
tileContent = <FauxFrame />;
}
return (
<div className="monitor-tile">
{isLive && feed.live_asset_id
? <HlsPreview assetId={feed.live_asset_id} recorderId={feed.id} />
: <FauxFrame />}
{tileContent}
{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 }}>
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6, flexWrap: 'wrap', maxWidth: '70%' }}>
{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>}
{feed.capturePort && (
<span className="badge outline" style={{ fontSize: 9, padding: '1px 5px', opacity: 0.8 }}>
{feed.capturePort}
</span>
)}
</div>
{isLive && (
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}>
@ -1209,7 +1280,7 @@ function MonitorTile({ feed, seed }) {
)}
<div className="monitor-tile-label">
<span className="name">{feed.name}</span>
{feed.elapsed && feed.elapsed !== '·' && <span className="time mono">{feed.elapsed}</span>}
{isLive && <span className="time mono">{displayElapsed}</span>}
</div>
</div>
);