dragonflight/services/web-ui/public/visuals.jsx
Claude a2790601c9 feat(library): first-frame poster thumbnail for live recordings
Replace the HLS 'connecting…' player in the library with a real frame grabbed
from the start of the recording, while the recording is still live.

Flow:
- recorders.js already pre-creates the asset as status='live' + ASSET_ID env
- capture-manager.start() fires _publishLiveThumbnail() (non-blocking): polls
  /live/<id> for the first seg-*.ts, extracts frame 0 via ffmpeg (scaled JPEG,
  yuvj420p), uploads to S3 thumbnails/<id>.jpg, then POSTs the key to mam-api
- new mam-api POST /assets/:id/live-thumbnail sets thumbnail_s3_key on the still
  -live row (status untouched); idempotent no-op once finalized
- visuals.jsx AssetThumb: for live assets, show the static poster once the key /
  signed URL is available, else fall back to the live HLS preview. Pulsing LIVE
  border kept either way
- POST /assets gains an optional status param (default 'processing'); 'live'
  skips the proxy/thumbnail queue
- capture /stop route now finalizes the pre-created asset by id (guarded) instead
  of POSTing a duplicate

🤖 Generated with Claude Code
2026-06-02 15:21:05 +00:00

326 lines
13 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: once the capture sidecar has published a poster
// thumbnail (first frame of the recording), show that static frame instead
// of the HLS "connecting…" player. Until the poster exists (the brief window
// before the first segment is grabbed), fall back to the live HLS preview.
if (asset.status === 'live' && asset.id) {
if (asset.thumbnail_s3_key || thumbUrl) {
const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Live recording thumbnail';
return (
<div className="asset-thumb" style={{ aspectRatio: aspect, position: 'relative', background: '#000', overflow: 'hidden' }}>
{thumbUrl
? <img src={thumbUrl} alt={altText} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
: <FauxFrame />}
{/* Keep the pulsing LIVE border so it still reads as recording */}
<div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />
</div>
);
}
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 });