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 + 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:
parent
9ec2997f53
commit
46dc17ffb1
1 changed files with 102 additions and 2 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue