fix(playout): HLS preview path + live elapsed counter

- nginx.conf: add /media/live/ location serving from the media volume
  mount. CasparCG sidecar writes HLS preview to /media/live/<id>/ 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 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-31 13:16:57 -04:00
parent 00b04aa4a8
commit d778aa4cdb
2 changed files with 50 additions and 8 deletions

View file

@ -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/<channel_id>/. 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;

View file

@ -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 }) {
<div className="po-monitor-overlay muted">Channel stopped</div>
)}
</div>
{engine && (
<div className="po-monitor-foot mono muted">
{engine.currentClip && <span className="po-monitor-clip-name">{engine.currentClip}</span>}
<span>clip {engine.currentIndex >= 0 ? engine.currentIndex + 1 : ''} / {engine.playlistLength || 0}</span>
{engine.loop && <span> · loop</span>}
</div>
)}
<div className="po-monitor-foot mono muted">
{engine && engine.currentClip
? <span className="po-monitor-clip-name">{engine.currentClip}</span>
: <span>{onAir ? 'Idle' : 'Stopped'}</span>}
{engine && engine.currentIndex >= 0 && (
<span style={{ marginLeft: 'auto', display: 'flex', gap: 10 }}>
<span style={{ color: 'var(--success)', fontVariantNumeric: 'tabular-nums' }}>
{fmtElapsed(elapsed)}
</span>
<span>clip {engine.currentIndex + 1}/{engine.playlistLength || 0}</span>
{engine.loop && <span></span>}
</span>
)}
{engine && engine.lastError && (
<span style={{ color: 'var(--warning)', fontSize: 10, marginLeft: 6 }} title={engine.lastError}></span>
)}
</div>
</div>
);
}