From 57116dde420a5de638257de76f39d394c8370260 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Mon, 18 May 2026 09:40:42 -0400 Subject: [PATCH] feat(recorders): stable elapsed timer + live HLS preview on the card; optimistic signal default --- services/mam-api/src/routes/recorders.js | 30 +++++++++++++++++-- services/web-ui/public/recorders.html | 38 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index d0b45b0..a729f26 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -77,7 +77,29 @@ router.get('/', async (req, res, next) => { const result = await pool.query( 'SELECT * FROM recorders ORDER BY created_at DESC' ); - res.json(result.rows); + // Annotate recording rows with the container's actual StartedAt + the + // pre-created live asset id so the UI can keep a stable elapsed timer + // and embed an HLS preview. + const rows = await Promise.all(result.rows.map(async (r) => { + if (r.status === 'recording' && r.container_id) { + try { + const insp = await dockerApi('GET', `/containers/${r.container_id}/json`); + if (insp.status === 200 && insp.data && insp.data.State) { + r.started_at = insp.data.State.StartedAt; + } + } catch (_) { /* leave started_at undefined */ } + try { + const live = await pool.query( + `SELECT id FROM assets WHERE project_id = $1 AND display_name = $2 AND status = 'live' ORDER BY created_at DESC LIMIT 1`, + [r.project_id, r.current_session_id] + ); + if (live.rows.length > 0) r.live_asset_id = live.rows[0].id; + } catch (_) { /* skip */ } + } + return r; + })); + res.json(rows); + } catch (err) { next(err); } @@ -422,13 +444,14 @@ router.get('/:id/status', async (req, res, next) => { const duration = Math.floor((now - startedAt) / 1000); // Try to fetch live signal from the recorder's capture sidecar via network alias - let signal = container.State.Running ? 'connecting' : 'stopped'; + let signal = container.State.Running ? 'receiving' : 'stopped'; + let signalKnown = false; let live = null; try { const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) }); if (captureRes.ok) { live = await captureRes.json(); - if (live && live.signal) signal = live.signal; + if (live && live.signal) { signal = live.signal; signalKnown = true; } } } catch (_) { // Container may not be ready yet, or alias hasn't propagated. Leave signal as default. @@ -439,6 +462,7 @@ router.get('/:id/status', async (req, res, next) => { duration, containerId: recorder.container_id, signal, + signalKnown, framesReceived: live ? live.framesReceived : null, currentFps: live ? live.currentFps : null, lastFrameAt: live ? live.lastFrameAt : null, diff --git a/services/web-ui/public/recorders.html b/services/web-ui/public/recorders.html index e154201..9e65b45 100644 --- a/services/web-ui/public/recorders.html +++ b/services/web-ui/public/recorders.html @@ -28,6 +28,11 @@ transition: border-color var(--t-fast); } + .recorder-preview{position:relative;width:100%;aspect-ratio:16/9;background:#000;border-radius:var(--r-sm);overflow:hidden;margin:8px 0;border:1px solid var(--border)} + .recorder-preview video{width:100%;height:100%;object-fit:contain;display:block} + .recorder-preview-stamp{position:absolute;top:8px;left:8px;display:inline-flex;align-items:center;gap:6px;background:rgba(0,0,0,0.55);backdrop-filter:blur(4px);padding:3px 8px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:0.12em;color:#fff} + .recorder-preview-dot{width:6px;height:6px;background:oklch(62% 0.22 25);border-radius:50%;animation:fsPulse 1.4s ease-in-out infinite} + @keyframes fsPulse{0%,100%{opacity:0.7}50%{opacity:1}} .recorder-card.recording { border-color: var(--accent-border); } @@ -480,6 +485,7 @@ ${isRecording ? `00:00:00` : ''} ${isRecording ? `
Connecting…
` : ''} + ${isRecording && rec.live_asset_id ? `
LIVE
` : ''}
@@ -520,6 +526,38 @@ clearInterval(pState.timers[id]); delete pState.timers[id]; } }); + // Attach an HLS source to each live-preview