// 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 [filmstripKey, setFilmstripKey] = React.useState(0); // Player health: 'idle' | 'loading' | 'playing' | 'paused' | 'seeking' | 'waiting' | 'stalled' | 'error' const [playerState, setPlayerState] = React.useState('idle'); const [playerError, setPlayerError] = React.useState(null); // Array of {start, end} in milliseconds - populated from HTMLMediaElement.buffered const [buffered, setBuffered] = React.useState([]); // Wall-clock when waiting/stalled began (so we can show how long it's been hung) const [stallStart, setStallStart] = React.useState(null); const [stallElapsedMs, setStallElapsedMs] = React.useState(0); 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]); // Fetch server-side filmstrip (pre-built by filmstrip worker via FFmpeg). // Falls back to nothing if not ready yet - user can right-click → Re-generate. React.useEffect(() => { if (!assetId) return; let cancelled = false; setFilmFrames([]); setFilmstripLoading(true); window.ZAMPP_API.fetch('/assets/' + assetId + '/filmstrip') .then(function(r) { if (cancelled) return; if (!r || !r.url) { setFilmstripLoading(false); return; } // Fetch the JSON array of base64 frames from the signed S3 URL return fetch(r.url) .then(function(res) { return res.json(); }) .then(function(frames) { if (!cancelled && Array.isArray(frames) && frames.length) { setFilmFrames(frames); } }); }) .catch(function() {}) .finally(function() { if (!cancelled) setFilmstripLoading(false); }); return function() { cancelled = true; }; }, [assetId, filmstripKey]); // 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; }); } }; // Read HTMLMediaElement.buffered into a JSON-friendly array of {start, end} ms. // Called from onProgress and onTimeUpdate so the UI tracks buffer growth in real time. const updateBuffered = function() { const v = videoRef.current; if (!v || !v.buffered || v.buffered.length === 0) { setBuffered([]); return; } const ranges = []; for (let i = 0; i < v.buffered.length; i++) { ranges.push({ start: v.buffered.start(i) * 1000, end: v.buffered.end(i) * 1000, }); } setBuffered(ranges); }; // Tick the stall elapsed counter once per second when we're hung React.useEffect(() => { if (!stallStart) { setStallElapsedMs(0); return; } const i = setInterval(() => setStallElapsedMs(Date.now() - stallStart), 250); return () => clearInterval(i); }, [stallStart]); // #143 - if the player is stalled within 250 ms of EOF for more than 1.2 s, // treat it as a clean end. Avoids the silent-freeze users hit when seeking // to the last instant of a clip. React.useEffect(() => { if (!stallStart) return; if (!videoRef.current || !totalMs) return; const id = setTimeout(() => { const v = videoRef.current; if (!v) return; const posMs = (v.currentTime || 0) * 1000; if (totalMs - posMs <= 250 && (playerState === 'waiting' || playerState === 'stalled')) { try { v.pause(); } catch (_) {} setPlaying(false); setPlayerState('paused'); setStallStart(null); } }, 1200); return () => clearTimeout(id); }, [stallStart, totalMs, playerState]); const seek = function(ms) { // #143 - seeking exactly to `totalMs` parked the playhead one micro-sample // past the last decoded frame; the player then asked S3 for a range past // EOF and stalled silently. Pull the clamp back 50 ms so the final frames // are reachable but the player never asks for bytes past the file size. const upperBoundMs = Math.max(0, (totalMs || 0) - 50); const clamped = Math.max(0, Math.min(upperBoundMs, 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); }); }; const regenFilmstrip = function() { window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' }) .then(function() { window.alert('Filmstrip job queued: it will appear automatically when ready.'); }) .catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); }); }; // 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 (
{asset.name}
{asset.project}·updated {asset.updated}
{menuOpen && (
)}
{streamUrl ? (
); } function PlaybackBar({ current, total, onSeek, comments, buffered }) { const ref = React.useRef(null); const handle = function(e) { const r = ref.current.getBoundingClientRect(); const p = (e.clientX - r.left) / r.width; onSeek(Math.max(0, Math.min(1, p)) * total); }; const pct = total > 0 ? (current / total) * 100 : 0; const bufferedRanges = Array.isArray(buffered) ? buffered : []; return (
{/* Buffered byte ranges: translucent grey segments showing what the browser has loaded */} {total > 0 && bufferedRanges.map((br, i) => { const left = Math.max(0, (br.start / total) * 100); const right = Math.min(100, (br.end / total) * 100); if (right <= left) return null; return (
); })}
{comments.map(function(c) { const ct = parseDuration(c.time); if (!ct || total <= 0) return null; const x = (ct / total) * 100; return (
{c.avatar}
); })}
); } function FilmStrip({ seed, current, total, onSeek, comments, frames, loading, onRegenFilmstrip, onRegenProxy, reprocessing }) { const ref = React.useRef(null); const [ctx, setCtx] = React.useState(null); const fallbackFrames = 28; const handle = function(e) { if (!ref.current || total <= 0) return; const r = ref.current.getBoundingClientRect(); onSeek(((e.clientX - r.left) / r.width) * total); }; const handleContextMenu = function(e) { e.preventDefault(); setCtx({ x: e.clientX, y: e.clientY }); }; React.useEffect(function() { if (!ctx) return; var close = function() { setCtx(null); }; window.addEventListener('click', close); window.addEventListener('contextmenu', close); window.addEventListener('scroll', close, true); return function() { window.removeEventListener('click', close); window.removeEventListener('contextmenu', close); window.removeEventListener('scroll', close, true); }; }, [ctx]); const pct = total > 0 ? (current / total) * 100 : 0; const items = Array.isArray(frames) && frames.length ? frames : Array.from({ length: fallbackFrames }).map(function(_, i) { return null; }); return (
{items.map(function(src, i) { return (
{src ? ( ) : ( )}
); })}
{comments.map(function(c) { const ct = parseDuration(c.time); if (!ct || total <= 0) return null; const x = (ct / total) * 100; return (
{c.avatar}
); })}
{[0, 0.25, 0.5, 0.75, 1].map(function(p) { return {msToTimecode(p * total)}; })}
{loading &&
Building filmstrip…
} {ctx && (
)}
); } function CommentsList({ comments, onSeek, onResolve, onDelete }) { if (comments.length === 0) { return
No comments yet. Add one below to mark a frame.
; } return (
{comments.map(function(c) { return (
{c.avatar}
{c.who} {c.real}
{onDelete && ( )}
{c.text}
{c.resolved &&
✓ Resolved
}
); })}
); } function CommentComposer({ asset, currentMs, value, onChange, onSubmit }) { const me = window.ZAMPP_DATA?.ME; const myInitials = me?.initials || 'ZG'; return (
{myInitials}
{me?.name || 'You'}
@ {msToTimecode(currentMs)}