1517 lines
69 KiB
HTML
1517 lines
69 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||
<title>Recorders — Dragonflight</title>
|
||
<link rel="stylesheet" href="/dist/app.css">
|
||
<style>
|
||
/* ── Recorders page · AMPP-aligned theme ── */
|
||
.main {
|
||
background:
|
||
radial-gradient(ellipse 70% 50% at 50% 0%, oklch(28% 0.10 32 / 0.35), transparent 60%),
|
||
radial-gradient(ellipse 60% 40% at 90% 100%, oklch(35% 0.16 32 / 0.18), transparent 65%),
|
||
var(--bg-base);
|
||
}
|
||
.page-content { padding: 24px 32px 48px; }
|
||
|
||
.recorder-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.recorder-card {
|
||
position: relative;
|
||
background: oklch(13% 0.018 250 / 0.7);
|
||
border: 1px solid oklch(28% 0.04 260 / 0.5);
|
||
border-radius: 12px;
|
||
padding: 16px 18px 14px;
|
||
display: flex; flex-direction: column;
|
||
gap: 12px;
|
||
backdrop-filter: blur(6px);
|
||
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||
}
|
||
.recorder-card:hover { border-color: oklch(45% 0.20 32 / 0.4); }
|
||
.recorder-card.recording {
|
||
border-color: oklch(62% 0.22 25 / 0.55);
|
||
background: oklch(15% 0.025 260 / 0.8);
|
||
box-shadow: 0 16px 40px -16px oklch(62% 0.22 25 / 0.25);
|
||
}
|
||
.recorder-card.error { border-color: oklch(62% 0.22 25 / 0.5); }
|
||
|
||
.recorder-header {
|
||
display: flex; align-items: flex-start; justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
.recorder-id { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||
.recorder-name {
|
||
font-size: 15px; font-weight: 600;
|
||
letter-spacing: -0.005em;
|
||
color: var(--text-primary);
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.recorder-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||
.recorder-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||
|
||
.recorder-status-row { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
|
||
.recorder-status-row .text-sm { font-size: 13px; font-weight: 500; }
|
||
.recorder-timer { margin-left: auto; font-family: var(--font-mono); font-size: 14px; font-weight: 600; color: oklch(70% 0.18 32); letter-spacing: 0.02em; font-variant-numeric: tabular-nums; }
|
||
.recorder-card.recording .recorder-timer { color: oklch(62% 0.22 25); }
|
||
|
||
.signal-strip { position: relative; height: 4px; background: oklch(20% 0.04 260); border-radius: 2px; overflow: hidden; }
|
||
.signal-strip-fill { position: absolute; top: 0; bottom: 0; left: 0; width: 100%; background: linear-gradient(90deg, oklch(55% 0.20 32), oklch(70% 0.18 32)); animation: signalSlide 2.4s linear infinite; }
|
||
.signal-strip--warn .signal-strip-fill { background: oklch(82% 0.15 90); }
|
||
.signal-strip--bad .signal-strip-fill { background: oklch(62% 0.22 25); animation: none; opacity: 0.4; }
|
||
.signal-strip--idle .signal-strip-fill { background: oklch(35% 0.04 260); animation: none; opacity: 0.4; }
|
||
@keyframes signalSlide { 0% { transform: translateX(-100%) } 100% { transform: translateX(100%) } }
|
||
|
||
.recorder-preview { position: relative; width: 100%; aspect-ratio: 16/9; background: #000; border-radius: 8px; overflow: hidden; border: 1px solid oklch(28% 0.04 260 / 0.5); }
|
||
.recorder-preview video { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||
.recorder-preview-stamp { position: absolute; top: 10px; left: 10px; display: inline-flex; align-items: center; gap: 6px; background: oklch(10% 0.015 250 / 0.7); backdrop-filter: blur(6px); padding: 4px 10px; border-radius: 999px; border: 1px solid oklch(62% 0.22 25 / 0.5); font-size: 10px; font-weight: 700; letter-spacing: 0.14em; 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; box-shadow: 0 0 8px oklch(62% 0.22 25); }
|
||
@keyframes fsPulse { 0%,100% { opacity: 0.7 } 50% { opacity: 1 } }
|
||
|
||
.recorder-source { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: oklch(11% 0.018 250 / 0.6); border: 1px solid oklch(28% 0.04 260 / 0.4); border-radius: 6px; font-size: 12px; color: var(--text-secondary); font-family: var(--font-mono); letter-spacing: 0.01em; }
|
||
.recorder-source svg { color: var(--text-tertiary); }
|
||
|
||
.recorder-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 8px; border-top: 1px solid oklch(28% 0.04 260 / 0.4); }
|
||
.recorder-footer-meta { font-size: 11px; color: var(--text-tertiary); font-family: var(--font-mono); letter-spacing: 0.02em; }
|
||
.recorder-controls { display: flex; gap: 6px; }
|
||
|
||
/* Source-type + mode selectors — brand-blue tile feel */
|
||
.source-type-row, .mode-row { display: flex; gap: 8px; }
|
||
.source-type-btn, .mode-btn {
|
||
flex: 1; padding: 10px 12px;
|
||
background: oklch(13% 0.018 250 / 0.6);
|
||
border: 1px solid oklch(28% 0.04 260 / 0.45);
|
||
border-radius: 8px;
|
||
font-size: 13px; font-weight: 500;
|
||
color: var(--text-secondary);
|
||
cursor: pointer; text-align: center;
|
||
letter-spacing: 0.04em;
|
||
transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
|
||
}
|
||
.source-type-btn:hover, .mode-btn:hover {
|
||
border-color: oklch(45% 0.20 32 / 0.5);
|
||
color: var(--text-primary);
|
||
}
|
||
.source-type-btn.active, .mode-btn.active {
|
||
border-color: oklch(55% 0.20 32 / 0.7);
|
||
background: oklch(20% 0.08 32 / 0.45);
|
||
color: oklch(78% 0.14 32);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.form-section-label {
|
||
font-size: 11px; font-weight: 600;
|
||
letter-spacing: 0.18em; text-transform: uppercase;
|
||
color: var(--text-tertiary);
|
||
padding: 14px 0 8px;
|
||
}
|
||
|
||
.recorder-connect-info { margin-top: 4px; }
|
||
|
||
/* Slide panel polish */
|
||
.slide-panel { background: oklch(11% 0.018 250 / 0.98); border-left: 1px solid oklch(28% 0.04 260 / 0.6); }
|
||
.slide-panel-header { border-bottom: 1px solid oklch(28% 0.04 260 / 0.4); padding: 18px 22px; }
|
||
.slide-panel-title { font-size: 15px; font-weight: 600; letter-spacing: -0.005em; }
|
||
/* Flexbox overflow footgun fix: a flex child won't honour overflow:auto
|
||
unless we give it min-height:0. Without this, the new codec blocks
|
||
overflow past the panel bottom and the footer is unreachable. */
|
||
.slide-panel { height: 100vh; max-height: 100vh; overflow: hidden; }
|
||
.slide-panel-body {
|
||
padding: 18px 22px;
|
||
min-height: 0;
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* ── Codec tabs (Video / Audio / Container) ── */
|
||
.codec-block {
|
||
border: 1px solid oklch(28% 0.04 260 / 0.45);
|
||
border-radius: 10px;
|
||
background: oklch(12% 0.018 250 / 0.55);
|
||
overflow: hidden;
|
||
}
|
||
.codec-block-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 10px 14px;
|
||
background: oklch(15% 0.025 260 / 0.55);
|
||
border-bottom: 1px solid oklch(28% 0.04 260 / 0.4);
|
||
}
|
||
.codec-block-title {
|
||
font-size: 12px; font-weight: 600;
|
||
letter-spacing: 0.14em; text-transform: uppercase;
|
||
color: var(--text-secondary);
|
||
}
|
||
.codec-tabs {
|
||
display: flex; gap: 0;
|
||
padding: 0 8px;
|
||
background: oklch(10% 0.015 250 / 0.5);
|
||
border-bottom: 1px solid oklch(28% 0.04 260 / 0.4);
|
||
}
|
||
.codec-tab {
|
||
flex: 1;
|
||
padding: 10px 10px 9px;
|
||
background: transparent;
|
||
border: 0;
|
||
border-bottom: 2px solid transparent;
|
||
font-size: 12px; font-weight: 500;
|
||
letter-spacing: 0.08em; text-transform: uppercase;
|
||
color: var(--text-tertiary);
|
||
cursor: pointer;
|
||
transition: color 120ms ease, border-color 120ms ease;
|
||
}
|
||
.codec-tab:hover { color: var(--text-secondary); }
|
||
.codec-tab.active {
|
||
color: oklch(78% 0.14 32);
|
||
border-bottom-color: oklch(55% 0.20 32 / 0.85);
|
||
}
|
||
.codec-tab-panel { display: none; padding: 14px; }
|
||
.codec-tab-panel.active { display: block; }
|
||
|
||
/* ── SDI picker (node + visual port card) ── */
|
||
.sdi-picker { display: flex; flex-direction: column; gap: 12px; }
|
||
.sdi-card-wrap {
|
||
border: 1px solid oklch(28% 0.04 260 / 0.45);
|
||
border-radius: 10px;
|
||
background:
|
||
radial-gradient(ellipse 60% 40% at 50% 0%, oklch(28% 0.10 32 / 0.18), transparent 70%),
|
||
oklch(11% 0.018 250 / 0.6);
|
||
padding: 14px 16px 12px;
|
||
display: flex; flex-direction: column; gap: 8px;
|
||
}
|
||
.sdi-card-empty {
|
||
padding: 18px;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: var(--text-tertiary);
|
||
font-family: var(--font-mono);
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.sdi-card-meta {
|
||
display: flex; flex-wrap: wrap; gap: 14px;
|
||
font-size: 11px; color: var(--text-tertiary);
|
||
font-family: var(--font-mono); letter-spacing: 0.02em;
|
||
}
|
||
.sdi-card-meta strong { color: var(--text-secondary); font-weight: 500; }
|
||
|
||
.bmd-card-svg {
|
||
display: block; width: 100%;
|
||
max-width: 360px; margin: 0 auto;
|
||
height: auto;
|
||
}
|
||
.bmd-card-body {
|
||
fill: oklch(18% 0.04 260 / 0.85);
|
||
stroke: oklch(30% 0.05 260 / 0.7);
|
||
stroke-width: 1;
|
||
}
|
||
.bmd-card-bracket {
|
||
fill: oklch(60% 0.02 260 / 0.65);
|
||
stroke: oklch(40% 0.03 260 / 0.8);
|
||
stroke-width: 1;
|
||
}
|
||
.bmd-card-trace {
|
||
fill: none;
|
||
stroke: oklch(35% 0.08 32 / 0.4);
|
||
stroke-width: 0.8;
|
||
stroke-dasharray: 2 3;
|
||
}
|
||
.bmd-card-model {
|
||
fill: oklch(55% 0.04 260);
|
||
font: 600 9px/1 var(--font-mono, ui-monospace);
|
||
letter-spacing: 0.14em;
|
||
}
|
||
.bmd-port-ring {
|
||
fill: oklch(20% 0.03 260);
|
||
stroke: oklch(45% 0.04 260);
|
||
stroke-width: 1.4;
|
||
transition: stroke 120ms ease, fill 120ms ease;
|
||
}
|
||
.bmd-port-pin {
|
||
fill: oklch(70% 0.03 260);
|
||
}
|
||
.bmd-port-label {
|
||
fill: var(--text-secondary, oklch(75% 0.02 260));
|
||
font: 600 11px/1 ui-sans-serif, system-ui, sans-serif;
|
||
}
|
||
.bmd-port-sublabel {
|
||
fill: var(--text-tertiary, oklch(55% 0.02 260));
|
||
font: 500 9px/1 var(--font-mono, ui-monospace);
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.bmd-port-group { transition: filter 120ms ease; }
|
||
.bmd-port-group:hover .bmd-port-ring {
|
||
stroke: oklch(60% 0.18 32 / 0.8);
|
||
}
|
||
.bmd-port-group.is-selected .bmd-port-ring {
|
||
fill: oklch(30% 0.14 32 / 0.55);
|
||
stroke: oklch(75% 0.18 32);
|
||
stroke-width: 2;
|
||
filter: drop-shadow(0 0 6px oklch(60% 0.20 32 / 0.7));
|
||
}
|
||
.bmd-port-group.is-selected .bmd-port-pin {
|
||
fill: oklch(85% 0.10 32);
|
||
}
|
||
.bmd-port-group.is-selected .bmd-port-label {
|
||
fill: oklch(82% 0.14 32);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wd-shell" style="display:flex;min-height:100vh;">
|
||
<!-- Sidebar -->
|
||
<nav class="wd-sidebar" aria-label="Main navigation">
|
||
<div class="wd-sidebar-header">
|
||
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:18px;height:18px;">
|
||
<span class="wd-sidebar-brand">Dragonflight</span>
|
||
</div>
|
||
<div class="wd-sidebar-nav">
|
||
<a href="home.html" class="wd-nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
|
||
Home
|
||
</a>
|
||
<a href="index.html" class="wd-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="projects.html" class="wd-nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
|
||
Projects
|
||
</a>
|
||
<a href="upload.html" class="wd-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="wd-nav-item is-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="wd-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="wd-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="editor.html" class="wd-nav-item">
|
||
<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>
|
||
<div class="wd-sidebar-section">Admin</div>
|
||
<a href="users.html" class="wd-nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
|
||
Users
|
||
</a>
|
||
<a href="tokens.html" class="wd-nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
|
||
Tokens
|
||
</a>
|
||
<a href="containers.html" class="wd-nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="5" width="14" height="4" rx="1"/><rect x="1" y="10" width="14" height="4" rx="1"/><path d="M4 7h1M4 12h1"/></svg>
|
||
Containers
|
||
</a>
|
||
<a href="cluster.html" class="wd-nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2"/><circle cx="2" cy="3" r="1.5"/><circle cx="14" cy="3" r="1.5"/><circle cx="2" cy="13" r="1.5"/><circle cx="14" cy="13" r="1.5"/><path d="M3.1 4.1L6.5 6.5M12.9 4.1L9.5 6.5M3.1 11.9L6.5 9.5M12.9 11.9L9.5 9.5"/></svg>
|
||
Cluster
|
||
</a>
|
||
<a href="settings.html" class="wd-nav-item">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M2.9 2.9l1.1 1.1M12 12l1.1 1.1M2.9 13.1L4 12M12 4l1.1-1.1"/></svg>
|
||
Settings
|
||
</a>
|
||
</div>
|
||
<div class="wd-sidebar-footer">
|
||
<div class="wd-sidebar-user">
|
||
<div class="wd-sidebar-user-avatar" id="userAvatar">?</div>
|
||
<div class="wd-sidebar-user-info">
|
||
<div class="wd-sidebar-user-name" id="userName">—</div>
|
||
<div class="wd-sidebar-user-role" id="userRole"></div>
|
||
</div>
|
||
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout" id="logoutBtn" title="Sign out">
|
||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<div style="flex:1;display:flex;flex-direction:column;">
|
||
<header class="wd-topbar">
|
||
<div class="wd-topbar-left">
|
||
<span class="page-title">Recorders</span>
|
||
</div>
|
||
<div class="wd-topbar-right">
|
||
<button class="wd-btn wd-btn--primary wd-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="wd-btn wd-btn--primary wd-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" id="panelTitle">New recorder</span>
|
||
<button class="wd-btn wd-btn--ghost wd-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" 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 active" data-type="sdi" onclick="setSourceType('sdi')">SDI</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Dynamic source config -->
|
||
<div id="sourceConfigFields" class="conditional-fields"></div>
|
||
|
||
<!-- Master recording codec block -->
|
||
<div class="codec-block">
|
||
<div class="codec-block-header">
|
||
<span class="codec-block-title">Master recording</span>
|
||
</div>
|
||
<div class="codec-tabs" role="tablist" data-target="master">
|
||
<button class="codec-tab active" data-tab="video">Video</button>
|
||
<button class="codec-tab" data-tab="audio">Audio</button>
|
||
<button class="codec-tab" data-tab="container">Container</button>
|
||
</div>
|
||
|
||
<!-- Video -->
|
||
<div class="codec-tab-panel active" data-panel="master:video">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label" for="recCodec">Video codec</label>
|
||
<select id="recCodec"></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="3840x2160">3840×2160 (UHD)</option>
|
||
<option value="1920x1080">1920×1080 (HD)</option>
|
||
<option value="1280x720">1280×720</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row" id="recVideoBitrateRow" style="display:none;">
|
||
<div class="form-group">
|
||
<label class="form-label" for="recVideoBitrate">Video bitrate</label>
|
||
<input type="text" id="recVideoBitrate" placeholder="e.g. 50M, 100M">
|
||
<div class="form-hint">ffmpeg <code>-b:v</code> value. Leave empty for codec default.</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="recFramerate">Framerate</label>
|
||
<select id="recFramerate">
|
||
<option value="">Source</option>
|
||
<option value="23.976">23.976</option>
|
||
<option value="24">24</option>
|
||
<option value="25">25</option>
|
||
<option value="29.97">29.97</option>
|
||
<option value="30">30</option>
|
||
<option value="50">50</option>
|
||
<option value="59.94">59.94</option>
|
||
<option value="60">60</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row" id="recFramerateOnlyRow">
|
||
<div class="form-group">
|
||
<label class="form-label" for="recFramerateAlt">Framerate</label>
|
||
<select id="recFramerateAlt">
|
||
<option value="">Source</option>
|
||
<option value="23.976">23.976</option>
|
||
<option value="24">24</option>
|
||
<option value="25">25</option>
|
||
<option value="29.97">29.97</option>
|
||
<option value="30">30</option>
|
||
<option value="50">50</option>
|
||
<option value="59.94">59.94</option>
|
||
<option value="60">60</option>
|
||
</select>
|
||
<div class="form-hint">ProRes / DNxHR pick bitrate from profile + resolution, so only framerate is configurable.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Audio -->
|
||
<div class="codec-tab-panel" data-panel="master:audio">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label" for="recAudioCodec">Audio codec</label>
|
||
<select id="recAudioCodec"></select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="recAudioChannels">Channels</label>
|
||
<select id="recAudioChannels">
|
||
<option value="1">1 (mono)</option>
|
||
<option value="2" selected>2 (stereo)</option>
|
||
<option value="4">4</option>
|
||
<option value="6">6 (5.1)</option>
|
||
<option value="8">8 (7.1)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row" id="recAudioBitrateRow" style="display:none;">
|
||
<div class="form-group">
|
||
<label class="form-label" for="recAudioBitrate">Audio bitrate</label>
|
||
<input type="text" id="recAudioBitrate" placeholder="e.g. 192k, 320k">
|
||
<div class="form-hint">Only applies to compressed audio codecs (AAC, Opus, AC-3).</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Container -->
|
||
<div class="codec-tab-panel" data-panel="master:container">
|
||
<div class="form-group">
|
||
<label class="form-label" for="recContainer">Container format</label>
|
||
<select id="recContainer"></select>
|
||
<div class="form-hint">MOV is recommended for ProRes / DNxHR masters. MXF for broadcast workflows.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Proxy toggle -->
|
||
<div class="form-group">
|
||
<label class="toggle">
|
||
<input type="checkbox" id="proxyToggle" checked>
|
||
<div class="toggle-track"></div>
|
||
<span class="toggle-label">Generate proxy</span>
|
||
</label>
|
||
<div class="form-hint">SDI sources record proxy in parallel. Network sources (SRT/RTMP) generate proxy after stop.</div>
|
||
</div>
|
||
|
||
<!-- Proxy codec block (mirrors master) -->
|
||
<div class="codec-block" id="proxyBlock">
|
||
<div class="codec-block-header">
|
||
<span class="codec-block-title">Proxy</span>
|
||
</div>
|
||
<div class="codec-tabs" role="tablist" data-target="proxy">
|
||
<button class="codec-tab active" data-tab="video">Video</button>
|
||
<button class="codec-tab" data-tab="audio">Audio</button>
|
||
<button class="codec-tab" data-tab="container">Container</button>
|
||
</div>
|
||
|
||
<div class="codec-tab-panel active" data-panel="proxy:video">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label" for="proxyCodec">Video codec</label>
|
||
<select id="proxyCodec"></select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="proxyResolution">Resolution</label>
|
||
<select id="proxyResolution">
|
||
<option value="1920x1080">1920×1080</option>
|
||
<option value="1280x720">1280×720</option>
|
||
<option value="960x540">960×540</option>
|
||
<option value="640x360">640×360</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row" id="proxyVideoBitrateRow">
|
||
<div class="form-group">
|
||
<label class="form-label" for="proxyVideoBitrate">Video bitrate</label>
|
||
<input type="text" id="proxyVideoBitrate" value="8M" placeholder="e.g. 8M">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="proxyFramerate">Framerate</label>
|
||
<select id="proxyFramerate">
|
||
<option value="">Source</option>
|
||
<option value="23.976">23.976</option>
|
||
<option value="24">24</option>
|
||
<option value="25">25</option>
|
||
<option value="29.97">29.97</option>
|
||
<option value="30">30</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="codec-tab-panel" data-panel="proxy:audio">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label" for="proxyAudioCodec">Audio codec</label>
|
||
<select id="proxyAudioCodec"></select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="proxyAudioChannels">Channels</label>
|
||
<select id="proxyAudioChannels">
|
||
<option value="1">1 (mono)</option>
|
||
<option value="2" selected>2 (stereo)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row" id="proxyAudioBitrateRow">
|
||
<div class="form-group">
|
||
<label class="form-label" for="proxyAudioBitrate">Audio bitrate</label>
|
||
<input type="text" id="proxyAudioBitrate" value="192k" placeholder="e.g. 192k">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="codec-tab-panel" data-panel="proxy:container">
|
||
<div class="form-group">
|
||
<label class="form-label" for="proxyContainer">Container format</label>
|
||
<select id="proxyContainer"></select>
|
||
</div>
|
||
</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=6"></script>
|
||
<script src="js/topbar-strip.js?v=1"></script>
|
||
<script src="js/bmd-card.js?v=1"></script>
|
||
<script>
|
||
// ── Codec catalogues (must match capture-manager.js) ──────────────
|
||
// Keys are exactly the values VIDEO_CODECS / AUDIO_CODECS / CONTAINER_FMT
|
||
// accept. `bitrateControl` mirrors the capture-manager flag — when false,
|
||
// the bitrate input is hidden (ProRes profile drives the bitrate; DNxHR
|
||
// profile drives the bitrate; PCM/FLAC are constant-bitrate by definition).
|
||
const VIDEO_CODECS = {
|
||
prores_hq: { label: 'ProRes 422 HQ', bitrateControl: false },
|
||
prores_422: { label: 'ProRes 422', bitrateControl: false },
|
||
prores_lt: { label: 'ProRes 422 LT', bitrateControl: false },
|
||
prores_proxy: { label: 'ProRes 422 Proxy', bitrateControl: false },
|
||
dnxhr_hq: { label: 'DNxHR HQ', bitrateControl: false },
|
||
dnxhr_sq: { label: 'DNxHR SQ', bitrateControl: false },
|
||
dnxhd: { label: 'DNxHD', bitrateControl: true },
|
||
h264: { label: 'H.264 (libx264)', bitrateControl: true },
|
||
h264_nvenc: { label: 'H.264 NVENC', bitrateControl: true },
|
||
h265: { label: 'H.265 (libx265)', bitrateControl: true },
|
||
hevc_nvenc: { label: 'HEVC NVENC', bitrateControl: true },
|
||
};
|
||
const AUDIO_CODECS = {
|
||
pcm_s16le: { label: 'PCM 16-bit', bitrateControl: false },
|
||
pcm_s24le: { label: 'PCM 24-bit', bitrateControl: false },
|
||
pcm_s32le: { label: 'PCM 32-bit', bitrateControl: false },
|
||
flac: { label: 'FLAC', bitrateControl: false },
|
||
aac: { label: 'AAC', bitrateControl: true },
|
||
ac3: { label: 'AC-3', bitrateControl: true },
|
||
opus: { label: 'Opus', bitrateControl: true },
|
||
};
|
||
const CONTAINER_FMT = {
|
||
mov: 'MOV (QuickTime)',
|
||
mp4: 'MP4',
|
||
mkv: 'MKV (Matroska)',
|
||
mxf: 'MXF',
|
||
ts: 'TS (MPEG-TS)',
|
||
};
|
||
|
||
// Default recommended codecs per role
|
||
const MASTER_DEFAULT_VIDEO = 'prores_hq';
|
||
const MASTER_DEFAULT_AUDIO = 'pcm_s24le';
|
||
const MASTER_DEFAULT_CONTAINER = 'mov';
|
||
const PROXY_DEFAULT_VIDEO = 'h264';
|
||
const PROXY_DEFAULT_AUDIO = 'aac';
|
||
const PROXY_DEFAULT_CONTAINER = 'mp4';
|
||
|
||
const pState = {
|
||
recorders: [], timers: {}, sourceType: 'sdi', mode: 'caller',
|
||
projects: [], signals: {}, editingId: null,
|
||
// SDI picker state
|
||
bmdDevices: [], // flat list from /cluster/devices/blackmagic
|
||
sdiNodes: [], // grouped by node_id
|
||
selectedNodeId: null,
|
||
selectedDeviceIndex: null,
|
||
};
|
||
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
populateCodecDropdowns();
|
||
wireCodecTabs();
|
||
wireCodecChangeHandlers();
|
||
|
||
await Promise.all([loadRecorders(), loadProjects(), loadBmdDevices()]);
|
||
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('proxyBlock').style.display = e.target.checked ? '' : 'none';
|
||
};
|
||
document.getElementById('recProject').onchange = handleProjectChange;
|
||
updateSourceFields();
|
||
});
|
||
|
||
// ── Codec dropdown population ─────────────
|
||
function populateCodecDropdowns() {
|
||
const fill = (selId, dict, defaultKey) => {
|
||
const sel = document.getElementById(selId);
|
||
if (!sel) return;
|
||
sel.innerHTML = Object.entries(dict).map(([k, v]) => {
|
||
const label = typeof v === 'string' ? v : v.label;
|
||
return `<option value="${k}">${label}</option>`;
|
||
}).join('');
|
||
if (defaultKey) sel.value = defaultKey;
|
||
};
|
||
fill('recCodec', VIDEO_CODECS, MASTER_DEFAULT_VIDEO);
|
||
fill('recAudioCodec', AUDIO_CODECS, MASTER_DEFAULT_AUDIO);
|
||
fill('recContainer', CONTAINER_FMT, MASTER_DEFAULT_CONTAINER);
|
||
fill('proxyCodec', VIDEO_CODECS, PROXY_DEFAULT_VIDEO);
|
||
fill('proxyAudioCodec', AUDIO_CODECS, PROXY_DEFAULT_AUDIO);
|
||
fill('proxyContainer', CONTAINER_FMT, PROXY_DEFAULT_CONTAINER);
|
||
}
|
||
|
||
// ── Codec tabs: simple per-block accordion of panels ──
|
||
function wireCodecTabs() {
|
||
document.querySelectorAll('.codec-tabs').forEach(tabs => {
|
||
const target = tabs.dataset.target;
|
||
tabs.querySelectorAll('.codec-tab').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
tabs.querySelectorAll('.codec-tab').forEach(b => b.classList.toggle('active', b === btn));
|
||
const want = `${target}:${btn.dataset.tab}`;
|
||
document.querySelectorAll(`.codec-tab-panel[data-panel^="${target}:"]`).forEach(p => {
|
||
p.classList.toggle('active', p.dataset.panel === want);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// Show/hide bitrate inputs based on whether the selected codec supports
|
||
// bitrate control. ProRes & DNxHR (profile-driven) hide it; H.264/265 show it.
|
||
function wireCodecChangeHandlers() {
|
||
const sync = () => {
|
||
const masterV = VIDEO_CODECS[document.getElementById('recCodec').value] || {};
|
||
const masterA = AUDIO_CODECS[document.getElementById('recAudioCodec').value] || {};
|
||
document.getElementById('recVideoBitrateRow').style.display = masterV.bitrateControl ? 'grid' : 'none';
|
||
document.getElementById('recFramerateOnlyRow').style.display = masterV.bitrateControl ? 'none' : 'grid';
|
||
document.getElementById('recAudioBitrateRow').style.display = masterA.bitrateControl ? 'grid' : 'none';
|
||
|
||
const proxyV = VIDEO_CODECS[document.getElementById('proxyCodec').value] || {};
|
||
const proxyA = AUDIO_CODECS[document.getElementById('proxyAudioCodec').value] || {};
|
||
document.getElementById('proxyVideoBitrateRow').style.display = proxyV.bitrateControl ? 'grid' : 'none';
|
||
document.getElementById('proxyAudioBitrateRow').style.display = proxyA.bitrateControl ? 'grid' : 'none';
|
||
};
|
||
['recCodec', 'recAudioCodec', 'proxyCodec', 'proxyAudioCodec'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('change', sync);
|
||
});
|
||
sync();
|
||
}
|
||
|
||
// ── Cluster: BMD devices ──────────────────
|
||
async function loadBmdDevices() {
|
||
try {
|
||
const resp = await fetch('/api/v1/cluster/devices/blackmagic', { credentials: 'include' });
|
||
if (!resp.ok) return;
|
||
pState.bmdDevices = await resp.json();
|
||
// Group by node so we can render the per-node card
|
||
const byNode = new Map();
|
||
for (const d of pState.bmdDevices) {
|
||
if (!byNode.has(d.node_id)) {
|
||
byNode.set(d.node_id, {
|
||
node_id: d.node_id,
|
||
hostname: d.hostname,
|
||
ip_address: d.ip_address,
|
||
role: d.role,
|
||
online: d.online,
|
||
model: d.model,
|
||
devices: [],
|
||
});
|
||
}
|
||
byNode.get(d.node_id).devices.push(d);
|
||
}
|
||
pState.sdiNodes = Array.from(byNode.values());
|
||
// Re-render SDI fields if currently visible
|
||
if (pState.sourceType === 'sdi') updateSourceFields();
|
||
} catch (err) {
|
||
console.error('[bmd] load failed', err);
|
||
}
|
||
}
|
||
|
||
// ── 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 (sourceTypeKey === 'sdi') {
|
||
const idx = rec.device_index ?? cfg.device ?? 0;
|
||
// Resolve node hostname + model from the cluster list if we have it
|
||
const dev = pState.bmdDevices.find(d => d.node_id === rec.node_id && d.index === idx);
|
||
if (dev) {
|
||
sourceDisplay = `${dev.hostname} · ${dev.model || 'DeckLink'} · port ${idx + 1}`;
|
||
} else {
|
||
sourceDisplay = `DeckLink port ${idx + 1}`;
|
||
}
|
||
} else if (cfg.mode === 'listener') {
|
||
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.recording_codec ? `<span class="badge badge-idle">${esc(rec.recording_codec)}</span>` : ''}
|
||
${rec.recording_container ? `<span class="badge badge-idle">${esc(rec.recording_container.toUpperCase())}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="recorder-actions">
|
||
${!isRecording ? `<button class="wd-btn wd-btn--ghost wd-btn--sm" onclick="openEditPanel('${rec.id}')" title="Edit recorder" style="padding:0;width:28px;height:28px;">
|
||
<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>
|
||
</button>` : ''}
|
||
<button class="wd-btn wd-btn--ghost wd-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="wd-btn wd-btn--danger wd-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() {
|
||
pState.editingId = null;
|
||
document.getElementById('panelTitle').textContent = 'New recorder';
|
||
document.getElementById('saveRecorderBtn').textContent = 'Create recorder';
|
||
document.getElementById('probeBtn').style.display = '';
|
||
|
||
// Reset form to defaults
|
||
document.getElementById('recName').value = '';
|
||
document.getElementById('recCodec').value = MASTER_DEFAULT_VIDEO;
|
||
document.getElementById('recResolution').value = 'native';
|
||
document.getElementById('recVideoBitrate').value = '';
|
||
document.getElementById('recFramerate').value = '';
|
||
document.getElementById('recFramerateAlt').value = '';
|
||
document.getElementById('recAudioCodec').value = MASTER_DEFAULT_AUDIO;
|
||
document.getElementById('recAudioBitrate').value = '';
|
||
document.getElementById('recAudioChannels').value = '2';
|
||
document.getElementById('recContainer').value = MASTER_DEFAULT_CONTAINER;
|
||
|
||
document.getElementById('proxyToggle').checked = true;
|
||
document.getElementById('proxyBlock').style.display = '';
|
||
document.getElementById('proxyCodec').value = PROXY_DEFAULT_VIDEO;
|
||
document.getElementById('proxyResolution').value = '1920x1080';
|
||
document.getElementById('proxyVideoBitrate').value = '8M';
|
||
document.getElementById('proxyFramerate').value = '';
|
||
document.getElementById('proxyAudioCodec').value = PROXY_DEFAULT_AUDIO;
|
||
document.getElementById('proxyAudioBitrate').value = '192k';
|
||
document.getElementById('proxyAudioChannels').value = '2';
|
||
document.getElementById('proxyContainer').value = PROXY_DEFAULT_CONTAINER;
|
||
|
||
document.getElementById('recProject').value = '';
|
||
document.getElementById('recBin').innerHTML = '<option value="">Project root</option>';
|
||
const pr = document.getElementById('probeResult');
|
||
if (pr) pr.remove();
|
||
|
||
pState.sourceType = 'sdi';
|
||
pState.selectedNodeId = null;
|
||
pState.selectedDeviceIndex = null;
|
||
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === 'sdi'));
|
||
// Make sure tabs are on Video
|
||
document.querySelectorAll('.codec-tabs').forEach(tabs => {
|
||
tabs.querySelectorAll('.codec-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === 'video'));
|
||
});
|
||
document.querySelectorAll('.codec-tab-panel').forEach(p => p.classList.toggle('active', p.dataset.panel.endsWith(':video')));
|
||
|
||
document.getElementById('recorderPanel').classList.add('open');
|
||
document.getElementById('panelOverlay').classList.add('open');
|
||
wireCodecChangeHandlers(); // re-sync visibility
|
||
updateSourceFields();
|
||
}
|
||
|
||
function openEditPanel(recId) {
|
||
const rec = pState.recorders.find(r => r.id === recId);
|
||
if (!rec) return;
|
||
if (rec.status === 'recording') {
|
||
toast('Cannot edit while recording', 'Stop the recorder first', 'warning');
|
||
return;
|
||
}
|
||
|
||
pState.editingId = recId;
|
||
document.getElementById('panelTitle').textContent = 'Edit recorder';
|
||
document.getElementById('saveRecorderBtn').textContent = 'Save changes';
|
||
document.getElementById('probeBtn').style.display = '';
|
||
const pr = document.getElementById('probeResult');
|
||
if (pr) pr.remove();
|
||
|
||
// Basic fields
|
||
document.getElementById('recName').value = rec.name || '';
|
||
|
||
// Master codec
|
||
document.getElementById('recCodec').value = rec.recording_codec || MASTER_DEFAULT_VIDEO;
|
||
document.getElementById('recResolution').value = rec.recording_resolution || 'native';
|
||
document.getElementById('recVideoBitrate').value = rec.recording_video_bitrate || '';
|
||
document.getElementById('recFramerate').value = rec.recording_framerate || '';
|
||
document.getElementById('recFramerateAlt').value = rec.recording_framerate || '';
|
||
document.getElementById('recAudioCodec').value = rec.recording_audio_codec || MASTER_DEFAULT_AUDIO;
|
||
document.getElementById('recAudioBitrate').value = rec.recording_audio_bitrate || '';
|
||
document.getElementById('recAudioChannels').value = String(rec.recording_audio_channels ?? 2);
|
||
document.getElementById('recContainer').value = rec.recording_container || MASTER_DEFAULT_CONTAINER;
|
||
|
||
// Proxy
|
||
const proxyEnabled = rec.proxy_enabled !== false;
|
||
document.getElementById('proxyToggle').checked = proxyEnabled;
|
||
document.getElementById('proxyBlock').style.display = proxyEnabled ? '' : 'none';
|
||
document.getElementById('proxyCodec').value = rec.proxy_codec || PROXY_DEFAULT_VIDEO;
|
||
document.getElementById('proxyResolution').value = rec.proxy_resolution || '1920x1080';
|
||
document.getElementById('proxyVideoBitrate').value = rec.proxy_video_bitrate || '8M';
|
||
document.getElementById('proxyFramerate').value = rec.proxy_framerate || '';
|
||
document.getElementById('proxyAudioCodec').value = rec.proxy_audio_codec || PROXY_DEFAULT_AUDIO;
|
||
document.getElementById('proxyAudioBitrate').value = rec.proxy_audio_bitrate || '192k';
|
||
document.getElementById('proxyAudioChannels').value = String(rec.proxy_audio_channels ?? 2);
|
||
document.getElementById('proxyContainer').value = rec.proxy_container || PROXY_DEFAULT_CONTAINER;
|
||
|
||
wireCodecChangeHandlers();
|
||
|
||
// Source type
|
||
const srcType = (rec.source_type || 'srt').toLowerCase();
|
||
pState.sourceType = srcType;
|
||
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === srcType));
|
||
|
||
// Restore SDI selection if this is an SDI recorder
|
||
if (srcType === 'sdi') {
|
||
pState.selectedNodeId = rec.node_id || null;
|
||
pState.selectedDeviceIndex = rec.device_index ?? (rec.source_config?.device ?? 0);
|
||
} else {
|
||
pState.selectedNodeId = null;
|
||
pState.selectedDeviceIndex = null;
|
||
}
|
||
updateSourceFields();
|
||
|
||
// Populate source-specific fields after updateSourceFields injects DOM nodes
|
||
setTimeout(() => {
|
||
const cfg = rec.source_config || {};
|
||
if (srcType === 'srt') {
|
||
const u = document.getElementById('srtUrl');
|
||
if (u) u.value = cfg.url || '';
|
||
} else if (srcType === 'rtmp') {
|
||
const u = document.getElementById('rtmpUrl');
|
||
if (u) u.value = cfg.url || '';
|
||
}
|
||
}, 0);
|
||
|
||
// Project / bin
|
||
const projSel = document.getElementById('recProject');
|
||
projSel.value = rec.project_id || '';
|
||
document.getElementById('recBin').innerHTML = '<option value="">Project root</option>';
|
||
if (rec.project_id) {
|
||
handleProjectChange().then(() => {
|
||
if (rec.bin_id) document.getElementById('recBin').value = rec.bin_id;
|
||
});
|
||
}
|
||
|
||
document.getElementById('recorderPanel').classList.add('open');
|
||
document.getElementById('panelOverlay').classList.add('open');
|
||
}
|
||
|
||
function closePanel() {
|
||
pState.editingId = null;
|
||
document.getElementById('recorderPanel').classList.remove('open');
|
||
document.getElementById('panelOverlay').classList.remove('open');
|
||
const pr = document.getElementById('probeResult');
|
||
if (pr) pr.remove();
|
||
}
|
||
|
||
// ── 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') {
|
||
renderSdiPicker(container);
|
||
} 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>`;
|
||
} 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>`;
|
||
}
|
||
}
|
||
|
||
// SDI picker = node dropdown + inline BMD card SVG
|
||
function renderSdiPicker(container) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'sdi-picker';
|
||
|
||
// Node selector
|
||
const nodeGroup = document.createElement('div');
|
||
nodeGroup.className = 'form-group';
|
||
nodeGroup.innerHTML = `
|
||
<label class="form-label" for="sdiNode">Capture node</label>
|
||
<select id="sdiNode"></select>
|
||
<div class="form-hint">Pick the cluster node hosting the DeckLink card, then click a port on the diagram below.</div>
|
||
`;
|
||
wrap.appendChild(nodeGroup);
|
||
|
||
// Card host
|
||
const cardWrap = document.createElement('div');
|
||
cardWrap.className = 'sdi-card-wrap';
|
||
cardWrap.id = 'sdiCardWrap';
|
||
wrap.appendChild(cardWrap);
|
||
|
||
container.appendChild(wrap);
|
||
|
||
// Populate node options
|
||
const nodeSel = document.getElementById('sdiNode');
|
||
if (!pState.sdiNodes.length) {
|
||
nodeSel.innerHTML = '<option value="">No DeckLink-capable nodes detected</option>';
|
||
cardWrap.innerHTML = `<div class="sdi-card-empty">
|
||
No nodes in the cluster reported a DeckLink card.<br>
|
||
Connect a worker with BMD hardware and refresh.
|
||
</div>`;
|
||
return;
|
||
}
|
||
nodeSel.innerHTML = pState.sdiNodes.map(n => {
|
||
const onlineMark = n.online ? '' : ' (offline)';
|
||
const modelStr = n.model ? ` · ${n.model}` : '';
|
||
const portCount = n.devices.length;
|
||
return `<option value="${n.node_id}">${esc(n.hostname)}${modelStr} · ${portCount} port${portCount === 1 ? '' : 's'}${onlineMark}</option>`;
|
||
}).join('');
|
||
|
||
// Restore selection or pick first
|
||
if (pState.selectedNodeId && pState.sdiNodes.find(n => n.node_id === pState.selectedNodeId)) {
|
||
nodeSel.value = pState.selectedNodeId;
|
||
} else {
|
||
pState.selectedNodeId = pState.sdiNodes[0].node_id;
|
||
nodeSel.value = pState.selectedNodeId;
|
||
}
|
||
if (pState.selectedDeviceIndex == null) {
|
||
pState.selectedDeviceIndex = 0;
|
||
}
|
||
|
||
nodeSel.addEventListener('change', () => {
|
||
pState.selectedNodeId = nodeSel.value;
|
||
pState.selectedDeviceIndex = 0;
|
||
drawBmdCardForSelectedNode();
|
||
});
|
||
|
||
drawBmdCardForSelectedNode();
|
||
}
|
||
|
||
function drawBmdCardForSelectedNode() {
|
||
const wrap = document.getElementById('sdiCardWrap');
|
||
if (!wrap) return;
|
||
wrap.innerHTML = '';
|
||
|
||
const node = pState.sdiNodes.find(n => n.node_id === pState.selectedNodeId);
|
||
if (!node) {
|
||
wrap.innerHTML = `<div class="sdi-card-empty">Select a node to see its DeckLink card.</div>`;
|
||
return;
|
||
}
|
||
|
||
// Meta strip above the card
|
||
const meta = document.createElement('div');
|
||
meta.className = 'sdi-card-meta';
|
||
meta.innerHTML = `
|
||
<span><strong>Host:</strong> ${esc(node.hostname)}</span>
|
||
${node.ip_address ? `<span><strong>IP:</strong> ${esc(node.ip_address)}</span>` : ''}
|
||
${node.model ? `<span><strong>Model:</strong> ${esc(node.model)}</span>` : ''}
|
||
<span><strong>Ports:</strong> ${node.devices.length}</span>
|
||
<span><strong>Status:</strong> ${node.online ? 'online' : 'offline'}</span>
|
||
`;
|
||
wrap.appendChild(meta);
|
||
|
||
// SVG render
|
||
if (!window.BMDCards) {
|
||
wrap.appendChild(Object.assign(document.createElement('div'), {
|
||
className: 'sdi-card-empty',
|
||
textContent: 'BMD card renderer failed to load.',
|
||
}));
|
||
return;
|
||
}
|
||
const svg = window.BMDCards.render({
|
||
model: node.model,
|
||
deviceCount: node.devices.length,
|
||
selectedIndex: pState.selectedDeviceIndex,
|
||
onSelect: (i) => {
|
||
pState.selectedDeviceIndex = i;
|
||
drawBmdCardForSelectedNode();
|
||
},
|
||
});
|
||
wrap.appendChild(svg);
|
||
}
|
||
|
||
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>`);
|
||
}
|
||
|
||
// ── Probe ─────────────────────────────────
|
||
async function handleProbe() {
|
||
const btn = document.getElementById('probeBtn');
|
||
btn.disabled = true; btn.textContent = 'Probing...';
|
||
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') {
|
||
payload.device = pState.selectedDeviceIndex ?? 0;
|
||
if (pState.selectedNodeId) payload.node_id = pState.selectedNodeId;
|
||
}
|
||
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">' + esc(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;
|
||
}
|
||
|
||
// ── Save recorder (create or update) ──────
|
||
async function handleSaveRecorder() {
|
||
const name = document.getElementById('recName').value.trim();
|
||
if (!name) { toast('Enter a recorder name', '', 'warning'); return; }
|
||
|
||
const type = pState.sourceType;
|
||
const masterCodec = document.getElementById('recCodec').value;
|
||
const masterCodecMeta = VIDEO_CODECS[masterCodec] || {};
|
||
const proxyCodec = document.getElementById('proxyCodec').value;
|
||
const proxyCodecMeta = VIDEO_CODECS[proxyCodec] || {};
|
||
const masterAudioMeta = AUDIO_CODECS[document.getElementById('recAudioCodec').value] || {};
|
||
const proxyAudioMeta = AUDIO_CODECS[document.getElementById('proxyAudioCodec').value] || {};
|
||
|
||
// SDI requires a node + device index
|
||
let nodeId = null;
|
||
let deviceIndex = null;
|
||
let sourceConfig = {};
|
||
if (type === 'sdi') {
|
||
if (!pState.selectedNodeId) {
|
||
toast('Pick an SDI capture node', '', 'warning');
|
||
return;
|
||
}
|
||
nodeId = pState.selectedNodeId;
|
||
deviceIndex = pState.selectedDeviceIndex ?? 0;
|
||
sourceConfig.device = deviceIndex;
|
||
} else if (type === 'srt') {
|
||
sourceConfig.mode = 'caller';
|
||
sourceConfig.url = (document.getElementById('srtUrl')?.value || '').trim();
|
||
if (!sourceConfig.url) { toast('Enter an SRT source URL', '', 'warning'); return; }
|
||
} else if (type === 'rtmp') {
|
||
sourceConfig.mode = 'caller';
|
||
sourceConfig.url = (document.getElementById('rtmpUrl')?.value || '').trim();
|
||
if (!sourceConfig.url) { toast('Enter an RTMP source URL', '', 'warning'); return; }
|
||
}
|
||
|
||
const proxyEnabled = document.getElementById('proxyToggle').checked;
|
||
|
||
// Choose the right framerate field — bitrate-controlled codecs use the
|
||
// "main" framerate (in the same row as bitrate); profile-driven codecs
|
||
// use the alt framerate in its own row.
|
||
const masterFramerate = masterCodecMeta.bitrateControl
|
||
? document.getElementById('recFramerate').value
|
||
: document.getElementById('recFramerateAlt').value;
|
||
|
||
const payload = {
|
||
name,
|
||
source_type: type,
|
||
source_config: sourceConfig,
|
||
|
||
// Master codec
|
||
recording_codec: masterCodec,
|
||
recording_resolution: document.getElementById('recResolution').value,
|
||
recording_video_bitrate: masterCodecMeta.bitrateControl ? (document.getElementById('recVideoBitrate').value.trim() || null) : null,
|
||
recording_framerate: masterFramerate || null,
|
||
recording_audio_codec: document.getElementById('recAudioCodec').value,
|
||
recording_audio_bitrate: masterAudioMeta.bitrateControl ? (document.getElementById('recAudioBitrate').value.trim() || null) : null,
|
||
recording_audio_channels: parseInt(document.getElementById('recAudioChannels').value, 10),
|
||
recording_container: document.getElementById('recContainer').value,
|
||
|
||
// Proxy
|
||
proxy_enabled: proxyEnabled,
|
||
proxy_codec: proxyEnabled ? proxyCodec : undefined,
|
||
proxy_resolution: proxyEnabled ? document.getElementById('proxyResolution').value : undefined,
|
||
proxy_video_bitrate: proxyEnabled && proxyCodecMeta.bitrateControl
|
||
? (document.getElementById('proxyVideoBitrate').value.trim() || null)
|
||
: undefined,
|
||
proxy_framerate: proxyEnabled ? (document.getElementById('proxyFramerate').value || null) : undefined,
|
||
proxy_audio_codec: proxyEnabled ? document.getElementById('proxyAudioCodec').value : undefined,
|
||
proxy_audio_bitrate: proxyEnabled && proxyAudioMeta.bitrateControl
|
||
? (document.getElementById('proxyAudioBitrate').value.trim() || null)
|
||
: undefined,
|
||
proxy_audio_channels: proxyEnabled ? parseInt(document.getElementById('proxyAudioChannels').value, 10) : undefined,
|
||
proxy_container: proxyEnabled ? document.getElementById('proxyContainer').value : undefined,
|
||
|
||
// Pinning
|
||
project_id: document.getElementById('recProject').value || null,
|
||
node_id: nodeId,
|
||
device_index: deviceIndex,
|
||
};
|
||
|
||
if (pState.editingId) {
|
||
const r = await patchRecorder(pState.editingId, payload);
|
||
if (r.success) {
|
||
toast('Recorder updated', name, 'success');
|
||
closePanel();
|
||
await loadRecorders();
|
||
} else toast('Failed to update recorder', r.error, 'error');
|
||
} else {
|
||
const r = await createRecorder(payload);
|
||
if (r.success) {
|
||
toast('Recorder created', name, 'success');
|
||
closePanel();
|
||
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 === null || s === undefined) return '';
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
</script>
|
||
<script src="js/auth-guard.js"></script>
|
||
</body>
|
||
</html>
|