dragonflight/services/web-ui/public/recorders.html
Zac 79369c378a fix: SRT/RTMP ingest + thumbnail crashes
Recorder model was creating capture containers but ffmpeg never spawned
inside them, so SRT/RTMP listeners bound the host port without ingesting
anything. Thumbnail extraction was also crashing on yuv444p sources,
leaving uploaded assets stuck at status=processing forever.

* capture/src/index.js: read RECORDER_ID/SOURCE_TYPE/LISTEN/LISTEN_PORT/
  STREAM_KEY/SOURCE_URL from env on startup and call captureManager.start()
  immediately. SIGTERM handler now flushes ffmpeg + S3 upload and POSTs the
  asset to mam-api before exiting.
* worker/ffmpeg/executor.js: force -pix_fmt yuv420p on proxy transcode and
  -pix_fmt yuvj420p on thumbnail extraction so mjpeg encoder accepts the
  input regardless of source pixel format.
* mam-api/routes/assets.js: when capture posts proxyKey=null but hiresKey
  is set (SRT/RTMP case), enqueue a proxy job from the hires so the asset
  ends up with a browser-playable proxy + thumbnail instead of stuck-ready.
* mam-api/routes/recorders.js: accept UI field aliases (codec/resolution/
  proxy_config), clean up unstarted containers on port collision, bump the
  docker stop timeout to 5min so long uploads can flush.
* web-ui/recorders.html: change default ports from 1935/9000 to 41936/49001
  to avoid common collisions with other RTMP/SRT services.
2026-05-17 07:01:54 -04:00

737 lines
28 KiB
HTML
Raw Permalink 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: 'listener', projects: [] };
document.addEventListener('DOMContentLoaded', async () => {
await Promise.all([loadRecorders(), loadProjects()]);
setInterval(loadRecorders, 5000);
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.mode === 'listener') {
const port = cfg.listen_port || (sourceTypeKey === 'srt' ? 49001 : 41936);
sourceDisplay = `Listen :${port}`;
} else if (cfg.url) {
sourceDisplay = cfg.url;
} else if (cfg.device !== undefined) {
sourceDisplay = `DeckLink ${cfg.device}`;
}
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>
<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 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 = 'listener';
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 class="form-group">
<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="49001" 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://${location.hostname || '10.0.0.25'}:49001?mode=caller</code></span>
</div>
</div>
<div id="srtCallerFields" style="display:none;">
<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?mode=caller">
</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 class="form-group">
<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="41936" 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://${location.hostname || '10.0.0.25'}:41936/live/stream</code></span>
</div>
</div>
<div id="rtmpCallerFields" style="display:none;">
<div class="form-group">
<label class="form-label" for="rtmpUrl">Source URL</label>
<input type="url" id="rtmpUrl" placeholder="rtmp://192.168.1.100/live/key">
</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) {
pState.mode = mode;
document.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
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 ─────
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 = mode;
if (mode === 'listener') sourceConfig.listen_port = parseInt(document.getElementById('srtPort')?.value || '49001');
else sourceConfig.url = document.getElementById('srtUrl')?.value;
} else if (type === 'rtmp') {
sourceConfig.mode = mode;
if (mode === 'listener') {
sourceConfig.listen_port = parseInt(document.getElementById('rtmpPort')?.value || '41936');
sourceConfig.stream_key = document.getElementById('rtmpKey')?.value || 'stream';
} else {
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>