dragonflight/services/web-ui/public/recorders.html
Zac ac1878452f 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.
2026-05-17 07:39:58 -04:00

725 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recorders — Z-AMPP</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="stylesheet" href="css/common.css?v=3">
<style>
/* Recorder grid */
.recorder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--sp-4);
}
/* Recorder card */
.recorder-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-3);
transition: border-color var(--t-fast);
}
.recorder-card.recording {
border-color: var(--accent-border);
}
.recorder-card.error {
border-color: oklch(62% 0.22 25 / 0.3);
}
/* Card header */
.recorder-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
}
.recorder-id {
display: flex;
flex-direction: column;
gap: var(--sp-1);
min-width: 0;
}
.recorder-name {
font-size: var(--text-base);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recorder-badges {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.recorder-actions {
display: flex;
gap: var(--sp-1);
flex-shrink: 0;
}
/* Status row */
.recorder-status-row {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.recorder-timer {
font-size: var(--text-sm);
font-variant-numeric: tabular-nums;
color: var(--accent);
font-weight: 500;
letter-spacing: 0.02em;
}
.recorder-timer.hidden { display: none; }
/* Source info */
.recorder-source {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-xs);
color: var(--text-secondary);
}
/* Connection info banner */
.recorder-connect-info {
margin-top: var(--sp-1);
}
/* Card footer */
.recorder-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding-top: var(--sp-2);
border-top: 1px solid var(--border);
}
.recorder-footer-meta {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.recorder-controls {
display: flex;
gap: var(--sp-2);
}
/* Form sections */
.form-section-label {
font-size: var(--text-xs);
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
padding-bottom: var(--sp-3);
border-bottom: 1px solid var(--border);
}
/* Source type radio row */
.source-type-row {
display: flex;
gap: var(--sp-2);
}
.source-type-btn {
flex: 1;
padding: var(--sp-2) var(--sp-3);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
text-align: center;
transition: border-color var(--t-fast), background var(--t-fast), color var(--t-fast);
}
.source-type-btn:hover {
border-color: var(--border-strong);
color: var(--text-primary);
}
.source-type-btn.active {
border-color: var(--accent-border);
background: var(--accent-subtle);
color: var(--accent);
font-weight: 500;
}
/* Mode selector */
.mode-row {
display: flex;
gap: var(--sp-2);
}
.mode-btn {
flex: 1;
padding: var(--sp-2) var(--sp-3);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--t-fast);
}
.mode-btn:hover { border-color: var(--border-strong); color: var(--text-primary); }
.mode-btn.active { border-color: var(--accent-border); background: var(--accent-subtle); color: var(--accent); font-weight: 500; }
</style>
</head>
<body>
<div class="shell">
<!-- Sidebar -->
<nav class="sidebar" aria-label="Main navigation">
<div class="sidebar-brand">
<img src="img/dragon-mark.png" alt="Z-AMPP" class="sidebar-logo">
<span class="sidebar-brand-name">Z-AMPP</span>
</div>
<nav class="sidebar-nav">
<a href="index.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
Library
</a>
<a href="upload.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
Ingest
</a>
<a href="recorders.html" class="nav-item active">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
Recorders
</a>
<a href="capture.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
Capture
</a>
<a href="jobs.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
Jobs
</a>
</nav>
</nav>
<div class="main">
<header class="topbar">
<div class="topbar-left">
<span class="page-title">Recorders</span>
</div>
<div class="topbar-right">
<button class="btn btn-primary btn-sm" id="newRecorderBtn">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
New recorder
</button>
</div>
</header>
<div class="page-content">
<div id="recorderGrid" class="recorder-grid"></div>
<div id="recorderEmpty" class="empty-state" style="display:none;">
<div class="empty-state-icon">
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="9" width="26" height="22" rx="2"/><path d="M28 16l10-5v18l-10-5"/><circle cx="15" cy="20" r="4"/></svg>
</div>
<div class="empty-state-title">No recorders yet</div>
<div class="empty-state-body">Create a recorder to ingest live streams via SRT, RTMP, or SDI.</div>
<div class="empty-state-actions">
<button class="btn btn-primary btn-sm" onclick="openPanel()">New recorder</button>
</div>
</div>
</div>
</div>
</div>
<!-- Slide panel: new/edit recorder -->
<div class="slide-overlay" id="panelOverlay"></div>
<div class="slide-panel" id="recorderPanel">
<div class="slide-panel-header">
<span class="slide-panel-title">New recorder</span>
<button class="btn btn-ghost btn-sm" id="closePanelBtn" style="padding:0;width:28px;height:28px;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
</button>
</div>
<div class="slide-panel-body">
<!-- Name -->
<div class="form-group">
<label class="form-label" for="recName">Recorder name</label>
<input type="text" id="recName" placeholder="e.g. Studio A SRT">
</div>
<!-- Source type -->
<div class="form-group">
<label class="form-label">Source type</label>
<div class="source-type-row">
<button class="source-type-btn active" data-type="srt" onclick="setSourceType('srt')">SRT</button>
<button class="source-type-btn" data-type="rtmp" onclick="setSourceType('rtmp')">RTMP</button>
<button class="source-type-btn" data-type="sdi" onclick="setSourceType('sdi')">SDI</button>
</div>
</div>
<!-- Dynamic source config -->
<div id="sourceConfigFields" class="conditional-fields"></div>
<!-- Recording settings -->
<div class="form-group">
<div class="form-section-label">Recording settings</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="recCodec">Codec</label>
<select id="recCodec">
<option value="prores_hq">ProRes HQ</option>
<option value="prores_422">ProRes 422</option>
<option value="prores_lt">ProRes LT</option>
<option value="h264">H.264</option>
<option value="dnxhd">DNxHD</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="recResolution">Resolution</label>
<select id="recResolution">
<option value="native">Native (source)</option>
<option value="1920x1080">1920×1080</option>
<option value="1280x720">1280×720</option>
<option value="3840x2160">3840×2160</option>
</select>
</div>
</div>
<div class="form-group">
<label class="toggle">
<input type="checkbox" id="proxyToggle">
<div class="toggle-track"></div>
<span class="toggle-label">Generate proxy on stop</span>
</label>
</div>
<!-- Proxy settings (shown when proxy enabled) -->
<div id="proxyFields" style="display:none;" class="form-row">
<div class="form-group">
<label class="form-label" for="proxyCodec">Proxy codec</label>
<select id="proxyCodec">
<option value="h264">H.264</option>
<option value="h265">H.265</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="proxyBitrate">Proxy bitrate</label>
<select id="proxyBitrate">
<option value="2000k">2 Mbps</option>
<option value="4000k">4 Mbps</option>
<option value="8000k">8 Mbps</option>
</select>
</div>
</div>
<!-- Project / bin -->
<div class="form-group">
<div class="form-section-label">Destination</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="recProject">Project</label>
<select id="recProject">
<option value="">None (manual assignment)</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="recBin">Bin</label>
<select id="recBin">
<option value="">Project root</option>
</select>
</div>
</div>
</div>
<div class="slide-panel-footer">
<button class="btn btn-ghost" onclick="closePanel()">Cancel</button>
<button class="btn btn-primary" id="saveRecorderBtn">Create recorder</button>
</div>
</div>
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js"></script>
<script>
const pState = { recorders: [], timers: {}, sourceType: 'srt', mode: 'caller', projects: [], signals: {} };
document.addEventListener('DOMContentLoaded', async () => {
await Promise.all([loadRecorders(), loadProjects()]);
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('closePanelBtn').onclick = closePanel;
document.getElementById('panelOverlay').onclick = closePanel;
document.getElementById('saveRecorderBtn').onclick = handleSaveRecorder;
document.getElementById('proxyToggle').onchange = e => {
document.getElementById('proxyFields').style.display = e.target.checked ? 'grid' : 'none';
};
document.getElementById('recProject').onchange = handleProjectChange;
updateSourceFields();
});
// ── Load / render ─────────────────────────
async function loadRecorders() {
const r = await getRecorders();
if (!r.success) return;
pState.recorders = r.data;
renderRecorders();
}
function renderRecorders() {
const grid = document.getElementById('recorderGrid');
const empty = document.getElementById('recorderEmpty');
if (!pState.recorders.length) {
grid.innerHTML = ''; empty.style.display = 'flex'; return;
}
empty.style.display = 'none';
grid.innerHTML = pState.recorders.map(rec => {
const isRecording = rec.status === 'recording';
const cfg = rec.source_config || {};
const sourceTypeKey = (rec.source_type || 'sdi').toLowerCase();
const badgeClass = { sdi:'badge-sdi', srt:'badge-srt', rtmp:'badge-rtmp' }[sourceTypeKey] || 'badge-idle';
const statusClass = isRecording ? 'recording' : rec.status === 'error' ? 'error' : '';
const statusDotClass = isRecording ? 'status-dot--recording' : rec.status === 'error' ? 'status-dot--error' : 'status-dot--idle';
let sourceDisplay = '';
if (cfg.url) {
sourceDisplay = cfg.url;
} else if (cfg.device !== undefined) {
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 = '';
if (!isRecording && cfg.mode === 'listener') {
const serverIp = location.hostname || '10.0.0.25';
if (sourceTypeKey === 'srt') {
const port = cfg.listen_port || 49001;
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>
<span>Push to <code>srt://${serverIp}:${port}?mode=caller</code></span>
</div>`;
} else if (sourceTypeKey === 'rtmp') {
const port = cfg.listen_port || 41936;
const key = cfg.stream_key || 'stream';
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>
<span>Push to <code>rtmp://${serverIp}:${port}/live/${key}</code></span>
</div>`;
}
}
const lastRec = rec.last_recording_at ? new Date(rec.last_recording_at).toLocaleString() : 'Never';
return `<div class="recorder-card ${statusClass}" data-id="${rec.id}">
<div class="recorder-header">
<div class="recorder-id">
<div class="recorder-name">${esc(rec.name)}</div>
<div class="recorder-badges">
<span class="badge ${badgeClass}">${sourceTypeKey.toUpperCase()}</span>
${rec.codec ? `<span class="badge badge-idle">${esc(rec.codec)}</span>` : ''}
</div>
</div>
<div class="recorder-actions">
<button class="btn btn-ghost btn-sm" onclick="handleDeleteRecorder('${rec.id}')" title="Delete recorder" style="padding:0;width:28px;height:28px;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M6 4V2h4v2M5 4v9h6V4"/></svg>
</button>
</div>
</div>
<div class="recorder-status-row">
<span class="status-dot ${statusDotClass}"></span>
<span class="text-sm" style="color:${isRecording ? 'var(--accent)' : rec.status === 'error' ? 'var(--status-red)' : 'var(--text-secondary)'}">
${isRecording ? 'Recording' : rec.status === 'error' ? 'Error' : 'Idle'}
</span>
${isRecording ? `<span class="recorder-timer" id="timer-${rec.id}">00:00:00</span>` : ''}
</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">
<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>
<span>${esc(sourceDisplay)}</span>
</div>
${connectBanner}
<div class="recorder-footer">
<span class="recorder-footer-meta">Last: ${lastRec}</span>
<div class="recorder-controls">
${isRecording
? `<button class="btn btn-danger btn-sm" onclick="handleStop('${rec.id}')">Stop</button>`
: `<button class="btn btn-record btn-sm" onclick="handleStart('${rec.id}')">
<svg viewBox="0 0 14 14" fill="currentColor" width="10" height="10"><circle cx="7" cy="7" r="5"/></svg>
Record
</button>`
}
</div>
</div>
</div>`;
}).join('');
// Start timers for recording recorders
pState.recorders.filter(r => r.status === 'recording').forEach(rec => {
if (!pState.timers[rec.id]) {
const startedAt = rec.started_at ? new Date(rec.started_at) : new Date();
pState.timers[rec.id] = setInterval(() => {
const el = document.getElementById(`timer-${rec.id}`);
if (!el) { clearInterval(pState.timers[rec.id]); delete pState.timers[rec.id]; return; }
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
el.textContent = formatDur(elapsed);
}, 500);
}
});
Object.keys(pState.timers).forEach(id => {
if (!pState.recorders.find(r => r.id === id && r.status === 'recording')) {
clearInterval(pState.timers[id]); delete pState.timers[id];
}
});
}
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) {
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(':');
}
// ── Controls ──────────────────────────────
async function handleStart(id) {
const r = await startRecorder(id);
if (r.success) { toast('Recording started', '', 'success'); loadRecorders(); }
else toast('Failed to start', r.error, 'error');
}
async function handleStop(id) {
const r = await stopRecorder(id);
if (r.success) { toast('Recording stopped', '', 'success'); loadRecorders(); }
else toast('Failed to stop', r.error, 'error');
}
async function handleDeleteRecorder(id) {
if (!confirm('Delete this recorder?')) return;
const r = await deleteRecorder(id);
if (r.success) { toast('Recorder deleted', '', 'success'); loadRecorders(); }
else toast('Delete failed', r.error, 'error');
}
// ── Panel ─────────────────────────────────
function openPanel() {
document.getElementById('recorderPanel').classList.add('open');
document.getElementById('panelOverlay').classList.add('open');
updateSourceFields();
}
function closePanel() {
document.getElementById('recorderPanel').classList.remove('open');
document.getElementById('panelOverlay').classList.remove('open');
}
// ── Source type ───────────────────────────
function setSourceType(type) {
pState.sourceType = type;
pState.mode = 'caller';
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === type));
updateSourceFields();
}
function updateSourceFields() {
const container = document.getElementById('sourceConfigFields');
const type = pState.sourceType;
container.innerHTML = '';
if (type === 'sdi') {
container.innerHTML = `
<div class="form-group">
<label class="form-label" for="sdiDevice">DeckLink device</label>
<select id="sdiDevice">
<option value="0">DeckLink Card 1</option>
<option value="1">DeckLink Card 2</option>
</select>
</div>`;
} else if (type === 'srt') {
container.innerHTML = `
<div id="srtCallerFields">
<div class="form-group">
<label class="form-label" for="srtUrl">Source URL</label>
<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>`;
// Wire port input to update banner
setTimeout(() => {
const portIn = document.getElementById('srtPort');
if (portIn) portIn.addEventListener('input', () => {
const el = document.getElementById('srtConnectStr');
if (el) el.textContent = `srt://${location.hostname || '10.0.0.25'}:${portIn.value}?mode=caller`;
});
}, 0);
} else if (type === 'rtmp') {
container.innerHTML = `
<div id="rtmpCallerFields">
<div class="form-group">
<label class="form-label" for="rtmpUrl">Source URL</label>
<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>`;
setTimeout(() => {
const portIn = document.getElementById('rtmpPort');
const keyIn = document.getElementById('rtmpKey');
const update = () => {
const el = document.getElementById('rtmpConnectStr');
if (el) el.textContent = `rtmp://${location.hostname || '10.0.0.25'}:${portIn?.value || 41936}/live/${keyIn?.value || 'stream'}`;
};
portIn?.addEventListener('input', update);
keyIn?.addEventListener('input', update);
}, 0);
}
}
function setMode(_mode) {
// Listener mode UI was removed - all recorders are caller (pull) mode now.
pState.mode = 'caller';
}
// ── Projects for recorder destination ─────
async function loadProjects() {
const r = await getProjects();
if (!r.success) return;
pState.projects = r.data;
const sel = document.getElementById('recProject');
sel.innerHTML = '<option value="">None (manual assignment)</option>' +
r.data.map(p => `<option value="${p.id}">${esc(p.name)}</option>`).join('');
}
async function handleProjectChange() {
const projectId = document.getElementById('recProject').value;
const binSel = document.getElementById('recBin');
binSel.innerHTML = '<option value="">Project root</option>';
if (!projectId) return;
const r = await getBins(projectId);
if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`);
}
// ── Save recorder ─────────────────────────
async function handleSaveRecorder() {
const name = document.getElementById('recName').value.trim();
if (!name) { toast('Enter a recorder name', '', 'warning'); return; }
const type = pState.sourceType;
const mode = pState.mode;
const codec = document.getElementById('recCodec').value;
const resolution = document.getElementById('recResolution').value;
const projectId = document.getElementById('recProject').value || null;
const binId = document.getElementById('recBin').value || null;
let sourceConfig = {};
if (type === 'sdi') {
sourceConfig.device = parseInt(document.getElementById('sdiDevice')?.value || '0');
} else if (type === 'srt') {
sourceConfig.mode = 'caller';
sourceConfig.url = document.getElementById('srtUrl')?.value;
} else if (type === 'rtmp') {
sourceConfig.mode = 'caller';
sourceConfig.url = document.getElementById('rtmpUrl')?.value;
}
const proxy = document.getElementById('proxyToggle').checked;
const proxyConfig = proxy ? {
codec: document.getElementById('proxyCodec').value,
bitrate: document.getElementById('proxyBitrate').value,
} : null;
const payload = {
name, source_type: type, source_config: sourceConfig,
codec, resolution, project_id: projectId, bin_id: binId,
proxy_config: proxyConfig,
};
const r = await createRecorder(payload);
if (r.success) {
toast('Recorder created', name, 'success');
closePanel();
document.getElementById('recName').value = '';
await loadRecorders();
} else toast('Failed to create recorder', r.error, 'error');
}
function toast(title, msg, type = 'info') {
const el = document.createElement('div');
el.className = `toast toast--${type}`;
el.innerHTML = `<div class="toast-body"><div class="toast-title">${esc(title)}</div>${msg ? `<div class="toast-msg">${esc(msg)}</div>` : ''}</div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
</body>
</html>