dragonflight/services/web-ui/public/screens-asset.jsx
ZGaetano 6f2de45819 feat: wire real video playback via GET /assets/:id/stream
- Fetch stream URL on asset open; show <video> element for mp4/hls
- Use hls.js for live HLS streams (loaded via CDN in index.html)
- Sync video play/pause/seek/timeupdate to React state
- Show loading state while fetching stream, status message when unavailable
- Add Retry processing button for error-status assets
- totalMs derived from video metadata when available, falls back to parseDuration
2026-05-22 13:37:55 -04:00

496 lines
20 KiB
JavaScript

// screens-asset.jsx — asset detail (Frame.io-style player, filmstrip, comments)
const { COMMENTS: SEED_COMMENTS } = window.ZAMPP_DATA;
// 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(SEED_COMMENTS || []);
const [newComment, setNewComment] = React.useState("");
// Stream / video state
const [streamUrl, setStreamUrl] = React.useState(null);
const [streamType, setStreamType] = React.useState(null);
const [streamLoading, setStreamLoading] = React.useState(false);
const [videoDuration, setVideoDuration] = React.useState(0);
const [retrying, setRetrying] = 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);
setVideoDuration(0);
setCurrentMs(0);
setPlaying(false);
setStreamLoading(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
.then(function(r) {
if (r && r.url) {
setStreamUrl(r.url);
setStreamType(r.type || 'mp4');
}
})
.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]);
// 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;
};
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. Refresh in a moment.'); })
.catch(function(e) { window.alert('Retry failed: ' + (e.message || 'unknown error')); })
.finally(function() { setRetrying(false); });
};
const addComment = function() {
if (!newComment.trim()) return;
const t = msToTimecode(currentMs);
setComments(function(c) {
return [...c, {
id: 'n' + Date.now(),
who: "Zach Gaetano",
avatar: "ZG",
time: t,
real: "just now",
text: newComment,
resolved: false,
frame: Math.floor(currentMs / 1000 * 30),
}];
});
setNewComment("");
};
const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; });
const statusMessage =
asset.status === 'processing' ? 'Processing…' :
asset.status === 'live' ? 'Live recording in progress' :
asset.status === 'error' ? 'Processing failed' :
'Preview not yet available';
return (
<div className="asset-detail fade-in">
<div className="asset-detail-header">
<button className="icon-btn" onClick={onClose}><Icon name="arrowLeft" /></button>
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{asset.name}</span>
<StatusDot status={asset.status} />
</div>
<div style={{ fontSize: 11.5, color: "var(--text-3)", display: "flex", gap: 6 }}>
<span>{asset.project}</span><span>·</span><span>updated {asset.updated}</span>
</div>
</div>
<div style={{ flex: 1 }} />
<button className="btn ghost sm"><Icon name="download" />Download</button>
<button className="btn subtle sm"><Icon name="check" />Approve</button>
<button className="icon-btn"><Icon name="more" /></button>
</div>
<div className="asset-detail-body">
<div className="player-col">
<div className="player">
<div className="player-canvas">
{streamUrl ? (
<video
key={streamUrl}
ref={videoRef}
src={streamType !== 'hls' ? streamUrl : undefined}
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block', background: '#000' }}
onTimeUpdate={function() { if (videoRef.current) setCurrentMs(videoRef.current.currentTime * 1000); }}
onLoadedMetadata={function() { if (videoRef.current) setVideoDuration(videoRef.current.duration * 1000); }}
onPlay={function() { setPlaying(true); }}
onPause={function() { setPlaying(false); }}
onEnded={function() { setPlaying(false); }}
/>
) : (
<React.Fragment>
<FauxFrame seed={asset.seed || 1} />
<div className="scanlines" />
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: 'var(--text-3)' }}>
{streamLoading ? (
<div style={{ fontSize: 13 }}>Loading</div>
) : (
<React.Fragment>
<div style={{ fontSize: 13, marginBottom: 8 }}>{statusMessage}</div>
<StatusDot status={asset.status} />
{asset.status === 'error' && (
<button
className="btn ghost sm"
style={{ marginTop: 12, display: 'block', margin: '12px auto 0' }}
onClick={retryProcessing}
disabled={retrying}
>
{retrying ? 'Retrying…' : 'Retry processing'}
</button>
)}
</React.Fragment>
)}
</div>
</div>
{!streamLoading && !playing && totalMs > 0 && (
<button className="player-play-overlay" onClick={togglePlay}>
<Icon name="play" size={28} />
</button>
)}
</React.Fragment>
)}
<div className="player-overlay-markers">
{visibleComments
.filter(function(c) { return Math.abs(parseDuration(c.time) - currentMs) < 200; })
.map(function(c) {
return (
<div key={c.id} className="player-pin">
<div className="player-pin-avatar">{c.avatar}</div>
<div className="player-pin-bubble">{c.text}</div>
</div>
);
})}
</div>
{asset.status === "live" && (
<div style={{ position: "absolute", top: 12, left: 12 }}>
<span className="badge live">LIVE · REC</span>
</div>
)}
{totalMs > 0 && (
<div className="player-tc">
<span className="mono">{msToTimecode(currentMs)}</span>
<span style={{ opacity: 0.5 }}> / {asset.duration}</span>
</div>
)}
</div>
{totalMs > 0 && (
<div className="player-controls">
<button className="icon-btn" onClick={togglePlay}>
<Icon name={playing ? "pause" : "play"} size={14} />
</button>
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-2)", minWidth: 70 }}>{msToTimecode(currentMs)}</span>
<PlaybackBar current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} />
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-3)", minWidth: 70, textAlign: "right" }}>{asset.duration}</span>
<div style={{ width: 1, height: 18, background: "var(--border-strong)" }} />
<button className="icon-btn"><Icon name="audio" size={14} /></button>
<button className="icon-btn"><Icon name="layout" size={14} /></button>
</div>
)}
{totalMs > 0 && (
<FilmStrip seed={asset.seed || 1} current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} />
)}
</div>
<div className="asset-detail-tabs">
<button className={tab === "comments" ? "active" : ""} onClick={function() { setTab("comments"); }}>
Comments <span className="count">{comments.length}</span>
</button>
<button className={tab === "versions" ? "active" : ""} onClick={function() { setTab("versions"); }}>
Versions
</button>
<button className={tab === "metadata" ? "active" : ""} onClick={function() { setTab("metadata"); }}>
Metadata
</button>
<button className={tab === "audio" ? "active" : ""} onClick={function() { setTab("audio"); }}>
Audio
</button>
<div style={{ flex: 1 }} />
{tab === "comments" && (
<label className="tiny-toggle">
<input type="checkbox" checked={showResolved} onChange={function(e) { setShowResolved(e.target.checked); }} /> Show resolved
</label>
)}
</div>
<div className="asset-detail-content">
{tab === "comments" && (
<CommentsList
comments={visibleComments}
onSeek={function(c) { seek(parseDuration(c.time)); }}
onResolve={function(id) {
setComments(function(cs) { return cs.map(function(c) { return c.id === id ? Object.assign({}, c, { resolved: !c.resolved }) : c; }); });
}}
/>
)}
{tab === "versions" && <VersionsTab />}
{tab === "metadata" && <MetadataTab asset={asset} />}
{tab === "audio" && <AudioTab asset={asset} />}
</div>
</div>
<div className="comment-composer-col">
<CommentComposer
asset={asset}
currentMs={currentMs}
value={newComment}
onChange={setNewComment}
onSubmit={addComment}
/>
</div>
</div>
</div>
);
}
function PlaybackBar({ current, total, onSeek, comments }) {
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;
return (
<div className="playback-bar" ref={ref} onClick={handle}>
<div className="playback-fill" style={{ width: pct + '%' }} />
<div className="playback-handle" style={{ left: pct + '%' }} />
{comments.map(function(c) {
const ct = parseDuration(c.time);
if (!ct || total <= 0) return null;
const x = (ct / total) * 100;
return (
<div
key={c.id}
className={'playback-marker ' + (c.resolved ? 'resolved' : '')}
style={{ left: x + '%' }}
onClick={function(e) { e.stopPropagation(); onSeek(ct); }}
>
<span className="marker-avatar">{c.avatar}</span>
</div>
);
})}
</div>
);
}
function FilmStrip({ seed, current, total, onSeek, comments }) {
const ref = React.useRef(null);
const frames = 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 pct = total > 0 ? (current / total) * 100 : 0;
return (
<div className="filmstrip-wrap">
<div className="filmstrip" ref={ref} onClick={handle}>
{Array.from({ length: frames }).map(function(_, i) {
return (
<div key={i} className="film-frame" style={{ background: _FRAME_GRADIENTS[(seed + i) % _FRAME_GRADIENTS.length] }}>
<FauxFrame seed={(seed + i) % 6} />
</div>
);
})}
<div className="filmstrip-playhead" style={{ left: pct + '%' }} />
{comments.map(function(c) {
const ct = parseDuration(c.time);
if (!ct || total <= 0) return null;
const x = (ct / total) * 100;
return (
<div key={c.id} className={'filmstrip-pin ' + (c.resolved ? 'resolved' : '')} style={{ left: x + '%' }}>
<span className="filmstrip-pin-avatar">{c.avatar}</span>
</div>
);
})}
</div>
<div className="filmstrip-tc">
{[0, 0.25, 0.5, 0.75, 1].map(function(p) {
return <span key={p} className="mono">{msToTimecode(p * total)}</span>;
})}
</div>
</div>
);
}
function CommentsList({ comments, onSeek, onResolve }) {
if (comments.length === 0) {
return <div style={{ padding: 40, textAlign: "center", color: "var(--text-3)" }}>No comments yet.</div>;
}
return (
<div className="comments-list">
{comments.map(function(c) {
return (
<div key={c.id} className={'comment ' + (c.resolved ? 'resolved' : '')}>
<div className="comment-avatar avatar" style={{ background: avatarColor(c.avatar) }}>{c.avatar}</div>
<div className="comment-body">
<div className="comment-head">
<span className="comment-who">{c.who}</span>
<button className="comment-time" onClick={function() { onSeek(c); }}><Icon name="clock" size={10} />{c.time}</button>
<span className="comment-when">{c.real}</span>
<div style={{ flex: 1 }} />
<button className="comment-action" onClick={function() { onResolve(c.id); }}>
<Icon name={c.resolved ? "refresh" : "check"} size={12} />
</button>
</div>
<div className="comment-text">{c.text}</div>
{c.resolved && <div className="comment-resolved"> Resolved</div>}
</div>
</div>
);
})}
</div>
);
}
function CommentComposer({ asset, currentMs, value, onChange, onSubmit }) {
return (
<div className="comment-composer">
<div className="composer-head">
<span style={{ fontWeight: 600, fontSize: 12 }}>Reviewers</span>
<div className="avatar-stack">
{["ZG"].map(function(a, i) {
return <div key={i} className="avatar" style={{ background: avatarColor(a), width: 22, height: 22, fontSize: 10 }}>{a}</div>;
})}
<button className="add-reviewer"><Icon name="plus" size={11} /></button>
</div>
</div>
<div className="composer-tag">
<span className="badge accent">@ {msToTimecode(currentMs)}</span>
</div>
<textarea
className="composer-input"
placeholder="Leave a comment at this timecode…"
value={value}
onChange={function(e) { onChange(e.target.value); }}
onKeyDown={function(e) { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(); }}
/>
<div className="composer-bar">
<span style={{ flex: 1 }} />
<span style={{ fontSize: 10.5, color: "var(--text-3)" }}> to send</span>
<button className="btn primary sm" onClick={onSubmit} disabled={!value.trim()}>Comment</button>
</div>
</div>
);
}
function VersionsTab() {
return (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
Version history not yet available.
</div>
);
}
function MetadataTab({ asset }) {
const rows = [
{ k: "Filename", v: asset.name },
{ k: "Duration", v: asset.duration || '—' },
{ k: "Resolution", v: asset.res || '—' },
{ k: "Codec", v: asset.codec || '—' },
{ k: "File size", v: asset.size || '—' },
{ k: "Status", v: asset.status || '—' },
{ k: "Updated", v: asset.updated || '—' },
{ k: "Project", v: asset.project || '—' },
];
return (
<div className="meta-table">
{rows.map(function(r) {
return (
<div key={r.k} className="meta-row">
<div className="meta-k">{r.k}</div>
<div className="meta-v">{r.v}</div>
</div>
);
})}
</div>
);
}
function AudioTab({ asset }) {
return (
<div style={{ padding: "16px 0" }}>
<div style={{ background: "var(--bg-2)", border: "1px solid var(--border)", borderRadius: 8, padding: 16, display: "flex", flexDirection: "column", gap: 12 }}>
<Waveform seed={3} color="var(--accent)" />
<Waveform seed={7} color="var(--purple)" />
</div>
</div>
);
}
function parseDuration(d) {
if (!d || d === '—' || typeof d !== 'string') return 0;
const parts = d.split(':');
if (parts.length < 2) return 0;
const nums = parts.map(Number);
if (nums.some(isNaN)) return 0;
if (nums.length === 3) return ((nums[0] * 60 + nums[1]) * 60 + nums[2]) * 1000;
return (nums[0] * 60 + nums[1]) * 1000;
}
function msToTimecode(ms) {
const total = Math.floor((ms || 0) / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return String(h).padStart(2, "0") + ':' + String(m).padStart(2, "0") + ':' + String(s).padStart(2, "0");
}
function avatarColor(initials) {
let h = 0;
for (const c of (initials || '?')) h = (h * 31 + c.charCodeAt(0)) & 0xff;
return 'linear-gradient(135deg, hsl(' + (h % 360) + ' 60% 50%), hsl(' + ((h + 60) % 360) + ' 60% 45%))';
}
Object.assign(window, { AssetDetail, msToTimecode, parseDuration, avatarColor });