2026-04-07 22:05:43 -04:00
<!DOCTYPE html>
< html lang = "en" >
< head >
2026-05-16 13:57:20 -04:00
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
2026-05-17 07:39:19 -04:00
< title > Recorders — Z-AMPP< / title >
2026-05-16 13:57:20 -04:00
< 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" >
2026-05-17 07:39:19 -04:00
< link rel = "stylesheet" href = "css/common.css?v=3" >
2026-05-16 13:57:20 -04:00
< 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 >
2026-04-07 22:05:43 -04:00
< / head >
< body >
2026-05-16 13:57:20 -04:00
< div class = "shell" >
<!-- Sidebar -->
< nav class = "sidebar" aria-label = "Main navigation" >
< div class = "sidebar-brand" >
2026-05-18 09:28:49 -04:00
< img src = "img/ampp-safe.png?v=hardhat" alt = "Z-AMPP" class = "sidebar-logo" >
2026-05-17 07:39:19 -04:00
< span class = "sidebar-brand-name" > Z-AMPP< / span >
2026-04-07 22:05:43 -04:00
< / div >
2026-05-16 13:57:20 -04:00
< 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 >
2026-05-17 21:44:15 -04:00
< 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 >
2026-05-16 13:57:20 -04:00
< / 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 >
2026-04-07 22:05:43 -04:00
< / div >
2026-05-16 13:57:20 -04:00
< 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" >
2026-04-07 22:05:43 -04:00
< / div >
2026-05-16 13:57:20 -04:00
<!-- 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 >
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
<!-- Dynamic source config -->
< div id = "sourceConfigFields" class = "conditional-fields" > < / div >
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
<!-- Recording settings -->
< div class = "form-group" >
< div class = "form-section-label" > Recording settings< / div >
< / div >
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
< 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 >
2026-05-17 07:39:19 -04:00
< option value = "1920x1080" > 1920× 1080< / option >
< option value = "1280x720" > 1280× 720< / option >
< option value = "3840x2160" > 3840× 2160< / option >
2026-05-16 13:57:20 -04:00
< / select >
< / div >
< / div >
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
< 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 >
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
<!-- 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 >
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
<!-- 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 >
2026-05-17 18:39:09 -04:00
< button class = "btn btn-secondary" id = "probeBtn" > Probe source< / button >
2026-05-16 13:57:20 -04:00
< button class = "btn btn-primary" id = "saveRecorderBtn" > Create recorder< / button >
< / div >
< / div >
< div class = "toast-container" id = "toastContainer" aria-live = "polite" > < / div >
2026-05-17 12:55:36 -04:00
< script src = "js/api.js?v=5" > < / script >
feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.
Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).
New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.
Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.
Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:04:38 -04:00
< script src = "js/topbar-strip.js?v=1" > < / script >
2026-05-16 13:57:20 -04:00
< script >
2026-05-17 07:39:19 -04:00
const pState = { recorders: [], timers: {}, sourceType: 'srt', mode: 'caller', projects: [], signals: {} };
2026-05-16 13:57:20 -04:00
document.addEventListener('DOMContentLoaded', async () => {
await Promise.all([loadRecorders(), loadProjects()]);
setInterval(loadRecorders, 5000);
2026-05-17 07:39:19 -04:00
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 (_) {}
}));
}
2026-05-16 13:57:20 -04:00
document.getElementById('newRecorderBtn').onclick = openPanel;
document.getElementById('closePanelBtn').onclick = closePanel;
document.getElementById('panelOverlay').onclick = closePanel;
document.getElementById('saveRecorderBtn').onclick = handleSaveRecorder;
2026-05-17 18:39:09 -04:00
document.getElementById('probeBtn').onclick = handleProbe;
2026-05-16 13:57:20 -04:00
document.getElementById('proxyToggle').onchange = e => {
document.getElementById('proxyFields').style.display = e.target.checked ? 'grid' : 'none';
};
document.getElementById('recProject').onchange = handleProjectChange;
updateSourceFields();
});
2026-05-17 07:39:19 -04:00
// ── Load / render ─────────────────────────
2026-05-16 13:57:20 -04:00
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 = '';
2026-05-17 07:39:19 -04:00
if (cfg.url) {
2026-05-16 13:57:20 -04:00
sourceDisplay = cfg.url;
} else if (cfg.device !== undefined) {
sourceDisplay = `DeckLink ${cfg.device}`;
2026-05-17 07:39:19 -04:00
} 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})`;
2026-05-16 13:57:20 -04:00
}
let connectBanner = '';
if (!isRecording & & cfg.mode === 'listener') {
const serverIp = location.hostname || '10.0.0.25';
if (sourceTypeKey === 'srt') {
2026-05-17 07:39:19 -04:00
const port = cfg.listen_port || 49001;
2026-05-16 13:57:20 -04:00
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') {
2026-05-17 07:39:19 -04:00
const port = cfg.listen_port || 41936;
2026-05-16 13:57:20 -04:00
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 >
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
< div class = "recorder-status-row" >
2026-05-17 18:39:09 -04:00
< 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)'}" >
${isRecording ? 'Connecting...' : rec.status === 'error' ? 'Error' : 'Idle'}
2026-05-16 13:57:20 -04:00
< / span >
${isRecording ? `< span class = "recorder-timer" id = "timer-${rec.id}" > 00:00:00< / span > ` : ''}
< / div >
feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.
Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).
New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.
Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.
Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:04:38 -04:00
${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" > Connecting…< / span > < / div > ` : ''}
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
< 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 >
2026-04-07 22:05:43 -04:00
2026-05-16 13:57:20 -04:00
${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 > `
2026-04-07 22:05:43 -04:00
}
2026-05-16 13:57:20 -04:00
< / div >
< / div >
< / div > `;
}).join('');
2026-05-17 07:39:19 -04:00
// Start timers for recording recorders
2026-05-16 13:57:20 -04:00
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];
}
});
}
2026-05-17 07:39:19 -04:00
function updateSignalBadge(rid, st) {
2026-05-17 18:39:09 -04:00
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;
}
feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.
Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).
New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.
Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.
Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:04:38 -04:00
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');
}
2026-05-17 07:39:19 -04:00
}
2026-05-16 13:57:20 -04:00
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(':');
}
2026-05-17 07:39:19 -04:00
// ── Controls ──────────────────────────────
2026-05-16 13:57:20 -04:00
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');
}
2026-05-17 07:39:19 -04:00
// ── Panel ─────────────────────────────────
2026-05-16 13:57:20 -04:00
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');
}
2026-05-17 07:39:19 -04:00
// ── Source type ───────────────────────────
2026-05-16 13:57:20 -04:00
function setSourceType(type) {
pState.sourceType = type;
2026-05-17 07:39:19 -04:00
pState.mode = 'caller';
2026-05-16 13:57:20 -04:00
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 = `
2026-05-17 07:39:19 -04:00
< div id = "srtCallerFields" >
2026-05-16 13:57:20 -04:00
< div class = "form-group" >
< label class = "form-label" for = "srtUrl" > Source URL< / label >
2026-05-17 07:39:19 -04:00
< 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 >
2026-05-16 13:57:20 -04:00
< / div >
< / div > `;
2026-05-17 07:39:19 -04:00
// Wire port input to update banner
2026-05-16 13:57:20 -04:00
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`;
2026-04-07 22:05:43 -04:00
});
2026-05-16 13:57:20 -04:00
}, 0);
} else if (type === 'rtmp') {
container.innerHTML = `
2026-05-17 07:39:19 -04:00
< div id = "rtmpCallerFields" >
2026-05-16 13:57:20 -04:00
< div class = "form-group" >
< label class = "form-label" for = "rtmpUrl" > Source URL< / label >
2026-05-17 07:39:19 -04:00
< 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 >
2026-05-16 13:57:20 -04:00
< / div >
< / div > `;
setTimeout(() => {
const portIn = document.getElementById('rtmpPort');
const keyIn = document.getElementById('rtmpKey');
const update = () => {
const el = document.getElementById('rtmpConnectStr');
2026-05-17 07:39:19 -04:00
if (el) el.textContent = `rtmp://${location.hostname || '10.0.0.25'}:${portIn?.value || 41936}/live/${keyIn?.value || 'stream'}`;
2026-05-16 13:57:20 -04:00
};
portIn?.addEventListener('input', update);
keyIn?.addEventListener('input', update);
}, 0);
}
}
2026-05-17 07:39:19 -04:00
function setMode(_mode) {
// Listener mode UI was removed - all recorders are caller (pull) mode now.
pState.mode = 'caller';
2026-05-16 13:57:20 -04:00
}
2026-05-17 07:39:19 -04:00
// ── Projects for recorder destination ─────
2026-05-16 13:57:20 -04:00
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 > `);
}
2026-05-17 07:39:19 -04:00
// ── Save recorder ─────────────────────────
2026-05-17 18:39:09 -04:00
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() {
2026-05-16 13:57:20 -04:00
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') {
2026-05-17 07:39:19 -04:00
sourceConfig.mode = 'caller';
sourceConfig.url = document.getElementById('srtUrl')?.value;
2026-05-16 13:57:20 -04:00
} else if (type === 'rtmp') {
2026-05-17 07:39:19 -04:00
sourceConfig.mode = 'caller';
sourceConfig.url = document.getElementById('rtmpUrl')?.value;
2026-05-16 13:57:20 -04:00
}
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,'& ').replace(/< /g,'< ').replace(/>/g,'> ').replace(/"/g,'" ');
}
< / script >
2026-04-07 22:05:43 -04:00
< / body >
< / html >