// screens-asset.jsx — asset detail (Frame.io-style player, filmstrip, comments) // Simple gradient palette — replaces the missing thumbGrad function const _FRAME_GRADIENTS = [ 'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)', 'linear-gradient(135deg,#1e2030 0%,#2d2040 100%)', 'linear-gradient(135deg,#0d1520 0%,#1a2535 100%)', 'linear-gradient(135deg,#1f1a2e 0%,#2a2040 100%)', 'linear-gradient(135deg,#0f1e18 0%,#1a3028 100%)', 'linear-gradient(135deg,#1e1510 0%,#302018 100%)', 'linear-gradient(135deg,#1a1020 0%,#281830 100%)', 'linear-gradient(135deg,#101828 0%,#182438 100%)', 'linear-gradient(135deg,#1e2820 0%,#283830 100%)', 'linear-gradient(135deg,#201820 0%,#302030 100%)', 'linear-gradient(135deg,#181e28 0%,#202838 100%)', ]; function AssetDetail({ asset, onClose }) { const [playing, setPlaying] = React.useState(false); const [currentMs, setCurrentMs] = React.useState(0); const [tab, setTab] = React.useState("comments"); const [showResolved, setShowResolved] = React.useState(false); const [comments, setComments] = React.useState([]); const [newComment, setNewComment] = React.useState(""); const [commentsLoading, setCommentsLoading] = React.useState(false); // Stream / video state const [streamUrl, setStreamUrl] = React.useState(null); const [streamType, setStreamType] = React.useState(null); // Why the stream is unavailable: 'no_proxy' (has hi-res source, browser // can't play it directly) or null (still loading / live / playable). const [streamReason, setStreamReason] = React.useState(null); const [streamHasSource, setStreamHasSource] = React.useState(false); const [streamLoading, setStreamLoading] = React.useState(false); const [videoDuration, setVideoDuration] = React.useState(0); const [retrying, setRetrying] = React.useState(false); const [reprocessing, setReprocessing] = React.useState(null); // 'proxy' | 'thumbnail' | null const [filmFrames, setFilmFrames] = React.useState([]); const [filmstripLoading, setFilmstripLoading] = React.useState(false); const videoRef = React.useRef(null); const assetId = asset && asset.id; const totalMs = videoDuration > 0 ? videoDuration : parseDuration(asset.duration); // Fetch stream URL when asset changes React.useEffect(() => { if (!assetId) return; setStreamUrl(null); setStreamType(null); setStreamReason(null); setStreamHasSource(false); setVideoDuration(0); setCurrentMs(0); setPlaying(false); setFilmFrames([]); setStreamLoading(true); window.ZAMPP_API.fetch('/assets/' + assetId + '/stream') .then(function(r) { if (r && r.url) { setStreamUrl(r.url); setStreamType(r.type || 'mp4'); } else if (r) { // {url: null, reason: 'no_proxy', has_source: true|false} setStreamReason(r.reason || null); setStreamHasSource(!!r.has_source); } }) .catch(function() {}) .finally(function() { setStreamLoading(false); }); }, [assetId]); // Wire hls.js for live HLS streams React.useEffect(() => { if (!streamUrl || streamType !== 'hls' || !videoRef.current) return; if (!window.Hls) return; const hls = new window.Hls(); hls.loadSource(streamUrl); hls.attachMedia(videoRef.current); return function() { hls.destroy(); }; }, [streamUrl, streamType]); // Build filmstrip from real video frames. HLS streams use hls.js probe. React.useEffect(() => { if (!streamUrl || totalMs <= 0) { setFilmFrames([]); setFilmstripLoading(false); return; } let cancelled = false; const build = async function() { setFilmstripLoading(true); const probe = document.createElement('video'); probe.crossOrigin = 'anonymous'; probe.muted = true; probe.playsInline = true; probe.preload = 'auto'; probe.style.cssText = 'position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;pointer-events:none'; document.body.appendChild(probe); const timeout = setTimeout(function() { if (probe.parentNode) probe.parentNode.removeChild(probe); if (!cancelled) { setFilmFrames([]); setFilmstripLoading(false); } cancelled = true; }, 15000); try { if (streamType === 'hls') { if (!window.Hls) throw new Error('hls.js not loaded'); await new Promise(function(resolve, reject) { const hls = new window.Hls(); hls.on(window.Hls.Events.MANIFEST_PARSED, function() { probe.oncanplay = function() { probe.oncanplay = null; resolve(); }; probe.onerror = reject; }); hls.on(window.Hls.Events.ERROR, function(ev, data) { reject(data); }); hls.loadSource(streamUrl); hls.attachMedia(probe); }); } else { await new Promise(function(resolve, reject) { probe.onloadedmetadata = resolve; probe.onerror = reject; probe.src = streamUrl; }); } const frameCount = 28; const width = 160; const height = 90; const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); const nextFrames = []; for (let i = 0; i < frameCount; i++) { const at = frameCount === 1 ? 0 : (probe.duration * i) / (frameCount - 1); await new Promise(function(resolve) { const done = function() { try { ctx.drawImage(probe, 0, 0, width, height); nextFrames.push(canvas.toDataURL('image/jpeg', 0.72)); } catch (_) { nextFrames.push(null); } resolve(); }; const onSeeked = function() { probe.removeEventListener('seeked', onSeeked); done(); }; probe.addEventListener('seeked', onSeeked); probe.currentTime = Math.min(Math.max(at, 0), Math.max(0, probe.duration - 0.05)); }); if (cancelled) return; } if (!cancelled) setFilmFrames(nextFrames); } catch (_) { if (!cancelled) setFilmFrames([]); } finally { clearTimeout(timeout); if (probe.parentNode) probe.parentNode.removeChild(probe); if (!cancelled) setFilmstripLoading(false); } }; build(); return function() { cancelled = true; }; }, [streamUrl, streamType, totalMs]); // Fake playback timer — only used when no real video stream React.useEffect(() => { if (!playing || totalMs <= 0 || streamUrl) return; const i = setInterval(function() { setCurrentMs(function(t) { const next = t + 100; if (next >= totalMs) { setPlaying(false); return totalMs; } return next; }); }, 100); return function() { clearInterval(i); }; }, [playing, totalMs, streamUrl]); const togglePlay = function() { if (videoRef.current) { if (videoRef.current.paused) { videoRef.current.play(); } else { videoRef.current.pause(); } } else { setPlaying(function(p) { return !p; }); } }; const seek = function(ms) { const clamped = Math.max(0, Math.min(totalMs || 0, ms)); setCurrentMs(clamped); if (videoRef.current) videoRef.current.currentTime = clamped / 1000; }; // Pull a presigned hi-res URL and trigger a browser download with the // asset's display name as the filename. Falls back to opening in a new tab. const [downloading, setDownloading] = React.useState(false); const downloadHires = function() { if (downloading) return; setDownloading(true); window.ZAMPP_API.fetch('/assets/' + assetId + '/hires') .then(function(r) { if (!r || !r.url) { window.alert('No hi-res source available for this asset.'); return; } const a = document.createElement('a'); a.href = r.url; a.download = r.filename || (asset.name + '.' + (r.ext || 'mov')); a.target = '_blank'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); document.body.removeChild(a); }) .catch(function(e) { window.alert('Download failed: ' + (e.message || 'unknown error')); }) .finally(function() { setDownloading(false); }); }; // Right-click style menu on the kebab icon — delete, copy ID. const [menuOpen, setMenuOpen] = React.useState(false); const moreBtnRef = React.useRef(null); React.useEffect(function() { if (!menuOpen) return; const close = function() { setMenuOpen(false); }; window.addEventListener('click', close); return function() { window.removeEventListener('click', close); }; }, [menuOpen]); const copyId = function() { if (navigator.clipboard) navigator.clipboard.writeText(assetId).catch(function() {}); setMenuOpen(false); }; const deleteAsset = function() { setMenuOpen(false); if (!window.confirm('Delete "' + (asset.name || assetId) + '" permanently?\nRemoves DB row + S3 objects. Cannot be undone.')) return; window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' }) .then(function() { onClose && onClose(); }) .catch(function(e) { window.alert('Delete failed: ' + e.message); }); }; const retryProcessing = function() { if (retrying) return; setRetrying(true); window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' }) .then(function() { window.alert('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.'); }) .catch(function(e) { window.alert('Retry failed: ' + (e.message || 'unknown error')); }) .finally(function() { setRetrying(false); }); }; const reprocessJob = function(type) { if (reprocessing) return; setReprocessing(type); window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=' + type, { method: 'POST' }) .then(function() { window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.'); }) .catch(function(e) { window.alert('Reprocess failed: ' + (e.message || 'unknown error')); }) .finally(function() { setReprocessing(null); }); }; // Map a /assets/:id/comments row into the legacy shape the consumer // components (PlaybackBar pins, FilmStrip pins, comment list) already expect. function _normalizeComment(row) { const frameMs = row.frame_ms != null ? row.frame_ms : 0; const real = row.created_at ? window.ZAMPP_API.fmtRelative(row.created_at) : 'just now'; return { id: row.id, who: row.author_name || window.ZAMPP_DATA?.ME?.name || 'You', avatar: row.author_initials || (row.author_name ? row.author_name.slice(0, 2).toUpperCase() : (window.ZAMPP_DATA?.ME?.initials || 'ZG')), time: msToTimecode(frameMs), frame_ms: frameMs, real, text: row.body, resolved: !!row.resolved, frame: Math.floor(frameMs / 1000 * 30), }; } // Load persisted comments whenever the open asset changes. React.useEffect(() => { if (!assetId) return; setCommentsLoading(true); window.ZAMPP_API.fetch('/assets/' + assetId + '/comments') .then(function(r) { setComments(((r && r.comments) || []).map(_normalizeComment)); }) .catch(function() { setComments([]); }) .finally(function() { setCommentsLoading(false); }); }, [assetId]); const addComment = function() { const text = newComment.trim(); if (!text) return; setNewComment(''); window.ZAMPP_API.fetch('/assets/' + assetId + '/comments', { method: 'POST', body: JSON.stringify({ body: text, frame_ms: Math.round(currentMs) }), }) .then(function(row) { setComments(function(c) { return [...c, _normalizeComment(row)]; }); }) .catch(function(e) { window.alert('Could not post comment: ' + (e.message || 'unknown error')); setNewComment(text); }); }; const toggleResolved = function(c) { window.ZAMPP_API.fetch('/assets/' + assetId + '/comments/' + c.id, { method: 'PATCH', body: JSON.stringify({ resolved: !c.resolved }), }) .then(function(row) { setComments(function(prev) { return prev.map(x => x.id === c.id ? _normalizeComment(row) : x); }); }) .catch(function() {}); }; const deleteComment = function(c) { if (!confirm('Delete this comment?')) return; window.ZAMPP_API.fetch('/assets/' + assetId + '/comments/' + c.id, { method: 'DELETE' }) .then(function() { setComments(function(prev) { return prev.filter(x => x.id !== c.id); }); }) .catch(function(e) { window.alert('Delete failed: ' + e.message); }); }; const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; }); // The player overlay text reflects three states: live processing, // explicit error, "ingested but never proxied" (the SRT-archive case), // or generic not-ready. const isMissingProxy = !streamUrl && streamReason === 'no_proxy' && streamHasSource; const statusMessage = asset.status === 'processing' ? 'Processing…' : asset.status === 'live' ? 'Live recording in progress' : asset.status === 'error' ? 'Processing failed' : isMissingProxy ? 'This clip was ingested but never got a browser-playable proxy.' : 'Preview not yet available'; // "Retry processing" (for status=error) and "Generate proxy" (for // status=ready/archived but missing proxy) both call the same /retry // endpoint. Label depends on what the user is staring at. const canRetry = (asset.status === 'error') || isMissingProxy; const retryLabel = isMissingProxy ? 'Generate proxy' : 'Retry processing'; return (