diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js
index 50108d8..de6244c 100644
--- a/services/capture/src/capture-manager.js
+++ b/services/capture/src/capture-manager.js
@@ -19,7 +19,19 @@ const GROWING_PATH = process.env.GROWING_PATH || '/growing';
// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount
// (the host-bound /growing volume is used instead, or S3 streaming if growing
// is off).
-const GROWING_SMB_MOUNT = process.env.GROWING_SMB_MOUNT || '';
+// mount.cifs needs a UNC source (//host/share). Operators (and Settings) often
+// store the share as an `smb://host/share` URL or a Windows `\\host\share`
+// path; the kernel rejects those outright ("Mounting cifs URL not implemented
+// yet"), which silently drops us back to S3. Normalize any of these forms to
+// the `//host/share` UNC the mount helper accepts.
+function toUncShare(raw) {
+ if (!raw) return '';
+ let s = String(raw).trim().replace(/\\/g, '/'); // \\host\share -> //host/share
+ s = s.replace(/^smb:\/\//i, '//'); // smb://host/share -> //host/share
+ if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, ''); // host/share -> //host/share
+ return s;
+}
+const GROWING_SMB_MOUNT = toUncShare(process.env.GROWING_SMB_MOUNT || '');
const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || '';
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';
diff --git a/services/playout/src/playout-manager.js b/services/playout/src/playout-manager.js
index c846483..abb3ba9 100644
--- a/services/playout/src/playout-manager.js
+++ b/services/playout/src/playout-manager.js
@@ -1,6 +1,6 @@
import { AmcpClient } from './amcp.js';
import { spawn } from 'node:child_process';
-import { mkdirSync } from 'node:fs';
+import { mkdirSync, readdirSync, unlinkSync } from 'node:fs';
// Playout manager — owns one CasparCG channel's lifecycle inside this sidecar.
//
@@ -211,6 +211,20 @@ export class PlayoutManager {
_startHlsRemux() {
if (!HLS_DIR) return;
try { mkdirSync(HLS_DIR, { recursive: true }); } catch (_) {}
+ // Purge stale HLS artifacts from any prior session before starting. The
+ // /media volume is a shared host bind, so a previous (or duplicate/failover)
+ // sidecar can leave orphaned index*.ts + an old index.m3u8 behind. ffmpeg's
+ // index%d.ts counter restarts at 0, so those leftovers collide with the new
+ // segment numbering and can briefly corrupt the live playlist hls.js reads
+ // (it sees a frozen / non-monotonic edge → monitor goes black). A clean dir
+ // per session guarantees a coherent live timeline.
+ try {
+ for (const f of readdirSync(HLS_DIR)) {
+ if (/\.ts$/.test(f) || /\.m3u8$/.test(f)) {
+ try { unlinkSync(`${HLS_DIR}/${f}`); } catch (_) {}
+ }
+ }
+ } catch (_) {}
this._stopHlsRemux();
const out = `${HLS_DIR}/index.m3u8`;
diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx
index 9da9262..09c8369 100644
--- a/services/web-ui/public/screens-ingest.jsx
+++ b/services/web-ui/public/screens-ingest.jsx
@@ -997,6 +997,7 @@ function Capture({ navigate }) {
/* ===== Monitors ===== */
function Monitors({ navigate }) {
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
+ const [channels, setChannels] = React.useState([]);
const [grid, setGrid] = React.useState(4);
React.useEffect(() => {
@@ -1008,6 +1009,11 @@ function Monitors({ navigate }) {
setRecorders(norm);
})
.catch(() => {});
+ // Playout channels surface here too so an operator can watch on-air
+ // output alongside ingest. Degrade silently if the endpoint is absent.
+ window.ZAMPP_API.fetch('/playout/channels')
+ .then(raw => setChannels(Array.isArray(raw) ? raw : []))
+ .catch(() => setChannels([]));
};
refresh();
const id = setInterval(refresh, 5000);
@@ -1032,18 +1038,87 @@ function Monitors({ navigate }) {
- {feeds.length === 0 ? (
-
No active feeds. Start a recorder to see live video here.
+ {feeds.length === 0 && channels.length === 0 ? (
+
No active feeds. Start a recorder or playout channel to see live video here.
) : (
-
- {feeds.map((f, i) => )}
-
+
+ {feeds.length > 0 && (
+
+ Ingest
+
+ {feeds.map((f, i) => )}
+
+
+ )}
+ {channels.length > 0 && (
+
+ Playout
+
+ {channels.slice(0, grid * grid).map(c =>
)}
+
+
+ )}
+
)}
);
}
+function PlayoutMonitorTile({ channel }) {
+ const videoRef = React.useRef(null);
+ const hlsRef = React.useRef(null);
+ const onAir = channel.status === 'running';
+ const previewUrl = '/api/v1/playout/channels/' + channel.id + '/hls/index.m3u8';
+
+ React.useEffect(() => {
+ const vid = videoRef.current;
+ if (!vid) return;
+ if (hlsRef.current) { try { hlsRef.current.destroy(); } catch (_) {} hlsRef.current = null; }
+ if (!onAir) { vid.src = ''; return; }
+
+ if (window.Hls && window.Hls.isSupported()) {
+ const hls = new window.Hls({
+ liveSyncDurationCount: 3,
+ liveMaxLatencyDurationCount: 6,
+ xhrSetup: (xhr) => { xhr.withCredentials = true; },
+ });
+ hlsRef.current = hls;
+ hls.loadSource(previewUrl);
+ hls.attachMedia(vid);
+ hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
+ } else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
+ vid.src = previewUrl;
+ vid.play().catch(() => {});
+ }
+ return () => {
+ if (hlsRef.current) { try { hlsRef.current.destroy(); } catch (_) {} hlsRef.current = null; }
+ };
+ }, [onAir, channel.id]);
+
+ return (
+
+ {onAir ? (
+
+ ) : (
+
+ )}
+ {onAir &&
}
+ {!onAir && (
+
channel idle
+ )}
+
+ {onAir ? ON AIR : IDLE}
+
+
+ {channel.name}
+ {channel.output_type && {String(channel.output_type).toUpperCase()}}
+
+
+ );
+}
+
function MonitorTile({ feed, seed }) {
const [levels, setLevels] = React.useState([0.65, 0.78]);
const isLive = feed.status === 'recording';
diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx
index d0dd9d6..cb3a858 100644
--- a/services/web-ui/public/screens-playout.jsx
+++ b/services/web-ui/public/screens-playout.jsx
@@ -392,6 +392,15 @@ function ProgramMonitor({ channel, engine }) {
const hls = new window.Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
+ // Keep hls.js pinned to the live edge. The preview is a CPU-encoded
+ // confidence monitor whose live-edge segment may still be mid-write
+ // when first fetched; a small back-buffer + tolerant stall handling
+ // lets the player skip transient gaps instead of freezing.
+ backBufferLength: 8,
+ maxBufferLength: 10,
+ liveDurationInfinity: true,
+ highBufferWatchdogPeriod: 1,
+ nudgeMaxRetry: 10,
// The playlist is served from /api/ (auth-gated); send the session
// cookie so the request authenticates. Segments are static + public.
xhrSetup: (xhr) => { xhr.withCredentials = true; },
@@ -400,6 +409,51 @@ function ProgramMonitor({ channel, engine }) {
hls.loadSource(previewUrl);
hls.attachMedia(vid);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
+
+ // Resilient recovery. Without this, the FIRST fatal hls.js error (a
+ // buffer stall on the live edge, a media/decode error, or a transient
+ // fragment/playlist load error against the rewinding live playlist)
+ // permanently halts playback and the monitor goes black — which is
+ // exactly the "flashes a frame then stays black" symptom: hls.js renders
+ // a fragment or two, hits an unrecovered error, and never resumes. We
+ // distinguish error types and recover in place rather than tearing down.
+ let recoverCount = 0;
+ hls.on(window.Hls.Events.ERROR, (_evt, data) => {
+ if (!data.fatal) {
+ // Non-fatal buffer stalls: nudge hls.js back to the live edge.
+ if (data.details === window.Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
+ try { hls.startLoad(); } catch (_) {}
+ }
+ return;
+ }
+ switch (data.type) {
+ case window.Hls.ErrorTypes.NETWORK_ERROR:
+ // Playlist/fragment load errors against the live edge are usually
+ // transient (segment rotated or mid-write). Re-arm the loader.
+ try { hls.startLoad(); } catch (_) {}
+ break;
+ case window.Hls.ErrorTypes.MEDIA_ERROR:
+ // Decode/buffer-append failures: flush + rebuild the buffer.
+ recoverCount += 1;
+ if (recoverCount <= 3) {
+ try { hls.recoverMediaError(); } catch (_) {}
+ } else {
+ // Repeated media errors: full reload of the source from scratch.
+ recoverCount = 0;
+ try { hls.destroy(); } catch (_) {}
+ if (hlsRef.current === hls) hlsRef.current = null;
+ }
+ break;
+ default:
+ // Unrecoverable: drop the instance so a re-render can re-init.
+ try { hls.destroy(); } catch (_) {}
+ if (hlsRef.current === hls) hlsRef.current = null;
+ }
+ });
+ // A stalled