feat(recorders): stable elapsed timer + live HLS preview on the card; optimistic signal default

This commit is contained in:
Zac Gaetano 2026-05-18 09:40:42 -04:00
parent 57c3871cc1
commit 57116dde42
2 changed files with 65 additions and 3 deletions

View file

@ -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,

View file

@ -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 ? `<span class="recorder-timer" id="timer-${rec.id}">00:00:00</span>` : ''}
</div>
${isRecording ? `<div class="signal-strip" id="signalStrip-${rec.id}"><div class="signal-strip-fill"></div></div><div class="recorder-status-row" style="font-size:var(--text-xs);"><span id="signal-${rec.id}" style="color:var(--text-tertiary);font-family:var(--font-mono);letter-spacing:0.02em">Connecting…</span></div>` : ''}
${isRecording && rec.live_asset_id ? `<div class="recorder-preview"><video id="livevideo-${rec.id}" data-live-id="${rec.live_asset_id}" muted playsinline autoplay></video><div class="recorder-preview-stamp"><span class="recorder-preview-dot"></span>LIVE</div></div>` : ''}
<div class="recorder-source">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M2 4h10M2 8h7M2 12h5"/></svg>
@ -520,6 +526,38 @@
clearInterval(pState.timers[id]); delete pState.timers[id];
}
});
// Attach an HLS source to each live-preview <video> on the page.
pState.recorders.filter(r => r.status === 'recording' && r.live_asset_id).forEach(rec => {
const v = document.getElementById('livevideo-' + rec.id);
if (!v || v.dataset.attached === '1') return;
const url = '/live/' + rec.live_asset_id + '/index.m3u8';
const attach = () => {
if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = url; v.play().catch(() => {});
} else if (window.Hls && window.Hls.isSupported()) {
const hls = new Hls({ lowLatencyMode: true, liveSyncDuration: 2, liveMaxLatencyDuration: 6 });
hls.loadSource(url); hls.attachMedia(v);
hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(() => {}));
v._hls = hls;
}
v.dataset.attached = '1';
};
if (window.Hls) attach();
else {
const sc = document.createElement('script');
sc.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.0/dist/hls.min.js';
sc.onload = attach;
document.head.appendChild(sc);
}
});
document.querySelectorAll('video[data-live-id]').forEach(v => {
const id = v.id.replace('livevideo-', '');
const rec = pState.recorders.find(r => r.id === id);
if (!rec || rec.status !== 'recording') {
try { if (v._hls) { v._hls.destroy(); delete v._hls; } } catch (_) {}
v.removeAttribute('src');
}
});
}
function updateSignalBadge(rid, st) {