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';
|
const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail';
|
||||||
return (
|
return (
|
||||||
<div className="asset-thumb" style={{ background: 'var(--bg-2)', aspectRatio: aspect, overflow: 'hidden', position: 'relative' }}>
|
<div className="asset-thumb" style={{ background: 'var(--bg-2)', aspectRatio: aspect, overflow: 'hidden', position: 'relative' }}>
|
||||||
{thumbUrl
|
{thumbUrl
|
||||||
? <img src={thumbUrl} alt={altText} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
? <img src={thumbUrl} alt={altText} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
: <FauxFrame />}
|
: <FauxFrame />}
|
||||||
{asset.status === 'live' && (
|
</div>
|
||||||
<div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none' }} />
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue