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";
|
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/ {
|
location /live/ {
|
||||||
alias /live/;
|
alias /live/;
|
||||||
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
||||||
|
|
@ -69,6 +69,15 @@ server {
|
||||||
add_header Access-Control-Allow-Origin *;
|
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
|
# API proxy - forward to mam-api service
|
||||||
location /api/ {
|
location /api/ {
|
||||||
set $api_upstream http://mam-api:3000;
|
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 ──────────────────────────────────────────────────────────
|
// ── Program monitor ──────────────────────────────────────────────────────────
|
||||||
function ProgramMonitor({ channel, engine }) {
|
function ProgramMonitor({ channel, engine }) {
|
||||||
const videoRef = React.useRef(null);
|
const videoRef = React.useRef(null);
|
||||||
const hlsRef = React.useRef(null);
|
const hlsRef = React.useRef(null);
|
||||||
const onAir = channel.status === 'running';
|
const onAir = channel.status === 'running';
|
||||||
const previewUrl = `/media/live/${channel.id}/index.m3u8`;
|
const previewUrl = `/media/live/${channel.id}/index.m3u8`;
|
||||||
|
const elapsed = useElapsed(engine && engine.currentItemStartedAt);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const vid = videoRef.current;
|
const vid = videoRef.current;
|
||||||
|
|
@ -390,13 +413,23 @@ function ProgramMonitor({ channel, engine }) {
|
||||||
<div className="po-monitor-overlay muted">Channel stopped</div>
|
<div className="po-monitor-overlay muted">Channel stopped</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{engine && (
|
<div className="po-monitor-foot mono muted">
|
||||||
<div className="po-monitor-foot mono muted">
|
{engine && engine.currentClip
|
||||||
{engine.currentClip && <span className="po-monitor-clip-name">{engine.currentClip}</span>}
|
? <span className="po-monitor-clip-name">{engine.currentClip}</span>
|
||||||
<span>clip {engine.currentIndex >= 0 ? engine.currentIndex + 1 : '–'} / {engine.playlistLength || 0}</span>
|
: <span>{onAir ? 'Idle' : 'Stopped'}</span>}
|
||||||
{engine.loop && <span> · loop</span>}
|
{engine && engine.currentIndex >= 0 && (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue