dragonflight/services/web-ui/public/recorders.html

858 lines
35 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-preview{position:relative;width:100%;aspect-ratio:16/9;background:#000;border-radius:var(--r-sm);overflow:hidden;margin:8px 0;border:1px solid var(--border)}
.recorder-preview video{width:100%;height:100%;object-fit:contain;display:block}
.recorder-preview-stamp{position:absolute;top:8px;left:8px;display:inline-flex;align-items:center;gap:6px;background:rgba(0,0,0,0.55);backdrop-filter:blur(4px);padding:3px 8px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:0.12em;color:#fff}
.recorder-preview-dot{width:6px;height:6px;background:oklch(62% 0.22 25);border-radius:50%;animation:fsPulse 1.4s ease-in-out infinite}
@keyframes fsPulse{0%,100%{opacity:0.7}50%{opacity:1}}
.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/ampp-safe.png?v=hardhat" 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>
<a href="#" class="nav-item" target="_blank" onclick="window.open(location.protocol + '//' + location.hostname + ':47435/', '_blank'); return false;" rel="noopener">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
Editor
</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-secondary" id="probeBtn">Probe source</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?v=5"></script>
<script src="js/topbar-strip.js?v=1"></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('probeBtn').onclick = handleProbe;
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';
// Skip full DOM rebuild if structure is unchanged — the per-second timer
// and the signal poll will update the dynamic fields in place. Rebuilding
// every 5s was tearing down the live <video> element and the timer span.
const sig = pState.recorders.map(r => r.id + ':' + r.status + ':' + (r.live_asset_id || '') + ':' + (r.started_at || '')).join('|');
if (sig === pState._lastRenderSig && grid.children.length === pState.recorders.length) {
return;
}
pState._lastRenderSig = sig;
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}" id="statusDot-${rec.id}"></span>
<span class="text-sm" id="statusText-${rec.id}" style="color:${isRecording ? 'var(--accent)' : rec.status === 'error' ? 'var(--status-red)' : 'var(--text-secondary)'}">
${(() => { if (rec.status === 'error') return 'Error'; if (!isRecording) return 'Idle'; const sg = (pState.signals[rec.id]||{}).signal; if (sg === 'lost') return 'Signal lost'; if (sg === 'error') return 'Connection error'; if (sg === 'connecting') return 'Connecting...'; return 'Recording'; })()}
</span>
${isRecording ? `<span class="recorder-timer" id="timer-${rec.id}">${rec.started_at ? formatDur(Math.max(0, Math.floor((Date.now() - new Date(rec.started_at).getTime())/1000))) : "00:00:00"}</span>` : ''}
</div>
${isRecording ? `<div class="signal-strip" id="signalStrip-${rec.id}"><div class="signal-strip-fill"></div></div><div class="recorder-status-row" style="font-size:var(--text-xs);"><span id="signal-${rec.id}" style="color:var(--text-tertiary);font-family:var(--font-mono);letter-spacing:0.02em">${(pState.signals[rec.id]||{}).signal === "lost" ? "No signal — stream dropped" : (pState.signals[rec.id]||{}).signal === "error" ? "Capture error" : "Receiving stream"}</span></div>` : ''}
${isRecording && rec.live_asset_id ? `<div class="recorder-preview"><video id="livevideo-${rec.id}" data-live-id="${rec.live_asset_id}" muted playsinline autoplay></video><div class="recorder-preview-stamp"><span class="recorder-preview-dot"></span>LIVE</div></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];
}
});
// Attach an HLS source to each live-preview <video> on the page.
pState.recorders.filter(r => r.status === 'recording' && r.live_asset_id).forEach(rec => {
const v = document.getElementById('livevideo-' + rec.id);
if (!v || v.dataset.attached === '1') return;
const url = '/live/' + rec.live_asset_id + '/index.m3u8';
const attach = () => {
if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = url; v.play().catch(() => {});
} else if (window.Hls && window.Hls.isSupported()) {
const hls = new Hls({ lowLatencyMode: true, liveSyncDuration: 2, liveMaxLatencyDuration: 6 });
hls.loadSource(url); hls.attachMedia(v);
hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(() => {}));
v._hls = hls;
}
v.dataset.attached = '1';
};
if (window.Hls) attach();
else {
const sc = document.createElement('script');
sc.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.0/dist/hls.min.js';
sc.onload = attach;
document.head.appendChild(sc);
}
});
document.querySelectorAll('video[data-live-id]').forEach(v => {
const id = v.id.replace('livevideo-', '');
const rec = pState.recorders.find(r => r.id === id);
if (!rec || rec.status !== 'recording') {
try { if (v._hls) { v._hls.destroy(); delete v._hls; } } catch (_) {}
v.removeAttribute('src');
}
});
}
function updateSignalBadge(rid, st) {
const el = document.getElementById('signal-' + rid);
const sig = st.signal || 'connecting';
if (el) {
const detail = {
connecting: 'Waiting for stream...',
receiving: 'Receiving • ' + (st.framesReceived || 0) + ' fr • ' + Math.round(st.currentFps || 0) + ' fps',
lost: 'No signal — stream dropped',
error: st.lastError ? st.lastError : 'Connection error',
stopped: 'Stopped',
};
const col = {
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 = detail[sig] || sig;
el.style.color = col[sig] || 'var(--text-tertiary)';
el.title = st.lastError || '';
}
const mainTxt = document.getElementById('statusText-' + rid);
const mainDot = document.getElementById('statusDot-' + rid);
if (mainTxt && mainDot) {
const mainLabel = { connecting: 'Connecting...', receiving: 'Recording', lost: 'Signal lost', error: 'Connection error' }[sig] || 'Recording';
const mainCol = { connecting: 'var(--status-yellow, oklch(82% 0.15 90))', receiving: 'var(--accent)', lost: 'var(--status-red, oklch(62% 0.22 25))', error: 'var(--status-red, oklch(62% 0.22 25))' }[sig] || 'var(--accent)';
mainTxt.textContent = mainLabel;
mainTxt.style.color = mainCol;
mainDot.style.background = mainCol;
}
const strip = document.getElementById('signalStrip-' + rid);
if (strip) {
strip.classList.remove('signal-strip--warn', 'signal-strip--bad', 'signal-strip--idle');
if (sig === 'connecting') strip.classList.add('signal-strip--warn');
else if (sig === 'lost' || sig === 'error') strip.classList.add('signal-strip--bad');
}
}
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 handleProbe() {
const btn = document.getElementById('probeBtn');
btn.disabled = true; btn.textContent = 'Probing...';
// Build payload from current form state
const type = pState.sourceType;
const payload = { source_type: type };
if (type === 'srt' && document.getElementById('srtUrl')) {
payload.source_url = document.getElementById('srtUrl').value.trim();
} else if (type === 'rtmp' && document.getElementById('rtmpUrl')) {
payload.source_url = document.getElementById('rtmpUrl').value.trim();
} else if (type === 'sdi') {
const d = document.getElementById('sdiDevice');
if (d) payload.device = parseInt(d.value || '0', 10);
}
try {
const r = await fetch('/api/v1/recorders/probe', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(payload),
});
const data = await r.json();
renderProbeResult(data);
} catch (err) {
renderProbeResult({ ok: false, error: 'Network error: ' + err.message });
} finally {
btn.disabled = false; btn.textContent = 'Probe source';
}
}
function renderProbeResult(d) {
let host = document.getElementById('probeResult');
if (!host) {
host = document.createElement('div');
host.id = 'probeResult';
host.style.cssText = 'margin-top:var(--sp-3);padding:var(--sp-3);border-radius:var(--r-md);border:1px solid var(--border);background:var(--bg-surface);font-size:var(--text-xs)';
const footer = document.querySelector('.slide-panel-footer');
footer.parentElement.insertBefore(host, footer);
}
if (!d.ok) {
host.style.borderColor = 'oklch(62% 0.22 25 / 0.5)';
host.style.background = 'oklch(62% 0.22 25 / 0.08)';
host.innerHTML = '<div style="color:var(--status-red);font-weight:500;margin-bottom:4px">No signal detected</div><div style="color:var(--text-secondary);white-space:pre-wrap">' + (d.error || 'Unknown error') + '</div>';
return;
}
host.style.borderColor = 'oklch(68% 0.18 148 / 0.5)';
host.style.background = 'oklch(68% 0.18 148 / 0.08)';
renderProbeOk(host, d);
}
function renderProbeOk(host, d) {
if (d.source_type === 'sdi') {
host.innerHTML = '<div style="color:var(--status-green);font-weight:500;margin-bottom:6px">DeckLink devices found</div><ul style="margin:0;padding-left:18px;color:var(--text-primary)">' + (d.devices || []).map(n => '<li>' + esc(n) + '</li>').join('') + '</ul>';
return;
}
const v = (d.streams || []).find(s => s.codec_type === 'video');
const a = (d.streams || []).find(s => s.codec_type === 'audio');
let html = '<div style="color:var(--status-green);font-weight:500;margin-bottom:6px">Signal detected</div><div style="color:var(--text-primary);line-height:1.6">';
if (v) html += '<div><strong>Video:</strong> ' + esc(v.codec_name || '?') + ' ' + (v.width || '?') + 'x' + (v.height || '?') + ' • ' + esc(v.r_frame_rate || v.avg_frame_rate || '?') + ' fps • ' + esc(v.pix_fmt || '?') + '</div>';
if (a) html += '<div><strong>Audio:</strong> ' + esc(a.codec_name || '?') + ' • ' + (a.sample_rate || '?') + ' Hz • ' + (a.channels || '?') + ' ch</div>';
html += '</div>';
host.innerHTML = html;
}
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>