fix(web-ui): show live HLS preview for recording assets in library

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/<assetId>/index.m3u8, shows a muted
live video feed, and displays a 'Connecting…' placeholder with a
record icon + pulsing 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 <noreply@anthropic.com>
This commit is contained in:
Zac 2026-06-02 01:34:17 +00:00
parent 1c068b470e
commit 4a3bf18f7f

View file

@ -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/<id>/index.m3u8
// while recording is in progress; no thumbnail_s3_key exists yet.
if (asset.status === 'live' && asset.id) {
return <LiveThumb assetId={asset.id} aspect={aspect} />;
}
const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail';
return (
<div className="asset-thumb" style={{ background: 'var(--bg-2)', aspectRatio: aspect, overflow: 'hidden', position: 'relative' }}>
{thumbUrl
? <img src={thumbUrl} alt={altText} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
: <FauxFrame />}
{asset.status === 'live' && (
<div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none' }} />
</div>
);
}
// 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 (
<div className="asset-thumb" style={{ aspectRatio: aspect, position: 'relative', background: '#000', overflow: 'hidden' }}>
<video
ref={videoRef}
muted
playsInline
autoPlay
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
{/* Pulsing red border while connecting or playing, matches LIVE badge colour */}
<div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />
{!ready && !failed && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 6,
background: 'rgba(0,0,0,0.55)', color: 'var(--text-3)', fontSize: 11 }}>
<Icon name="record" size={18} style={{ color: 'var(--live)', opacity: 0.9 }} />
<span>Connecting</span>
</div>
)}
{failed && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 6,
background: 'rgba(0,0,0,0.55)', color: 'var(--text-3)', fontSize: 11 }}>
<Icon name="record" size={18} style={{ color: 'var(--live)', opacity: 0.7 }} />
<span>Recording</span>
</div>
)}
</div>
);