diff --git a/services/web-ui/public/screens-asset.jsx b/services/web-ui/public/screens-asset.jsx
index 165f3fa..0146b36 100644
--- a/services/web-ui/public/screens-asset.jsx
+++ b/services/web-ui/public/screens-asset.jsx
@@ -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 (
@@ -81,32 +152,63 @@ function AssetDetail({ asset, onClose }) {
-
-
- {!playing && totalMs > 0 && (
-
- )}
- {totalMs <= 0 && (
-
-
-
- {asset.status === 'processing' ? 'Processing…' : asset.status === 'live' ? 'Live recording in progress' : 'Preview not yet available'}
+ {streamUrl ? (
+
+ ) : (
+
+
+
+
+
+ {streamLoading ? (
+
Loading…
+ ) : (
+
+ {statusMessage}
+
+ {asset.status === 'error' && (
+
+ )}
+
+ )}
-
-
+ {!streamLoading && !playing && totalMs > 0 && (
+
+ )}
+
)}
+
{visibleComments
- .filter(c => Math.abs(parseDuration(c.time) - currentMs) < 200)
- .map(c => (
-
-
{c.avatar}
-
{c.text}
-
- ))}
+ .filter(function(c) { return Math.abs(parseDuration(c.time) - currentMs) < 200; })
+ .map(function(c) {
+ return (
+
+
{c.avatar}
+
{c.text}
+
+ );
+ })}
{asset.status === "live" && (
@@ -123,7 +225,7 @@ function AssetDetail({ asset, onClose }) {
{totalMs > 0 && (
-
{tab === "comments" && (
-
seek(parseDuration(c.time))} onResolve={(id) => {
- setComments(cs => cs.map(c => c.id === id ? { ...c, resolved: !c.resolved } : c));
- }} />
+
)}
{tab === "versions" && }
{tab === "metadata" && }
@@ -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 (
-
-
- {comments.map(c => {
+
+
+ {comments.map(function(c) {
const ct = parseDuration(c.time);
if (!ct || total <= 0) return null;
const x = (ct / total) * 100;
return (
{ e.stopPropagation(); onSeek(ct); }}
+ className={'playback-marker ' + (c.resolved ? 'resolved' : '')}
+ style={{ left: x + '%' }}
+ onClick={function(e) { e.stopPropagation(); onSeek(ct); }}
>
{c.avatar}
@@ -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 (
- {Array.from({ length: frames }).map((_, i) => (
-
-
-
- ))}
-
- {comments.map(c => {
+ {Array.from({ length: frames }).map(function(_, i) {
+ return (
+
+
+
+ );
+ })}
+
+ {comments.map(function(c) {
const ct = parseDuration(c.time);
if (!ct || total <= 0) return null;
const x = (ct / total) * 100;
return (
-
- {[0, 0.25, 0.5, 0.75, 1].map(p => (
- {msToTimecode(p * total)}
- ))}
+ {[0, 0.25, 0.5, 0.75, 1].map(function(p) {
+ return {msToTimecode(p * total)};
+ })}
);
@@ -262,24 +370,26 @@ function CommentsList({ comments, onSeek, onResolve }) {
}
return (
- {comments.map(c => (
-
-
{c.avatar}
-
-
-
{c.who}
-
onSeek(c)}>{c.time}
-
{c.real}
-
-
onResolve(c.id)}>
-
-
+ {comments.map(function(c) {
+ return (
+
+
{c.avatar}
+
+
+
{c.who}
+
{c.time}
+
{c.real}
+
+
+
+
+
+
{c.text}
+ {c.resolved &&
✓ Resolved
}
-
{c.text}
- {c.resolved &&
✓ Resolved
}
-
- ))}
+ );
+ })}
);
}
@@ -290,9 +400,9 @@ function CommentComposer({ asset, currentMs, value, onChange, onSubmit }) {
Reviewers
- {["ZG"].map((a, i) => (
-
{a}
- ))}
+ {["ZG"].map(function(a, i) {
+ return
{a}
;
+ })}
@@ -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(); }}
/>
@@ -336,12 +446,14 @@ function MetadataTab({ asset }) {
];
return (
- {rows.map(r => (
-
- ))}
+ {rows.map(function(r) {
+ return (
+
+ );
+ })}
);
}
@@ -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 });