// visuals.jsx - reusable visual elements const _thumbCache = new Map(); function AssetThumb({ asset, size = 'md' }) { const aspect = size === 'tall' ? '9 / 16' : '16 / 9'; const [thumbUrl, setThumbUrl] = React.useState(_thumbCache.get(asset.id) || null); React.useEffect(() => { if (!asset.id || thumbUrl || !asset.thumbnail_s3_key) return; let cancelled = false; fetch((window.ZAMPP_API_PREFIX || '/api/v1') + '/assets/' + asset.id + '/thumbnail', { credentials: 'include' }) .then(r => r.ok ? r.json() : null) .then(d => { if (!cancelled && d && d.url) { _thumbCache.set(asset.id, d.url); setThumbUrl(d.url); } }) .catch(() => {}); return () => { cancelled = true; }; }, [asset.id, asset.thumbnail_s3_key]); if (asset.type === 'audio' || asset.media_type === 'audio') { return (
); } // 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} : }
); } // 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 (