From d778aa4cdb66e87da633b1c074d8784e767fe7ad Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 13:16:57 -0400 Subject: [PATCH] fix(playout): HLS preview path + live elapsed counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nginx.conf: add /media/live/ location serving from the media volume mount. CasparCG sidecar writes HLS preview to /media/live// but nginx only had /live/ (capture volume). Without this, preview requests returned the SPA shell instead of the .m3u8 playlist. - ProgramMonitor: add live elapsed counter (MM:SS, ticks every 500ms) driven by engine.currentItemStartedAt. Shows alongside clip index. Adds a ⚠ pip when lastError is set (e.g. NDI SDK missing) without blocking operation. Co-Authored-By: Claude Sonnet 4.6 --- services/web-ui/nginx.conf | 11 ++++- services/web-ui/public/screens-playout.jsx | 47 ++++++++++++++++++---- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/services/web-ui/nginx.conf b/services/web-ui/nginx.conf index 621112e..2590563 100644 --- a/services/web-ui/nginx.conf +++ b/services/web-ui/nginx.conf @@ -61,7 +61,7 @@ server { add_header Cache-Control "no-cache, no-store, must-revalidate"; } - # Live HLS — served from /live (bind-mounted shared volume), low cache so playlist refreshes + # Live HLS — served from /live (bind-mounted capture live volume) location /live/ { alias /live/; types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; } @@ -69,6 +69,15 @@ server { add_header Access-Control-Allow-Origin *; } + # Playout HLS preview — CasparCG sidecar writes to the media volume under + # /media/live//. This is a separate volume from /live/ (capture). + location /media/live/ { + alias /media/live/; + types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; } + add_header Cache-Control "no-cache"; + add_header Access-Control-Allow-Origin *; + } + # API proxy - forward to mam-api service location /api/ { set $api_upstream http://mam-api:3000; diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx index f214dcb..0e906fc 100644 --- a/services/web-ui/public/screens-playout.jsx +++ b/services/web-ui/public/screens-playout.jsx @@ -345,12 +345,35 @@ function Transport({ channel, playlistId, items, onStatus }) { ); } +// ── Elapsed timer ───────────────────────────────────────────────────────────── +function useElapsed(startedAt) { + const [elapsed, setElapsed] = React.useState(0); + React.useEffect(() => { + if (!startedAt) { setElapsed(0); return; } + const base = new Date(startedAt).getTime(); + const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - base) / 1000))); + tick(); + const id = setInterval(tick, 500); + return () => clearInterval(id); + }, [startedAt]); + return elapsed; +} + +function fmtElapsed(secs) { + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = secs % 60; + return (h > 0 ? String(h).padStart(2,'0') + ':' : '') + + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0'); +} + // ── Program monitor ────────────────────────────────────────────────────────── function ProgramMonitor({ channel, engine }) { const videoRef = React.useRef(null); const hlsRef = React.useRef(null); const onAir = channel.status === 'running'; const previewUrl = `/media/live/${channel.id}/index.m3u8`; + const elapsed = useElapsed(engine && engine.currentItemStartedAt); React.useEffect(() => { const vid = videoRef.current; @@ -390,13 +413,23 @@ function ProgramMonitor({ channel, engine }) {
Channel stopped
)} - {engine && ( -
- {engine.currentClip && {engine.currentClip}} - clip {engine.currentIndex >= 0 ? engine.currentIndex + 1 : '–'} / {engine.playlistLength || 0} - {engine.loop && · loop} -
- )} +
+ {engine && engine.currentClip + ? {engine.currentClip} + : {onAir ? 'Idle' : 'Stopped'}} + {engine && engine.currentIndex >= 0 && ( + + + {fmtElapsed(elapsed)} + + clip {engine.currentIndex + 1}/{engine.playlistLength || 0} + {engine.loop && } + + )} + {engine && engine.lastError && ( + + )} +
); }