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:
parent
e97a289722
commit
d509876444
1 changed files with 77 additions and 6 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue