dragonflight/services/web-ui/public/visuals.jsx
Zac 4a3bf18f7f 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>
2026-06-02 01:34:17 +00:00

313 lines
12 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 (
<div className="asset-thumb audio" style={{ aspectRatio: aspect }}>
<Waveform seed={asset.id ? asset.id.charCodeAt(0) % 60 : 1} />
<div className="thumb-overlay"><Icon name="audio" size={20} style={{ opacity: 0.9 }} /></div>
</div>
);
}
// 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 />}
</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>
);
}
function FauxFrame({ seed }) {
return <div className="thumb-svg" style={{ background: 'var(--bg-1)' }} />;
}
function Waveform({ seed = 1, color = 'var(--accent)', className = '' }) {
const bars = 60;
const pts = React.useMemo(() => {
return Array.from({ length: bars }).map((_, i) => {
const n = Math.sin(i * 0.7 + seed) * 0.5 + Math.sin(i * 2.1 + seed * 1.3) * 0.3 + Math.sin(i * 4.3 + seed * 0.7) * 0.2;
return Math.max(0.1, Math.min(1, 0.5 + n * 0.5));
});
}, [seed]);
return (
<svg viewBox="0 0 60 20" preserveAspectRatio="none" className={'waveform ' + className}>
{pts.map((p, i) => (
<rect key={i} x={i} y={10 - p * 9} width="0.6" height={p * 18} fill={color} rx="0.3" />
))}
</svg>
);
}
function LiveStrip({ seed = 1, count = 8 }) {
return (
<div className="live-strip">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="live-strip-cell" style={{ background: 'var(--bg-1)' }} />
))}
<div className="live-strip-now"><span className="live-pulse" />NOW</div>
</div>
);
}
function Sparkline({ data, color = 'var(--accent)', height = 28, fill = true }) {
const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;
const w = 100;
const pts = data.map((d, i) => {
const x = (i / (data.length - 1)) * w;
const y = height - ((d - min) / range) * height;
return x + ',' + y;
}).join(' ');
const area = '0,' + height + ' ' + pts + ' ' + w + ',' + height;
return (
<svg viewBox={'0 0 ' + w + ' ' + height} preserveAspectRatio="none" style={{ width: '100%', height, display: 'block' }}>
{fill && <polygon points={area} fill={color} opacity="0.15" />}
<polyline points={pts} fill="none" stroke={color} strokeWidth="1.5" />
</svg>
);
}
function AudioMeter({ level = 0.7, peak = 0.85, vertical = false }) {
const segs = 20;
return (
<div className={'audio-meter ' + (vertical ? 'v' : 'h')}>
{Array.from({ length: segs }).map((_, i) => {
const v = i / segs;
const on = v < level;
const color = v < 0.6 ? 'var(--success)' : v < 0.85 ? 'var(--warning)' : 'var(--danger)';
return <div key={i} className="audio-seg" style={{ background: on ? color : 'var(--bg-3)', opacity: on ? 1 : 0.4 }} />;
})}
</div>
);
}
function StatusDot({ status }) {
const map = {
online: { color: 'var(--success)', pulse: false },
recording: { color: 'var(--live)', pulse: true },
armed: { color: 'var(--accent)', pulse: false },
idle: { color: 'var(--text-4)', pulse: false },
error: { color: 'var(--danger)', pulse: true },
offline: { color: 'var(--text-4)', pulse: false },
processing: { color: 'var(--warning)', pulse: true },
ready: { color: 'var(--success)', pulse: false },
live: { color: 'var(--live)', pulse: true },
queued: { color: 'var(--text-3)', pulse: false },
running: { color: 'var(--accent)', pulse: true },
done: { color: 'var(--success)', pulse: false },
failed: { color: 'var(--danger)', pulse: false },
stopped: { color: 'var(--text-4)', pulse: false },
};
const s = map[status] || { color: 'var(--text-3)' };
return <span className={'status-dot ' + (s.pulse ? 'pulse' : '')} style={{ background: s.color, boxShadow: '0 0 0 3px ' + s.color + '30' }} />;
}
function Elapsed({ seconds, live = false }) {
const [t, setT] = React.useState(seconds);
React.useEffect(() => {
if (!live) return;
const i = setInterval(() => setT(x => x + 1), 1000);
return () => clearInterval(i);
}, [live]);
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
return <span className="mono">{String(h).padStart(2,'0')}:{String(m).padStart(2,'0')}:{String(s).padStart(2,'0')}</span>;
}
// ─────────────────────────────────────────────────────────────────────────
// ConfirmModal + useConfirm — in-page replacement for window.confirm().
//
// Usage in a component:
// const [confirm, confirmModal] = useConfirm();
// ...
// if (!(await confirm({ title: 'Delete user?', message: '…' }))) return;
// ...
// return (<>{confirmModal} ...rest of UI... </>);
//
// confirm(opts) returns a Promise<boolean>. Options:
// title, message, confirmLabel (default 'Delete'), cancelLabel ('Cancel'),
// danger (default true → red confirm button).
function ConfirmModal({ title, message, confirmLabel = 'Delete', cancelLabel = 'Cancel', danger = true, onConfirm, onCancel }) {
React.useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') onCancel();
else if (e.key === 'Enter') onConfirm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onConfirm, onCancel]);
return (
<div className="modal-backdrop" onClick={onCancel}>
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 440 }}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>{title}</div>
<button className="icon-btn" aria-label="Close" onClick={onCancel}><Icon name="x" /></button>
</div>
<div className="modal-body">
{typeof message === 'string'
? message.split('\n').map((line, i) => (
<div key={i} style={{ fontSize: 13, color: 'var(--text-2)', lineHeight: 1.5 }}>{line || ' '}</div>
))
: <div style={{ fontSize: 13, color: 'var(--text-2)', lineHeight: 1.5 }}>{message}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost" onClick={onCancel}>{cancelLabel}</button>
<button className={danger ? 'btn danger' : 'btn primary'} onClick={onConfirm} autoFocus>{confirmLabel}</button>
</div>
</div>
</div>
);
}
function useConfirm() {
const [state, setState] = React.useState(null); // { opts, resolve } | null
const confirm = React.useCallback((opts) => {
return new Promise((resolve) => {
setState({ opts: opts || {}, resolve });
});
}, []);
const close = React.useCallback((result) => {
setState((s) => {
if (s) s.resolve(result);
return null;
});
}, []);
const modal = state
? <ConfirmModal
{...state.opts}
onConfirm={() => close(true)}
onCancel={() => close(false)}
/>
: null;
return [confirm, modal];
}
Object.assign(window, { AssetThumb, FauxFrame, Waveform, LiveStrip, Sparkline, AudioMeter, StatusDot, Elapsed, ConfirmModal, useConfirm });