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
This commit is contained in:
parent
e3c3d60103
commit
6f2de45819
1 changed files with 209 additions and 97 deletions
|
|
@ -25,38 +25,109 @@ function AssetDetail({ asset, onClose }) {
|
|||
const [comments, setComments] = React.useState(SEED_COMMENTS || []);
|
||||
const [newComment, setNewComment] = React.useState("");
|
||||
|
||||
const totalMs = parseDuration(asset.duration);
|
||||
// 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 (!playing || totalMs <= 0) return;
|
||||
const i = setInterval(() => {
|
||||
setCurrentMs(t => {
|
||||
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 () => clearInterval(i);
|
||||
}, [playing, totalMs]);
|
||||
return function() { clearInterval(i); };
|
||||
}, [playing, totalMs, streamUrl]);
|
||||
|
||||
const seek = (ms) => setCurrentMs(Math.max(0, Math.min(totalMs || 0, ms)));
|
||||
const addComment = () => {
|
||||
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(c => [...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),
|
||||
}]);
|
||||
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(c => showResolved || !c.resolved);
|
||||
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">
|
||||
|
|
@ -81,32 +152,63 @@ function AssetDetail({ asset, onClose }) {
|
|||
<div className="player-col">
|
||||
<div className="player">
|
||||
<div className="player-canvas">
|
||||
<FauxFrame seed={asset.seed || 1} />
|
||||
<div className="scanlines" />
|
||||
{!playing && totalMs > 0 && (
|
||||
<button className="player-play-overlay" onClick={() => setPlaying(true)}>
|
||||
<Icon name="play" size={28} />
|
||||
</button>
|
||||
)}
|
||||
{totalMs <= 0 && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
<div style={{ fontSize: 13, marginBottom: 8 }}>
|
||||
{asset.status === 'processing' ? 'Processing…' : asset.status === 'live' ? 'Live recording in progress' : 'Preview not yet available'}
|
||||
{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>
|
||||
<StatusDot status={asset.status} />
|
||||
</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(c => Math.abs(parseDuration(c.time) - currentMs) < 200)
|
||||
.map(c => (
|
||||
<div key={c.id} className="player-pin">
|
||||
<div className="player-pin-avatar">{c.avatar}</div>
|
||||
<div className="player-pin-bubble">{c.text}</div>
|
||||
</div>
|
||||
))}
|
||||
.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 }}>
|
||||
|
|
@ -123,7 +225,7 @@ function AssetDetail({ asset, onClose }) {
|
|||
|
||||
{totalMs > 0 && (
|
||||
<div className="player-controls">
|
||||
<button className="icon-btn" onClick={() => setPlaying(p => !p)}>
|
||||
<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>
|
||||
|
|
@ -141,31 +243,35 @@ function AssetDetail({ asset, onClose }) {
|
|||
</div>
|
||||
|
||||
<div className="asset-detail-tabs">
|
||||
<button className={tab === "comments" ? "active" : ""} onClick={() => setTab("comments")}>
|
||||
<button className={tab === "comments" ? "active" : ""} onClick={function() { setTab("comments"); }}>
|
||||
Comments <span className="count">{comments.length}</span>
|
||||
</button>
|
||||
<button className={tab === "versions" ? "active" : ""} onClick={() => setTab("versions")}>
|
||||
<button className={tab === "versions" ? "active" : ""} onClick={function() { setTab("versions"); }}>
|
||||
Versions
|
||||
</button>
|
||||
<button className={tab === "metadata" ? "active" : ""} onClick={() => setTab("metadata")}>
|
||||
<button className={tab === "metadata" ? "active" : ""} onClick={function() { setTab("metadata"); }}>
|
||||
Metadata
|
||||
</button>
|
||||
<button className={tab === "audio" ? "active" : ""} onClick={() => setTab("audio")}>
|
||||
<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={e => setShowResolved(e.target.checked)} /> Show resolved
|
||||
<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={(c) => seek(parseDuration(c.time))} onResolve={(id) => {
|
||||
setComments(cs => cs.map(c => c.id === id ? { ...c, resolved: !c.resolved } : c));
|
||||
}} />
|
||||
<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} />}
|
||||
|
|
@ -189,7 +295,7 @@ function AssetDetail({ asset, onClose }) {
|
|||
|
||||
function PlaybackBar({ current, total, onSeek, comments }) {
|
||||
const ref = React.useRef(null);
|
||||
const handle = (e) => {
|
||||
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);
|
||||
|
|
@ -197,18 +303,18 @@ function PlaybackBar({ current, total, onSeek, comments }) {
|
|||
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(c => {
|
||||
<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={(e) => { e.stopPropagation(); onSeek(ct); }}
|
||||
className={'playback-marker ' + (c.resolved ? 'resolved' : '')}
|
||||
style={{ left: x + '%' }}
|
||||
onClick={function(e) { e.stopPropagation(); onSeek(ct); }}
|
||||
>
|
||||
<span className="marker-avatar">{c.avatar}</span>
|
||||
</div>
|
||||
|
|
@ -221,7 +327,7 @@ function PlaybackBar({ current, total, onSeek, comments }) {
|
|||
function FilmStrip({ seed, current, total, onSeek, comments }) {
|
||||
const ref = React.useRef(null);
|
||||
const frames = 28;
|
||||
const handle = (e) => {
|
||||
const handle = function(e) {
|
||||
if (!ref.current || total <= 0) return;
|
||||
const r = ref.current.getBoundingClientRect();
|
||||
onSeek(((e.clientX - r.left) / r.width) * total);
|
||||
|
|
@ -230,27 +336,29 @@ function FilmStrip({ seed, current, total, onSeek, comments }) {
|
|||
return (
|
||||
<div className="filmstrip-wrap">
|
||||
<div className="filmstrip" ref={ref} onClick={handle}>
|
||||
{Array.from({ length: frames }).map((_, i) => (
|
||||
<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(c => {
|
||||
{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}%` }}>
|
||||
<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(p => (
|
||||
<span key={p} className="mono">{msToTimecode(p * total)}</span>
|
||||
))}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map(function(p) {
|
||||
return <span key={p} className="mono">{msToTimecode(p * total)}</span>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -262,24 +370,26 @@ function CommentsList({ comments, onSeek, onResolve }) {
|
|||
}
|
||||
return (
|
||||
<div className="comments-list">
|
||||
{comments.map(c => (
|
||||
<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={() => 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={() => onResolve(c.id)}>
|
||||
<Icon name={c.resolved ? "refresh" : "check"} size={12} />
|
||||
</button>
|
||||
{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 className="comment-text">{c.text}</div>
|
||||
{c.resolved && <div className="comment-resolved">✓ Resolved</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -290,9 +400,9 @@ function CommentComposer({ asset, currentMs, value, onChange, onSubmit }) {
|
|||
<div className="composer-head">
|
||||
<span style={{ fontWeight: 600, fontSize: 12 }}>Reviewers</span>
|
||||
<div className="avatar-stack">
|
||||
{["ZG"].map((a, i) => (
|
||||
<div key={i} className="avatar" style={{ background: avatarColor(a), width: 22, height: 22, fontSize: 10 }}>{a}</div>
|
||||
))}
|
||||
{["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>
|
||||
|
|
@ -303,8 +413,8 @@ function CommentComposer({ asset, currentMs, value, onChange, onSubmit }) {
|
|||
className="composer-input"
|
||||
placeholder="Leave a comment at this timecode…"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(); }}
|
||||
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 }} />
|
||||
|
|
@ -336,12 +446,14 @@ function MetadataTab({ asset }) {
|
|||
];
|
||||
return (
|
||||
<div className="meta-table">
|
||||
{rows.map(r => (
|
||||
<div key={r.k} className="meta-row">
|
||||
<div className="meta-k">{r.k}</div>
|
||||
<div className="meta-v">{r.v}</div>
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -372,13 +484,13 @@ function msToTimecode(ms) {
|
|||
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")}`;
|
||||
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%))`;
|
||||
return 'linear-gradient(135deg, hsl(' + (h % 360) + ' 60% 50%), hsl(' + ((h + 60) % 360) + ' 60% 45%))';
|
||||
}
|
||||
|
||||
Object.assign(window, { AssetDetail, msToTimecode, parseDuration, avatarColor });
|
||||
|
|
|
|||
Loading…
Reference in a new issue