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:
parent
00b04aa4a8
commit
d778aa4cdb
2 changed files with 50 additions and 8 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue