From 46dc17ffb1e0d8480ad13ccd33f67e5ee25f4996 Mon Sep 17 00:00:00 2001 From: Zac Date: Tue, 2 Jun 2026 01:35:27 +0000 Subject: [PATCH] fix(web-ui): show live HLS preview for recording assets in library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live/in-progress assets had no thumbnail_s3_key, so AssetThumb fell through to FauxFrame (black box) and then an absolute red border div was drawn on top, producing the 'black box with red outline' symptom. Fix: when asset.status === 'live', render a new LiveThumb component instead of FauxFrame + border overlay. LiveThumb attaches hls.js (or native HLS on Safari) to /live//index.m3u8, shows a muted live video feed, and displays a 'Connecting…' placeholder with a record icon + live-colour border while the manifest loads. Falls back to a 'Recording…' placeholder if hls.js is unavailable or playback fails after retries. The red border overlay is removed from the non-live path; the LIVE badge rendered by AssetCard's thumb-status div still appears on top of the live video. Co-Authored-By: Claude Sonnet 4.6 --- services/web-ui/public/visuals.jsx | 104 ++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/services/web-ui/public/visuals.jsx b/services/web-ui/public/visuals.jsx index 79f4470..374b69b 100644 --- a/services/web-ui/public/visuals.jsx +++ b/services/web-ui/public/visuals.jsx @@ -25,14 +25,114 @@ function AssetThumb({ asset, size = 'md' }) { ); } + // Live/recording assets: show a muted HLS live preview instead of a black + // box. The capture container writes HLS segments to /live//index.m3u8 + // while recording is in progress; no thumbnail_s3_key exists yet. + if (asset.status === 'live' && asset.id) { + return ; + } + const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail'; return (
{thumbUrl ? {altText} : } - {asset.status === 'live' && ( -
+
+ ); +} + +// Muted inline HLS preview for a live/recording asset tile. Attaches hls.js +// (or native HLS on Safari) to show the live feed inside the library card. +// Shows a "connecting…" spinner while the manifest loads, falls back to a +// placeholder with a record icon if hls.js is unavailable or playback fails. +function LiveThumb({ assetId, aspect }) { + const videoRef = React.useRef(null); + const [ready, setReady] = React.useState(false); + const [failed, setFailed] = React.useState(false); + + React.useEffect(() => { + const v = videoRef.current; + if (!v || !assetId) return; + const url = '/live/' + assetId + '/index.m3u8'; + let destroyed = false; + let hls = null; + let retryTimer = 0; + let retryCount = 0; + const MAX_RETRIES = 6; + + const clearRetry = () => { if (retryTimer) { clearTimeout(retryTimer); retryTimer = 0; } }; + + if (v.canPlayType('application/vnd.apple.mpegurl')) { + const tryLoad = () => { + if (destroyed) return; + v.removeAttribute('src'); + v.load(); + v.src = url; + v.play().catch(() => {}); + }; + v.addEventListener('playing', () => { if (!destroyed) { retryCount = 0; setReady(true); } }); + v.addEventListener('error', () => { + if (destroyed || retryCount >= MAX_RETRIES) { setFailed(true); return; } + retryCount++; + clearRetry(); + retryTimer = setTimeout(tryLoad, Math.min(1000 * retryCount, 8000)); + }); + tryLoad(); + return () => { destroyed = true; clearRetry(); }; + } + + if (!window.Hls) { setFailed(true); return; } + + const startHls = () => { + if (destroyed) return; + hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true, maxBufferLength: 10 }); + hls.loadSource(url); + hls.attachMedia(v); + hls.on(window.Hls.Events.MANIFEST_PARSED, () => { + if (!destroyed) { retryCount = 0; setReady(true); v.play().catch(() => {}); } + }); + hls.on(window.Hls.Events.ERROR, (_e, data) => { + if (!data.fatal) return; + try { hls.destroy(); } catch (_) {} + hls = null; + if (destroyed || retryCount >= MAX_RETRIES) { setFailed(true); return; } + retryCount++; + clearRetry(); + retryTimer = setTimeout(startHls, Math.min(1000 * retryCount, 8000)); + }); + }; + + startHls(); + return () => { destroyed = true; clearRetry(); if (hls) { try { hls.destroy(); } catch (_) {} } }; + }, [assetId]); + + return ( +
+