fix: library + caller-only recorders + live signal indicator
Three problems blocked the end-to-end flow:
1) Library always rendered empty because /assets returns {assets,total} but
index.html (and capture.html) assumed r.data was an array. Fixed in
api.js by unwrapping r.data.assets centrally; total is kept on r.total.
2) SRT/RTMP caller mode pulled audio only. ffmpeg opened the network input
before the H264 SPS arrived, marked the video stream as pix_fmt=none,
and silently dropped it from the stream map. Added -probesize 32M
-analyzeduration 10M -fflags +genpts and explicit -map 0✌️0?/0🅰️0? so
each track survives independently of when it appears.
3) Hitting Record gave no feedback about whether a stream was actually
arriving. capture-manager now parses ffmpeg progress lines (frame=...
fps=...) and tracks framesReceived, currentFps, lastFrameAt, lastError.
getStatus() returns a derived signal enum (connecting | receiving |
lost | error | stopped). The recorder controller gives each spawned
container a stable network alias `recorder-<id>` and the GET
/recorders/:id/status endpoint proxies the live capture status through.
recorders.html polls that every 2s and renders the badge under each
active card with the running frame/fps counter or the ffmpeg error.
Also:
* recorders.html: dropped the listener-mode UI entirely. All new recorders
are caller-mode (pull). The MAM is no longer offered as an RTMP/SRT
server. Legacy listener records still render but read-only.
This commit is contained in:
parent
3154cce37c
commit
ac1878452f
4 changed files with 159 additions and 97 deletions
|
|
@ -11,6 +11,11 @@ class CaptureManager {
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
processes: {},
|
processes: {},
|
||||||
currentSession: {},
|
currentSession: {},
|
||||||
|
// Live signal metrics derived from ffmpeg stderr
|
||||||
|
framesReceived: 0,
|
||||||
|
currentFps: 0,
|
||||||
|
lastFrameAt: null,
|
||||||
|
lastError: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,7 +37,7 @@ class CaptureManager {
|
||||||
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
|
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { inputArgs: ['-i', url], isNetwork: true };
|
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceType === 'rtmp') {
|
if (sourceType === 'rtmp') {
|
||||||
|
|
@ -40,11 +45,11 @@ class CaptureManager {
|
||||||
const port = listenPort || 1935;
|
const port = listenPort || 1935;
|
||||||
const key = streamKey || 'stream';
|
const key = streamKey || 'stream';
|
||||||
return {
|
return {
|
||||||
inputArgs: ['-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
|
inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
|
||||||
isNetwork: true,
|
isNetwork: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { inputArgs: ['-i', sourceUrl], isNetwork: true };
|
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: SDI via DeckLink
|
// Default: SDI via DeckLink
|
||||||
|
|
@ -105,6 +110,8 @@ class CaptureManager {
|
||||||
// ProRes hires — fragmented moov for pipe-safe output on network sources
|
// ProRes hires — fragmented moov for pipe-safe output on network sources
|
||||||
const hiresCodecArgs = isNetwork
|
const hiresCodecArgs = isNetwork
|
||||||
? [
|
? [
|
||||||
|
'-map', '0:v:0?',
|
||||||
|
'-map', '0:a:0?',
|
||||||
'-c:v', 'prores_ks',
|
'-c:v', 'prores_ks',
|
||||||
'-profile:v', '3',
|
'-profile:v', '3',
|
||||||
'-c:a', 'pcm_s24le',
|
'-c:a', 'pcm_s24le',
|
||||||
|
|
@ -133,7 +140,19 @@ class CaptureManager {
|
||||||
const uploads = { hires: hiresUpload };
|
const uploads = { hires: hiresUpload };
|
||||||
|
|
||||||
hiresProcess.stderr.on('data', (data) => {
|
hiresProcess.stderr.on('data', (data) => {
|
||||||
console.error(`[HIRES] ${data}`);
|
const text = data.toString();
|
||||||
|
console.error(`[HIRES] ${text}`);
|
||||||
|
// Track stream signal: ffmpeg prints "frame= 123 fps= 30 ..." every ~1s
|
||||||
|
const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
|
||||||
|
if (m) {
|
||||||
|
this.state.framesReceived = parseInt(m[1], 10);
|
||||||
|
this.state.currentFps = parseFloat(m[2]);
|
||||||
|
this.state.lastFrameAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
// Surface fatal-looking lines for the status endpoint
|
||||||
|
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
|
||||||
|
this.state.lastError = text.trim().slice(0, 240);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// SDI only: spawn a second FFmpeg process for the proxy.
|
// SDI only: spawn a second FFmpeg process for the proxy.
|
||||||
|
|
@ -166,6 +185,10 @@ class CaptureManager {
|
||||||
this.state.recording = true;
|
this.state.recording = true;
|
||||||
this.state.sessionId = sessionId;
|
this.state.sessionId = sessionId;
|
||||||
this.state.processes = processes;
|
this.state.processes = processes;
|
||||||
|
this.state.framesReceived = 0;
|
||||||
|
this.state.currentFps = 0;
|
||||||
|
this.state.lastFrameAt = null;
|
||||||
|
this.state.lastError = null;
|
||||||
this.state.currentSession = {
|
this.state.currentSession = {
|
||||||
sessionId,
|
sessionId,
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -254,6 +277,14 @@ class CaptureManager {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const duration = Math.round((now - startTime) / 1000);
|
const duration = Math.round((now - startTime) / 1000);
|
||||||
|
|
||||||
|
const lastFrameAt = this.state.lastFrameAt;
|
||||||
|
const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
|
||||||
|
let signal = 'connecting';
|
||||||
|
if (this.state.framesReceived > 0) {
|
||||||
|
signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
|
||||||
|
} else if (this.state.lastError) {
|
||||||
|
signal = 'error';
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
recording: true,
|
recording: true,
|
||||||
sessionId: this.state.sessionId,
|
sessionId: this.state.sessionId,
|
||||||
|
|
@ -264,6 +295,12 @@ class CaptureManager {
|
||||||
binId: this.state.currentSession.binId,
|
binId: this.state.currentSession.binId,
|
||||||
duration,
|
duration,
|
||||||
startedAt: this.state.currentSession.startedAt,
|
startedAt: this.state.currentSession.startedAt,
|
||||||
|
signal,
|
||||||
|
framesReceived: this.state.framesReceived,
|
||||||
|
currentFps: this.state.currentFps,
|
||||||
|
lastFrameAt,
|
||||||
|
msSinceFrame,
|
||||||
|
lastError: this.state.lastError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,8 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build container config
|
// Build container config
|
||||||
|
// Stable network alias so mam-api can fetch live capture status by id
|
||||||
|
const alias = `recorder-${id}`;
|
||||||
const containerConfig = {
|
const containerConfig = {
|
||||||
Image: 'wild-dragon-capture:latest',
|
Image: 'wild-dragon-capture:latest',
|
||||||
Env: env,
|
Env: env,
|
||||||
|
|
@ -249,7 +251,12 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
NetworkMode: dockerNetwork,
|
NetworkMode: dockerNetwork,
|
||||||
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
||||||
},
|
},
|
||||||
Hostname: `recorder-${recorder.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`,
|
NetworkingConfig: {
|
||||||
|
EndpointsConfig: {
|
||||||
|
[dockerNetwork]: { Aliases: [alias] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Hostname: alias,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create container
|
// Create container
|
||||||
|
|
@ -396,10 +403,28 @@ router.get('/:id/status', async (req, res, next) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const duration = Math.floor((now - startedAt) / 1000);
|
const duration = Math.floor((now - startedAt) / 1000);
|
||||||
|
|
||||||
|
// Try to fetch live signal from the recorder's capture sidecar via network alias
|
||||||
|
let signal = container.State.Running ? 'connecting' : 'stopped';
|
||||||
|
let live = null;
|
||||||
|
try {
|
||||||
|
const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
|
||||||
|
if (captureRes.ok) {
|
||||||
|
live = await captureRes.json();
|
||||||
|
if (live && live.signal) signal = live.signal;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Container may not be ready yet, or alias hasn't propagated. Leave signal as default.
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: container.State.Running ? 'recording' : 'stopped',
|
status: container.State.Running ? 'recording' : 'stopped',
|
||||||
duration,
|
duration,
|
||||||
containerId: recorder.container_id,
|
containerId: recorder.container_id,
|
||||||
|
signal,
|
||||||
|
framesReceived: live ? live.framesReceived : null,
|
||||||
|
currentFps: live ? live.currentFps : null,
|
||||||
|
lastFrameAt: live ? live.lastFrameAt : null,
|
||||||
|
lastError: live ? live.lastError : null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,14 @@ async function captureApi(path, options = {}) {
|
||||||
async function getAssets(filters = {}) {
|
async function getAssets(filters = {}) {
|
||||||
const params = new URLSearchParams(filters);
|
const params = new URLSearchParams(filters);
|
||||||
const path = `/assets${params.toString() ? '?' + params.toString() : ''}`;
|
const path = `/assets${params.toString() ? '?' + params.toString() : ''}`;
|
||||||
return api(path);
|
const r = await api(path);
|
||||||
|
// API returns { assets, total }. Callers expect r.data to be the array,
|
||||||
|
// so unwrap here — but preserve total as a sibling for any caller that wants it.
|
||||||
|
if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) {
|
||||||
|
r.total = r.data.total;
|
||||||
|
r.data = r.data.assets;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAsset(assetId) {
|
async function getAsset(assetId) {
|
||||||
|
|
@ -253,7 +260,12 @@ async function stopRecording() {
|
||||||
* Get recent captures — uses assets API ordered by created_at desc.
|
* Get recent captures — uses assets API ordered by created_at desc.
|
||||||
*/
|
*/
|
||||||
async function getRecentCaptures(limit = 10) {
|
async function getRecentCaptures(limit = 10) {
|
||||||
return api(`/assets?limit=${limit}`);
|
const r = await api(`/assets?limit=${limit}`);
|
||||||
|
if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) {
|
||||||
|
r.total = r.data.total;
|
||||||
|
r.data = r.data.assets;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Not available in current capture service — returns empty */
|
/** Not available in current capture service — returns empty */
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Recorders — Wild Dragon</title>
|
<title>Recorders — Z-AMPP</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="css/common.css">
|
<link rel="stylesheet" href="css/common.css?v=3">
|
||||||
<style>
|
<style>
|
||||||
/* Recorder grid */
|
/* Recorder grid */
|
||||||
.recorder-grid {
|
.recorder-grid {
|
||||||
|
|
@ -191,10 +191,8 @@
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<nav class="sidebar" aria-label="Main navigation">
|
<nav class="sidebar" aria-label="Main navigation">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<div class="sidebar-brand-mark">
|
<img src="img/dragon-mark.png" alt="Z-AMPP" class="sidebar-logo">
|
||||||
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M8 1L2 5v6l6 4 6-4V5L8 1zm0 2.2L12 6v4l-4 2.7L4 10V6l4-2.8z"/></svg>
|
<span class="sidebar-brand-name">Z-AMPP</span>
|
||||||
</div>
|
|
||||||
<span class="sidebar-brand-name">Wild Dragon</span>
|
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<a href="index.html" class="nav-item">
|
<a href="index.html" class="nav-item">
|
||||||
|
|
@ -298,9 +296,9 @@
|
||||||
<label class="form-label" for="recResolution">Resolution</label>
|
<label class="form-label" for="recResolution">Resolution</label>
|
||||||
<select id="recResolution">
|
<select id="recResolution">
|
||||||
<option value="native">Native (source)</option>
|
<option value="native">Native (source)</option>
|
||||||
<option value="1920x1080">1920x1080</option>
|
<option value="1920x1080">1920×1080</option>
|
||||||
<option value="1280x720">1280x720</option>
|
<option value="1280x720">1280×720</option>
|
||||||
<option value="3840x2160">3840x2160</option>
|
<option value="3840x2160">3840×2160</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -361,11 +359,27 @@
|
||||||
|
|
||||||
<script src="js/api.js"></script>
|
<script src="js/api.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const pState = { recorders: [], timers: {}, sourceType: 'srt', mode: 'listener', projects: [] };
|
const pState = { recorders: [], timers: {}, sourceType: 'srt', mode: 'caller', projects: [], signals: {} };
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
await Promise.all([loadRecorders(), loadProjects()]);
|
await Promise.all([loadRecorders(), loadProjects()]);
|
||||||
setInterval(loadRecorders, 5000);
|
setInterval(loadRecorders, 5000);
|
||||||
|
setInterval(pollRecordingSignals, 2000);
|
||||||
|
|
||||||
|
// Poll live signal info for every recorder currently in `recording` state
|
||||||
|
async function pollRecordingSignals() {
|
||||||
|
const active = pState.recorders.filter(r => r.status === "recording");
|
||||||
|
await Promise.all(active.map(async (rec) => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/v1/recorders/${rec.id}/status`, { credentials: "include" });
|
||||||
|
if (resp.ok) {
|
||||||
|
const j = await resp.json();
|
||||||
|
pState.signals[rec.id] = j;
|
||||||
|
updateSignalBadge(rec.id, j);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('newRecorderBtn').onclick = openPanel;
|
document.getElementById('newRecorderBtn').onclick = openPanel;
|
||||||
document.getElementById('closePanelBtn').onclick = closePanel;
|
document.getElementById('closePanelBtn').onclick = closePanel;
|
||||||
|
|
@ -378,7 +392,7 @@
|
||||||
updateSourceFields();
|
updateSourceFields();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load / render
|
// ── Load / render ─────────────────────────
|
||||||
async function loadRecorders() {
|
async function loadRecorders() {
|
||||||
const r = await getRecorders();
|
const r = await getRecorders();
|
||||||
if (!r.success) return;
|
if (!r.success) return;
|
||||||
|
|
@ -404,26 +418,27 @@
|
||||||
const statusDotClass = isRecording ? 'status-dot--recording' : rec.status === 'error' ? 'status-dot--error' : 'status-dot--idle';
|
const statusDotClass = isRecording ? 'status-dot--recording' : rec.status === 'error' ? 'status-dot--error' : 'status-dot--idle';
|
||||||
|
|
||||||
let sourceDisplay = '';
|
let sourceDisplay = '';
|
||||||
if (cfg.mode === 'listener') {
|
if (cfg.url) {
|
||||||
const port = cfg.listen_port || (sourceTypeKey === 'srt' ? 9000 : 1935);
|
|
||||||
sourceDisplay = `Listen :${port}`;
|
|
||||||
} else if (cfg.url) {
|
|
||||||
sourceDisplay = cfg.url;
|
sourceDisplay = cfg.url;
|
||||||
} else if (cfg.device !== undefined) {
|
} else if (cfg.device !== undefined) {
|
||||||
sourceDisplay = `DeckLink ${cfg.device}`;
|
sourceDisplay = `DeckLink ${cfg.device}`;
|
||||||
|
} else if (cfg.mode === 'listener') {
|
||||||
|
// legacy/listener data - display read-only but no longer creatable from UI
|
||||||
|
const port = cfg.listen_port || (sourceTypeKey === 'srt' ? 49001 : 41936);
|
||||||
|
sourceDisplay = `(legacy listener :${port})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let connectBanner = '';
|
let connectBanner = '';
|
||||||
if (!isRecording && cfg.mode === 'listener') {
|
if (!isRecording && cfg.mode === 'listener') {
|
||||||
const serverIp = location.hostname || '10.0.0.25';
|
const serverIp = location.hostname || '10.0.0.25';
|
||||||
if (sourceTypeKey === 'srt') {
|
if (sourceTypeKey === 'srt') {
|
||||||
const port = cfg.listen_port || 9000;
|
const port = cfg.listen_port || 49001;
|
||||||
connectBanner = `<div class="info-banner recorder-connect-info">
|
connectBanner = `<div class="info-banner recorder-connect-info">
|
||||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
|
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
|
||||||
<span>Push to <code>srt://${serverIp}:${port}?mode=caller</code></span>
|
<span>Push to <code>srt://${serverIp}:${port}?mode=caller</code></span>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else if (sourceTypeKey === 'rtmp') {
|
} else if (sourceTypeKey === 'rtmp') {
|
||||||
const port = cfg.listen_port || 1935;
|
const port = cfg.listen_port || 41936;
|
||||||
const key = cfg.stream_key || 'stream';
|
const key = cfg.stream_key || 'stream';
|
||||||
connectBanner = `<div class="info-banner recorder-connect-info">
|
connectBanner = `<div class="info-banner recorder-connect-info">
|
||||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
|
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
|
||||||
|
|
@ -457,6 +472,7 @@
|
||||||
</span>
|
</span>
|
||||||
${isRecording ? `<span class="recorder-timer" id="timer-${rec.id}">00:00:00</span>` : ''}
|
${isRecording ? `<span class="recorder-timer" id="timer-${rec.id}">00:00:00</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${isRecording ? `<div class="recorder-status-row" style="font-size:var(--text-xs);"><span id="signal-${rec.id}" style="color:var(--text-tertiary)">Connecting…</span></div>` : ''}
|
||||||
|
|
||||||
<div class="recorder-source">
|
<div class="recorder-source">
|
||||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M2 4h10M2 8h7M2 12h5"/></svg>
|
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M2 4h10M2 8h7M2 12h5"/></svg>
|
||||||
|
|
@ -480,6 +496,7 @@
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// Start timers for recording recorders
|
||||||
pState.recorders.filter(r => r.status === 'recording').forEach(rec => {
|
pState.recorders.filter(r => r.status === 'recording').forEach(rec => {
|
||||||
if (!pState.timers[rec.id]) {
|
if (!pState.timers[rec.id]) {
|
||||||
const startedAt = rec.started_at ? new Date(rec.started_at) : new Date();
|
const startedAt = rec.started_at ? new Date(rec.started_at) : new Date();
|
||||||
|
|
@ -498,12 +515,35 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateSignalBadge(rid, st) {
|
||||||
|
const el = document.getElementById(`signal-${rid}`);
|
||||||
|
if (!el) return;
|
||||||
|
const sig = st.signal || "connecting";
|
||||||
|
const labels = {
|
||||||
|
connecting: "Connecting…",
|
||||||
|
receiving: `Receiving • ${st.framesReceived||0} fr • ${Math.round(st.currentFps||0)} fps`,
|
||||||
|
lost: "No signal — stream dropped",
|
||||||
|
error: "Connection error",
|
||||||
|
stopped: "Stopped",
|
||||||
|
};
|
||||||
|
const colors = {
|
||||||
|
connecting: "var(--status-yellow, oklch(82% 0.15 90))",
|
||||||
|
receiving: "var(--status-green, oklch(68% 0.18 148))",
|
||||||
|
lost: "var(--status-red, oklch(62% 0.22 25))",
|
||||||
|
error: "var(--status-red, oklch(62% 0.22 25))",
|
||||||
|
stopped: "var(--text-tertiary)",
|
||||||
|
};
|
||||||
|
el.textContent = labels[sig] || sig;
|
||||||
|
el.style.color = colors[sig] || "var(--text-tertiary)";
|
||||||
|
el.title = st.lastError || "";
|
||||||
|
}
|
||||||
|
|
||||||
function formatDur(s) {
|
function formatDur(s) {
|
||||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||||
return [h, m, sec].map(v => String(v).padStart(2,'0')).join(':');
|
return [h, m, sec].map(v => String(v).padStart(2,'0')).join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Controls
|
// ── Controls ──────────────────────────────
|
||||||
async function handleStart(id) {
|
async function handleStart(id) {
|
||||||
const r = await startRecorder(id);
|
const r = await startRecorder(id);
|
||||||
if (r.success) { toast('Recording started', '', 'success'); loadRecorders(); }
|
if (r.success) { toast('Recording started', '', 'success'); loadRecorders(); }
|
||||||
|
|
@ -523,7 +563,7 @@
|
||||||
else toast('Delete failed', r.error, 'error');
|
else toast('Delete failed', r.error, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Panel
|
// ── Panel ─────────────────────────────────
|
||||||
function openPanel() {
|
function openPanel() {
|
||||||
document.getElementById('recorderPanel').classList.add('open');
|
document.getElementById('recorderPanel').classList.add('open');
|
||||||
document.getElementById('panelOverlay').classList.add('open');
|
document.getElementById('panelOverlay').classList.add('open');
|
||||||
|
|
@ -535,10 +575,10 @@
|
||||||
document.getElementById('panelOverlay').classList.remove('open');
|
document.getElementById('panelOverlay').classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source type
|
// ── Source type ───────────────────────────
|
||||||
function setSourceType(type) {
|
function setSourceType(type) {
|
||||||
pState.sourceType = type;
|
pState.sourceType = type;
|
||||||
pState.mode = 'listener';
|
pState.mode = 'caller';
|
||||||
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === type));
|
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === type));
|
||||||
updateSourceFields();
|
updateSourceFields();
|
||||||
}
|
}
|
||||||
|
|
@ -560,31 +600,15 @@
|
||||||
|
|
||||||
} else if (type === 'srt') {
|
} else if (type === 'srt') {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="form-group">
|
<div id="srtCallerFields">
|
||||||
<label class="form-label">Mode</label>
|
|
||||||
<div class="mode-row">
|
|
||||||
<button class="mode-btn active" data-mode="listener" onclick="setMode('listener')">Listener - encoder pushes here</button>
|
|
||||||
<button class="mode-btn" data-mode="caller" onclick="setMode('caller')">Caller - pull from source</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="srtListenerFields">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="srtPort">Listen port (UDP)</label>
|
|
||||||
<input type="number" id="srtPort" value="9000" min="1024" max="65535">
|
|
||||||
<div class="form-hint">Encoders connect to this port on the server</div>
|
|
||||||
</div>
|
|
||||||
<div id="srtConnectInfo" class="info-banner">
|
|
||||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
|
|
||||||
<span>Encoder connect string: <code id="srtConnectStr">srt://10.0.0.25:9000?mode=caller</code></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="srtCallerFields" style="display:none;">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="srtUrl">Source URL</label>
|
<label class="form-label" for="srtUrl">Source URL</label>
|
||||||
<input type="url" id="srtUrl" placeholder="srt://192.168.1.100:4200?mode=caller">
|
<input type="url" id="srtUrl" placeholder="srt://192.168.1.100:4200">
|
||||||
|
<div class="form-hint">The recorder will connect out to this URL (caller mode). <code>?mode=caller</code> is appended automatically.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// Wire port input to update banner
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const portIn = document.getElementById('srtPort');
|
const portIn = document.getElementById('srtPort');
|
||||||
if (portIn) portIn.addEventListener('input', () => {
|
if (portIn) portIn.addEventListener('input', () => {
|
||||||
|
|
@ -595,33 +619,11 @@
|
||||||
|
|
||||||
} else if (type === 'rtmp') {
|
} else if (type === 'rtmp') {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="form-group">
|
<div id="rtmpCallerFields">
|
||||||
<label class="form-label">Mode</label>
|
|
||||||
<div class="mode-row">
|
|
||||||
<button class="mode-btn active" data-mode="listener" onclick="setMode('listener')">Listener - encoder pushes here</button>
|
|
||||||
<button class="mode-btn" data-mode="caller" onclick="setMode('caller')">Caller - pull from source</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="rtmpListenerFields">
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="rtmpPort">Listen port (TCP)</label>
|
|
||||||
<input type="number" id="rtmpPort" value="1935" min="1024" max="65535">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="rtmpKey">Stream key</label>
|
|
||||||
<input type="text" id="rtmpKey" value="stream" placeholder="stream">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="rtmpConnectInfo" class="info-banner">
|
|
||||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
|
|
||||||
<span>Push to: <code id="rtmpConnectStr">rtmp://10.0.0.25:1935/live/stream</code></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="rtmpCallerFields" style="display:none;">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="rtmpUrl">Source URL</label>
|
<label class="form-label" for="rtmpUrl">Source URL</label>
|
||||||
<input type="url" id="rtmpUrl" placeholder="rtmp://192.168.1.100/live/key">
|
<input type="url" id="rtmpUrl" placeholder="rtmp://server/live/streamkey">
|
||||||
|
<div class="form-hint">The recorder will pull this RTMP stream. Must be an existing published stream on an RTMP server.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
|
@ -630,7 +632,7 @@
|
||||||
const keyIn = document.getElementById('rtmpKey');
|
const keyIn = document.getElementById('rtmpKey');
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const el = document.getElementById('rtmpConnectStr');
|
const el = document.getElementById('rtmpConnectStr');
|
||||||
if (el) el.textContent = `rtmp://${location.hostname || '10.0.0.25'}:${portIn?.value || 1935}/live/${keyIn?.value || 'stream'}`;
|
if (el) el.textContent = `rtmp://${location.hostname || '10.0.0.25'}:${portIn?.value || 41936}/live/${keyIn?.value || 'stream'}`;
|
||||||
};
|
};
|
||||||
portIn?.addEventListener('input', update);
|
portIn?.addEventListener('input', update);
|
||||||
keyIn?.addEventListener('input', update);
|
keyIn?.addEventListener('input', update);
|
||||||
|
|
@ -638,20 +640,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMode(mode) {
|
function setMode(_mode) {
|
||||||
pState.mode = mode;
|
// Listener mode UI was removed - all recorders are caller (pull) mode now.
|
||||||
document.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
|
pState.mode = 'caller';
|
||||||
const type = pState.sourceType;
|
|
||||||
if (type === 'srt') {
|
|
||||||
document.getElementById('srtListenerFields').style.display = mode === 'listener' ? '' : 'none';
|
|
||||||
document.getElementById('srtCallerFields').style.display = mode === 'caller' ? '' : 'none';
|
|
||||||
} else if (type === 'rtmp') {
|
|
||||||
document.getElementById('rtmpListenerFields').style.display = mode === 'listener' ? '' : 'none';
|
|
||||||
document.getElementById('rtmpCallerFields').style.display = mode === 'caller' ? '' : 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Projects for recorder destination
|
// ── Projects for recorder destination ─────
|
||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
const r = await getProjects();
|
const r = await getProjects();
|
||||||
if (!r.success) return;
|
if (!r.success) return;
|
||||||
|
|
@ -670,7 +664,7 @@
|
||||||
if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`);
|
if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save recorder
|
// ── Save recorder ─────────────────────────
|
||||||
async function handleSaveRecorder() {
|
async function handleSaveRecorder() {
|
||||||
const name = document.getElementById('recName').value.trim();
|
const name = document.getElementById('recName').value.trim();
|
||||||
if (!name) { toast('Enter a recorder name', '', 'warning'); return; }
|
if (!name) { toast('Enter a recorder name', '', 'warning'); return; }
|
||||||
|
|
@ -686,18 +680,12 @@
|
||||||
if (type === 'sdi') {
|
if (type === 'sdi') {
|
||||||
sourceConfig.device = parseInt(document.getElementById('sdiDevice')?.value || '0');
|
sourceConfig.device = parseInt(document.getElementById('sdiDevice')?.value || '0');
|
||||||
} else if (type === 'srt') {
|
} else if (type === 'srt') {
|
||||||
sourceConfig.mode = mode;
|
sourceConfig.mode = 'caller';
|
||||||
if (mode === 'listener') sourceConfig.listen_port = parseInt(document.getElementById('srtPort')?.value || '9000');
|
sourceConfig.url = document.getElementById('srtUrl')?.value;
|
||||||
else sourceConfig.url = document.getElementById('srtUrl')?.value;
|
|
||||||
} else if (type === 'rtmp') {
|
} else if (type === 'rtmp') {
|
||||||
sourceConfig.mode = mode;
|
sourceConfig.mode = 'caller';
|
||||||
if (mode === 'listener') {
|
|
||||||
sourceConfig.listen_port = parseInt(document.getElementById('rtmpPort')?.value || '1935');
|
|
||||||
sourceConfig.stream_key = document.getElementById('rtmpKey')?.value || 'stream';
|
|
||||||
} else {
|
|
||||||
sourceConfig.url = document.getElementById('rtmpUrl')?.value;
|
sourceConfig.url = document.getElementById('rtmpUrl')?.value;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const proxy = document.getElementById('proxyToggle').checked;
|
const proxy = document.getElementById('proxyToggle').checked;
|
||||||
const proxyConfig = proxy ? {
|
const proxyConfig = proxy ? {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue