{"result":"File: services/web-ui/public/recorders.html\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <link rel=\"icon\" type=\"image/x-icon\" href=\"favicon.ico\">\n  <title>Recorders — Dragonflight</title>\n  <link rel=\"stylesheet\" href=\"/dist/app.css\">\n  <style>\n    /* ── Recorders page · AMPP-aligned theme ── */\n    .main {\n      background:\n        radial-gradient(ellipse 70% 50% at 50% 0%, oklch(28% 0.10 32 / 0.35), transparent 60%),\n        radial-gradient(ellipse 60% 40% at 90% 100%, oklch(35% 0.16 32 / 0.18), transparent 65%),\n        var(--bg-base);\n    }\n    .page-content { padding: 24px 32px 48px; }\n\n    .recorder-grid {\n      display: grid;\n      grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));\n      gap: 16px;\n    }\n\n    .recorder-card {\n      position: relative;\n      background: oklch(13% 0.018 250 / 0.7);\n      border: 1px solid oklch(28% 0.04 260 / 0.5);\n      border-radius: 12px;\n      padding: 16px 18px 14px;\n      display: flex; flex-direction: column;\n      gap: 12px;\n      backdrop-filter: blur(6px);\n      transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;\n    }\n    .recorder-card:hover { border-color: oklch(45% 0.20 32 / 0.4); }\n    .recorder-card.recording {\n      border-color: oklch(62% 0.22 25 / 0.55);\n      background: oklch(15% 0.025 260 / 0.8);\n      box-shadow: 0 16px 40px -16px oklch(62% 0.22 25 / 0.25);\n    }\n    .recorder-card.error { border-color: oklch(62% 0.22 25 / 0.5); }\n\n    .recorder-header {\n      display: flex; align-items: flex-start; justify-content: space-between;\n      gap: 12px;\n    }\n    .recorder-id { display: flex; flex-direction: column; gap: 6px; min-width: 0; }\n    .recorder-name {\n      font-size: 15px; font-weight: 600;\n      letter-spacing: -0.005em;\n      color: var(--text-primary);\n      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n    }\n    .recorder-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }\n    .recorder-actions { display: flex; gap: 4px; flex-shrink: 0; }\n\n    .recorder-status-row { display: flex; align-items: center; gap: 10px; padding: 2px 0; }\n    .recorder-status-row .text-sm { font-size: 13px; font-weight: 500; }\n    .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; }\n    .recorder-card.recording .recorder-timer { color: oklch(62% 0.22 25); }\n\n    .signal-strip { position: relative; height: 4px; background: oklch(20% 0.04 260); border-radius: 2px; overflow: hidden; }\n    .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; }\n    .signal-strip--warn .signal-strip-fill { background: oklch(82% 0.15 90); }\n    .signal-strip--bad  .signal-strip-fill { background: oklch(62% 0.22 25); animation: none; opacity: 0.4; }\n    .signal-strip--idle .signal-strip-fill { background: oklch(35% 0.04 260); animation: none; opacity: 0.4; }\n    @keyframes signalSlide { 0% { transform: translateX(-100%) } 100% { transform: translateX(100%) } }\n\n    .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); }\n    .recorder-preview video { width: 100%; height: 100%; object-fit: contain; display: block; }\n    .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; }\n    .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); }\n    @keyframes fsPulse { 0%,100% { opacity: 0.7 } 50% { opacity: 1 } }\n\n    .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; }\n    .recorder-source svg { color: var(--text-tertiary); }\n\n    .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); }\n    .recorder-footer-meta { font-size: 11px; color: var(--text-tertiary); font-family: var(--font-mono); letter-spacing: 0.02em; }\n    .recorder-controls { display: flex; gap: 6px; }\n\n    /* Source-type + mode selectors — brand-blue tile feel */\n    .source-type-row, .mode-row { display: flex; gap: 8px; }\n    .source-type-btn, .mode-btn {\n      flex: 1; padding: 10px 12px;\n      background: oklch(13% 0.018 250 / 0.6);\n      border: 1px solid oklch(28% 0.04 260 / 0.45);\n      border-radius: 8px;\n      font-size: 13px; font-weight: 500;\n      color: var(--text-secondary);\n      cursor: pointer; text-align: center;\n      letter-spacing: 0.04em;\n      transition: border-color 120ms ease, background 120ms ease, color 120ms ease;\n    }\n    .source-type-btn:hover, .mode-btn:hover {\n      border-color: oklch(45% 0.20 32 / 0.5);\n      color: var(--text-primary);\n    }\n    .source-type-btn.active, .mode-btn.active {\n      border-color: oklch(55% 0.20 32 / 0.7);\n      background: oklch(20% 0.08 32 / 0.45);\n      color: oklch(78% 0.14 32);\n      font-weight: 600;\n    }\n\n    .form-section-label {\n      font-size: 11px; font-weight: 600;\n      letter-spacing: 0.18em; text-transform: uppercase;\n      color: var(--text-tertiary);\n      padding: 14px 0 8px;\n    }\n\n    .recorder-connect-info { margin-top: 4px; }\n\n    /* Slide panel polish */\n    .slide-panel { background: oklch(11% 0.018 250 / 0.98); border-left: 1px solid oklch(28% 0.04 260 / 0.6); }\n    .slide-panel-header { border-bottom: 1px solid oklch(28% 0.04 260 / 0.4); padding: 18px 22px; }\n    .slide-panel-title { font-size: 15px; font-weight: 600; letter-spacing: -0.005em; }\n    /* Flexbox overflow footgun fix: a flex child won't honour overflow:auto\n       unless we give it min-height:0. Without this, the new codec blocks\n       overflow past the panel bottom and the footer is unreachable. */\n    .slide-panel { height: 100vh; max-height: 100vh; overflow: hidden; }\n    .slide-panel-body {\n      padding: 18px 22px;\n      min-height: 0;\n      flex: 1;\n      overflow-y: auto;\n    }\n\n    /* ── Codec tabs (Video / Audio / Container) ── */\n    .codec-block {\n      border: 1px solid oklch(28% 0.04 260 / 0.45);\n      border-radius: 10px;\n      background: oklch(12% 0.018 250 / 0.55);\n      overflow: hidden;\n    }\n    .codec-block-header {\n      display: flex; align-items: center; justify-content: space-between;\n      padding: 10px 14px;\n      background: oklch(15% 0.025 260 / 0.55);\n      border-bottom: 1px solid oklch(28% 0.04 260 / 0.4);\n    }\n    .codec-block-title {\n      font-size: 12px; font-weight: 600;\n      letter-spacing: 0.14em; text-transform: uppercase;\n      color: var(--text-secondary);\n    }\n    .codec-tabs {\n      display: flex; gap: 0;\n      padding: 0 8px;\n      background: oklch(10% 0.015 250 / 0.5);\n      border-bottom: 1px solid oklch(28% 0.04 260 / 0.4);\n    }\n    .codec-tab {\n      flex: 1;\n      padding: 10px 10px 9px;\n      background: transparent;\n      border: 0;\n      border-bottom: 2px solid transparent;\n      font-size: 12px; font-weight: 500;\n      letter-spacing: 0.08em; text-transform: uppercase;\n      color: var(--text-tertiary);\n      cursor: pointer;\n      transition: color 120ms ease, border-color 120ms ease;\n    }\n    .codec-tab:hover { color: var(--text-secondary); }\n    .codec-tab.active {\n      color: oklch(78% 0.14 32);\n      border-bottom-color: oklch(55% 0.20 32 / 0.85);\n    }\n    .codec-tab-panel { display: none; padding: 14px; }\n    .codec-tab-panel.active { display: block; }\n\n    /* ── SDI picker (node + visual port card) ── */\n    .sdi-picker { display: flex; flex-direction: column; gap: 12px; }\n    .sdi-card-wrap {\n      border: 1px solid oklch(28% 0.04 260 / 0.45);\n      border-radius: 10px;\n      background:\n        radial-gradient(ellipse 60% 40% at 50% 0%, oklch(28% 0.10 32 / 0.18), transparent 70%),\n        oklch(11% 0.018 250 / 0.6);\n      padding: 14px 16px 12px;\n      display: flex; flex-direction: column; gap: 8px;\n    }\n    .sdi-card-empty {\n      padding: 18px;\n      text-align: center;\n      font-size: 12px;\n      color: var(--text-tertiary);\n      font-family: var(--font-mono);\n      letter-spacing: 0.02em;\n    }\n    .sdi-card-meta {\n      display: flex; flex-wrap: wrap; gap: 14px;\n      font-size: 11px; color: var(--text-tertiary);\n      font-family: var(--font-mono); letter-spacing: 0.02em;\n    }\n    .sdi-card-meta strong { color: var(--text-secondary); font-weight: 500; }\n\n    .bmd-card-svg {\n      display: block; width: 100%;\n      max-width: 360px; margin: 0 auto;\n      height: auto;\n    }\n    .bmd-card-body {\n      fill: oklch(18% 0.04 260 / 0.85);\n      stroke: oklch(30% 0.05 260 / 0.7);\n      stroke-width: 1;\n    }\n    .bmd-card-bracket {\n      fill: oklch(60% 0.02 260 / 0.65);\n      stroke: oklch(40% 0.03 260 / 0.8);\n      stroke-width: 1;\n    }\n    .bmd-card-trace {\n      fill: none;\n      stroke: oklch(35% 0.08 32 / 0.4);\n      stroke-width: 0.8;\n      stroke-dasharray: 2 3;\n    }\n    .bmd-card-model {\n      fill: oklch(55% 0.04 260);\n      font: 600 9px/1 var(--font-mono, ui-monospace);\n      letter-spacing: 0.14em;\n    }\n    .bmd-port-ring {\n      fill: oklch(20% 0.03 260);\n      stroke: oklch(45% 0.04 260);\n      stroke-width: 1.4;\n      transition: stroke 120ms ease, fill 120ms ease;\n    }\n    .bmd-port-pin {\n      fill: oklch(70% 0.03 260);\n    }\n    .bmd-port-label {\n      fill: var(--text-secondary, oklch(75% 0.02 260));\n      font: 600 11px/1 ui-sans-serif, system-ui, sans-serif;\n    }\n    .bmd-port-sublabel {\n      fill: var(--text-tertiary, oklch(55% 0.02 260));\n      font: 500 9px/1 var(--font-mono, ui-monospace);\n      letter-spacing: 0.04em;\n    }\n    .bmd-port-group { transition: filter 120ms ease; }\n    .bmd-port-group:hover .bmd-port-ring {\n      stroke: oklch(60% 0.18 32 / 0.8);\n    }\n    .bmd-port-group.is-selected .bmd-port-ring {\n      fill: oklch(30% 0.14 32 / 0.55);\n      stroke: oklch(75% 0.18 32);\n      stroke-width: 2;\n      filter: drop-shadow(0 0 6px oklch(60% 0.20 32 / 0.7));\n    }\n    .bmd-port-group.is-selected .bmd-port-pin {\n      fill: oklch(85% 0.10 32);\n    }\n    .bmd-port-group.is-selected .bmd-port-label {\n      fill: oklch(82% 0.14 32);\n    }\n  </style>\n</head>\n<body>\n<div class=\"wd-shell\" style=\"display:flex;min-height:100vh;\">\n  <!-- Sidebar -->\n  <nav class=\"wd-sidebar\" aria-label=\"Main navigation\">\n    <div class=\"wd-sidebar-header\">\n      <img src=\"img/dragon-logo.png?v=1\" alt=\"Dragonflight\" style=\"width:18px;height:18px;\">\n      <span class=\"wd-sidebar-brand\">Dragonflight</span>\n    </div>\n        <div class=\"wd-sidebar-nav\">\n      <a href=\"home.html\" class=\"wd-nav-item\">\n        <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>\n        Home\n      </a>\n      <a href=\"index.html\" class=\"wd-nav-item\">\n        <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>\n        Library\n      </a>\n      <a href=\"projects.html\" class=\"wd-nav-item\">\n        <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>\n        Projects\n      </a>\n      <a href=\"upload.html\" class=\"wd-nav-item\">\n        <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>\n        Ingest\n      </a>\n      <a href=\"recorders.html\" class=\"wd-nav-item is-active\">\n        <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>\n        Recorders\n      </a>\n      <a href=\"capture.html\" class=\"wd-nav-item\">\n        <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>\n        Capture\n      </a>\n      <a href=\"jobs.html\" class=\"wd-nav-item\">\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M2 4h12M2 8h8M2 12h5\"/></svg>\n        Jobs\n      </a>\n      <a href=\"editor.html\" class=\"wd-nav-item\">\n        <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>\n        Editor\n      </a>\n      <div class=\"wd-sidebar-section\">Admin</div>\n      <a href=\"users.html\" class=\"wd-nav-item\">\n        <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>\n        Users\n      </a>\n      <a href=\"tokens.html\" class=\"wd-nav-item\">\n        <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>\n        Tokens\n      </a>\n      <a href=\"containers.html\" class=\"wd-nav-item\">\n        <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>\n        Containers\n      </a>\n      <a href=\"cluster.html\" class=\"wd-nav-item\">\n        <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>\n        Cluster\n      </a>\n      <a href=\"settings.html\" class=\"wd-nav-item\">\n        <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>\n        Settings\n      </a>\n    </div>\n    <div class=\"wd-sidebar-footer\">\n      <div class=\"wd-sidebar-user\">\n        <div class=\"wd-sidebar-user-avatar\" id=\"userAvatar\">?</div>\n        <div class=\"wd-sidebar-user-info\">\n          <div class=\"wd-sidebar-user-name\" id=\"userName\">—</div>\n          <div class=\"wd-sidebar-user-role\" id=\"userRole\"></div>\n        </div>\n        <button class=\"wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon wd-sidebar-user-logout\" id=\"logoutBtn\" title=\"Sign out\">\n          <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>\n        </button>\n      </div>\n    </div>\n  </nav>\n\n  <div style=\"flex:1;display:flex;flex-direction:column;\">\n    <header class=\"wd-topbar\">\n      <div class=\"wd-topbar-left\">\n        <span class=\"page-title\">Recorders</span>\n      </div>\n      <div class=\"wd-topbar-right\">\n        <button class=\"wd-btn wd-btn--primary wd-btn--sm\" id=\"newRecorderBtn\">\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M8 2v12M2 8h12\"/></svg>\n          New recorder\n        </button>\n      </div>\n    </header>\n\n    <div class=\"page-content\">\n      <div id=\"recorderGrid\" class=\"recorder-grid\"></div>\n      <div id=\"recorderEmpty\" class=\"empty-state\" style=\"display:none;\">\n        <div class=\"empty-state-icon\">\n          <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>\n        </div>\n        <div class=\"empty-state-title\">No recorders yet</div>\n        <div class=\"empty-state-body\">Create a recorder to ingest live streams via SRT, RTMP, or SDI.</div>\n        <div class=\"empty-state-actions\">\n          <button class=\"wd-btn wd-btn--primary wd-btn--sm\" onclick=\"openPanel()\">New recorder</button>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n\n<!-- Slide panel: new/edit recorder -->\n<div class=\"slide-overlay\" id=\"panelOverlay\"></div>\n<div class=\"slide-panel\" id=\"recorderPanel\">\n  <div class=\"slide-panel-header\">\n    <span class=\"slide-panel-title\" id=\"panelTitle\">New recorder</span>\n    <button class=\"wd-btn wd-btn--ghost wd-btn--sm\" id=\"closePanelBtn\" style=\"padding:0;width:28px;height:28px;\">\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M3 3l10 10M13 3L3 13\"/></svg>\n    </button>\n  </div>\n  <div class=\"slide-panel-body\">\n    <!-- Name -->\n    <div class=\"form-group\">\n      <label class=\"form-label\" for=\"recName\">Recorder name</label>\n      <input type=\"text\" id=\"recName\" placeholder=\"e.g. Studio A SRT\">\n    </div>\n\n    <!-- Source type -->\n    <div class=\"form-group\">\n      <label class=\"form-label\">Source type</label>\n      <div class=\"source-type-row\">\n        <button class=\"source-type-btn\" data-type=\"srt\" onclick=\"setSourceType('srt')\">SRT</button>\n        <button class=\"source-type-btn\" data-type=\"rtmp\" onclick=\"setSourceType('rtmp')\">RTMP</button>\n        <button class=\"source-type-btn active\" data-type=\"sdi\" onclick=\"setSourceType('sdi')\">SDI</button>\n      </div>\n    </div>\n\n    <!-- Dynamic source config -->\n    <div id=\"sourceConfigFields\" class=\"conditional-fields\"></div>\n\n    <!-- Master recording codec block -->\n    <div class=\"codec-block\">\n      <div class=\"codec-block-header\">\n        <span class=\"codec-block-title\">Master recording</span>\n      </div>\n      <div class=\"codec-tabs\" role=\"tablist\" data-target=\"master\">\n        <button class=\"codec-tab active\" data-tab=\"video\">Video</button>\n        <button class=\"codec-tab\" data-tab=\"audio\">Audio</button>\n        <button class=\"codec-tab\" data-tab=\"container\">Container</button>\n      </div>\n\n      <!-- Video -->\n      <div class=\"codec-tab-panel active\" data-panel=\"master:video\">\n        <div class=\"form-row\">\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"recCodec\">Video codec</label>\n            <select id=\"recCodec\"></select>\n          </div>\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"recResolution\">Resolution</label>\n            <select id=\"recResolution\">\n              <option value=\"native\">Native (source)</option>\n              <option value=\"3840x2160\">3840×2160 (UHD)</option>\n              <option value=\"1920x1080\">1920×1080 (HD)</option>\n              <option value=\"1280x720\">1280×720</option>\n            </select>\n          </div>\n        </div>\n        <div class=\"form-row\" id=\"recVideoBitrateRow\" style=\"display:none;\">\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"recVideoBitrate\">Video bitrate</label>\n            <input type=\"text\" id=\"recVideoBitrate\" placeholder=\"e.g. 50M, 100M\">\n            <div class=\"form-hint\">ffmpeg <code>-b:v</code> value. Leave empty for codec default.</div>\n          </div>\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"recFramerate\">Framerate</label>\n            <select id=\"recFramerate\">\n              <option value=\"\">Source</option>\n              <option value=\"23.976\">23.976</option>\n              <option value=\"24\">24</option>\n              <option value=\"25\">25</option>\n              <option value=\"29.97\">29.97</option>\n              <option value=\"30\">30</option>\n              <option value=\"50\">50</option>\n              <option value=\"59.94\">59.94</option>\n              <option value=\"60\">60</option>\n            </select>\n          </div>\n        </div>\n        <div class=\"form-row\" id=\"recFramerateOnlyRow\">\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"recFramerateAlt\">Framerate</label>\n            <select id=\"recFramerateAlt\">\n              <option value=\"\">Source</option>\n              <option value=\"23.976\">23.976</option>\n              <option value=\"24\">24</option>\n              <option value=\"25\">25</option>\n              <option value=\"29.97\">29.97</option>\n              <option value=\"30\">30</option>\n              <option value=\"50\">50</option>\n              <option value=\"59.94\">59.94</option>\n              <option value=\"60\">60</option>\n            </select>\n            <div class=\"form-hint\">ProRes / DNxHR pick bitrate from profile + resolution, so only framerate is configurable.</div>\n          </div>\n        </div>\n      </div>\n\n      <!-- Audio -->\n      <div class=\"codec-tab-panel\" data-panel=\"master:audio\">\n        <div class=\"form-row\">\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"recAudioCodec\">Audio codec</label>\n            <select id=\"recAudioCodec\"></select>\n          </div>\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"recAudioChannels\">Channels</label>\n            <select id=\"recAudioChannels\">\n              <option value=\"1\">1 (mono)</option>\n              <option value=\"2\" selected>2 (stereo)</option>\n              <option value=\"4\">4</option>\n              <option value=\"6\">6 (5.1)</option>\n              <option value=\"8\">8 (7.1)</option>\n            </select>\n          </div>\n        </div>\n        <div class=\"form-row\" id=\"recAudioBitrateRow\" style=\"display:none;\">\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"recAudioBitrate\">Audio bitrate</label>\n            <input type=\"text\" id=\"recAudioBitrate\" placeholder=\"e.g. 192k, 320k\">\n            <div class=\"form-hint\">Only applies to compressed audio codecs (AAC, Opus, AC-3).</div>\n          </div>\n        </div>\n      </div>\n\n      <!-- Container -->\n      <div class=\"codec-tab-panel\" data-panel=\"master:container\">\n        <div class=\"form-group\">\n          <label class=\"form-label\" for=\"recContainer\">Container format</label>\n          <select id=\"recContainer\"></select>\n          <div class=\"form-hint\">MOV is recommended for ProRes / DNxHR masters. MXF for broadcast workflows.</div>\n        </div>\n      </div>\n    </div>\n\n    <!-- Proxy toggle -->\n    <div class=\"form-group\">\n      <label class=\"toggle\">\n        <input type=\"checkbox\" id=\"proxyToggle\" checked>\n        <div class=\"toggle-track\"></div>\n        <span class=\"toggle-label\">Generate proxy</span>\n      </label>\n      <div class=\"form-hint\">SDI sources record proxy in parallel. Network sources (SRT/RTMP) generate proxy after stop.</div>\n    </div>\n\n    <!-- Proxy codec block (mirrors master) -->\n    <div class=\"codec-block\" id=\"proxyBlock\">\n      <div class=\"codec-block-header\">\n        <span class=\"codec-block-title\">Proxy</span>\n      </div>\n      <div class=\"codec-tabs\" role=\"tablist\" data-target=\"proxy\">\n        <button class=\"codec-tab active\" data-tab=\"video\">Video</button>\n        <button class=\"codec-tab\" data-tab=\"audio\">Audio</button>\n        <button class=\"codec-tab\" data-tab=\"container\">Container</button>\n      </div>\n\n      <div class=\"codec-tab-panel active\" data-panel=\"proxy:video\">\n        <div class=\"form-row\">\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"proxyCodec\">Video codec</label>\n            <select id=\"proxyCodec\"></select>\n          </div>\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"proxyResolution\">Resolution</label>\n            <select id=\"proxyResolution\">\n              <option value=\"1920x1080\">1920×1080</option>\n              <option value=\"1280x720\">1280×720</option>\n              <option value=\"960x540\">960×540</option>\n              <option value=\"640x360\">640×360</option>\n            </select>\n          </div>\n        </div>\n        <div class=\"form-row\" id=\"proxyVideoBitrateRow\">\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"proxyVideoBitrate\">Video bitrate</label>\n            <input type=\"text\" id=\"proxyVideoBitrate\" value=\"8M\" placeholder=\"e.g. 8M\">\n          </div>\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"proxyFramerate\">Framerate</label>\n            <select id=\"proxyFramerate\">\n              <option value=\"\">Source</option>\n              <option value=\"23.976\">23.976</option>\n              <option value=\"24\">24</option>\n              <option value=\"25\">25</option>\n              <option value=\"29.97\">29.97</option>\n              <option value=\"30\">30</option>\n            </select>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"codec-tab-panel\" data-panel=\"proxy:audio\">\n        <div class=\"form-row\">\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"proxyAudioCodec\">Audio codec</label>\n            <select id=\"proxyAudioCodec\"></select>\n          </div>\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"proxyAudioChannels\">Channels</label>\n            <select id=\"proxyAudioChannels\">\n              <option value=\"1\">1 (mono)</option>\n              <option value=\"2\" selected>2 (stereo)</option>\n            </select>\n          </div>\n        </div>\n        <div class=\"form-row\" id=\"proxyAudioBitrateRow\">\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"proxyAudioBitrate\">Audio bitrate</label>\n            <input type=\"text\" id=\"proxyAudioBitrate\" value=\"192k\" placeholder=\"e.g. 192k\">\n          </div>\n        </div>\n      </div>\n\n      <div class=\"codec-tab-panel\" data-panel=\"proxy:container\">\n        <div class=\"form-group\">\n          <label class=\"form-label\" for=\"proxyContainer\">Container format</label>\n          <select id=\"proxyContainer\"></select>\n        </div>\n      </div>\n    </div>\n\n    <!-- Project / bin -->\n    <div class=\"form-group\">\n      <div class=\"form-section-label\">Destination</div>\n    </div>\n    <div class=\"form-row\">\n      <div class=\"form-group\">\n        <label class=\"form-label\" for=\"recProject\">Project</label>\n        <select id=\"recProject\">\n          <option value=\"\">None (manual assignment)</option>\n        </select>\n      </div>\n      <div class=\"form-group\">\n        <label class=\"form-label\" for=\"recBin\">Bin</label>\n        <select id=\"recBin\">\n          <option value=\"\">Project root</option>\n        </select>\n      </div>\n    </div>\n  </div>\n  <div class=\"slide-panel-footer\">\n    <button class=\"btn btn-ghost\" onclick=\"closePanel()\">Cancel</button>\n    <button class=\"btn btn-secondary\" id=\"probeBtn\">Probe source</button>\n    <button class=\"btn btn-primary\" id=\"saveRecorderBtn\">Create recorder</button>\n  </div>\n</div>\n\n<div class=\"toast-container\" id=\"toastContainer\" aria-live=\"polite\"></div>\n\n<script src=\"js/api.js?v=6\"></script>\n<script src=\"js/topbar-strip.js?v=1\"></script>\n<script src=\"js/bmd-card.js?v=1\"></script>\n<script>\n  // ── Codec catalogues (must match capture-manager.js) ──────────────\n  // Keys are exactly the values VIDEO_CODECS / AUDIO_CODECS / CONTAINER_FMT\n  // accept. `bitrateControl` mirrors the capture-manager flag — when false,\n  // the bitrate input is hidden (ProRes profile drives the bitrate; DNxHR\n  // profile drives the bitrate; PCM/FLAC are constant-bitrate by definition).\n  const VIDEO_CODECS = {\n    prores_hq:    { label: 'ProRes 422 HQ',  bitrateControl: false },\n    prores_422:   { label: 'ProRes 422',     bitrateControl: false },\n    prores_lt:    { label: 'ProRes 422 LT',  bitrateControl: false },\n    prores_proxy: { label: 'ProRes 422 Proxy', bitrateControl: false },\n    dnxhr_hq:     { label: 'DNxHR HQ',       bitrateControl: false },\n    dnxhr_sq:     { label: 'DNxHR SQ',       bitrateControl: false },\n    dnxhd:        { label: 'DNxHD',          bitrateControl: true  },\n    h264:         { label: 'H.264 (libx264)', bitrateControl: true  },\n    h264_nvenc:   { label: 'H.264 NVENC',    bitrateControl: true  },\n    h265:         { label: 'H.265 (libx265)', bitrateControl: true  },\n    hevc_nvenc:   { label: 'HEVC NVENC',     bitrateControl: true  },\n  };\n  const AUDIO_CODECS = {\n    pcm_s16le: { label: 'PCM 16-bit',  bitrateControl: false },\n    pcm_s24le: { label: 'PCM 24-bit',  bitrateControl: false },\n    pcm_s32le: { label: 'PCM 32-bit',  bitrateControl: false },\n    flac:      { label: 'FLAC',        bitrateControl: false },\n    aac:       { label: 'AAC',         bitrateControl: true  },\n    ac3:       { label: 'AC-3',        bitrateControl: true  },\n    opus:      { label: 'Opus',        bitrateControl: true  },\n  };\n  const CONTAINER_FMT = {\n    mov: 'MOV (QuickTime)',\n    mp4: 'MP4',\n    mkv: 'MKV (Matroska)',\n    mxf: 'MXF',\n    ts:  'TS (MPEG-TS)',\n  };\n\n  // Default recommended codecs per role\n  const MASTER_DEFAULT_VIDEO = 'prores_hq';\n  const MASTER_DEFAULT_AUDIO = 'pcm_s24le';\n  const MASTER_DEFAULT_CONTAINER = 'mov';\n  const PROXY_DEFAULT_VIDEO = 'h264';\n  const PROXY_DEFAULT_AUDIO = 'aac';\n  const PROXY_DEFAULT_CONTAINER = 'mp4';\n\n  const pState = {\n    recorders: [], timers: {}, sourceType: 'sdi', mode: 'caller',\n    projects: [], signals: {}, editingId: null,\n    // SDI picker state\n    bmdDevices: [],         // flat list from /cluster/devices/blackmagic\n    sdiNodes: [],           // grouped by node_id\n    selectedNodeId: null,\n    selectedDeviceIndex: null,\n  };\n\n  document.addEventListener('DOMContentLoaded', async () => {\n    populateCodecDropdowns();\n    wireCodecTabs();\n    wireCodecChangeHandlers();\n\n    await Promise.all([loadRecorders(), loadProjects(), loadBmdDevices()]);\n    setInterval(loadRecorders, 5000);\n    setInterval(pollRecordingSignals, 2000);\n\n    // Poll live signal info for every recorder currently in `recording` state\n    async function pollRecordingSignals() {\n      const active = pState.recorders.filter(r => r.status === \"recording\");\n      await Promise.all(active.map(async (rec) => {\n        try {\n          const resp = await fetch(`/api/v1/recorders/${rec.id}/status`, { credentials: \"include\" });\n          if (resp.ok) {\n            const j = await resp.json();\n            pState.signals[rec.id] = j;\n            updateSignalBadge(rec.id, j);\n          }\n        } catch (_) {}\n      }));\n    }\n\n    document.getElementById('newRecorderBtn').onclick = openPanel;\n    document.getElementById('closePanelBtn').onclick = closePanel;\n    document.getElementById('panelOverlay').onclick = closePanel;\n    document.getElementById('saveRecorderBtn').onclick = handleSaveRecorder;\n    document.getElementById('probeBtn').onclick = handleProbe;\n    document.getElementById('proxyToggle').onchange = e => {\n      document.getElementById('proxyBlock').style.display = e.target.checked ? '' : 'none';\n    };\n    document.getElementById('recProject').onchange = handleProjectChange;\n    updateSourceFields();\n  });\n\n  // ── Codec dropdown population ─────────────\n  function populateCodecDropdowns() {\n    const fill = (selId, dict, defaultKey) => {\n      const sel = document.getElementById(selId);\n      if (!sel) return;\n      sel.innerHTML = Object.entries(dict).map(([k, v]) => {\n        const label = typeof v === 'string' ? v : v.label;\n        return `<option value=\"${k}\">${label}</option>`;\n      }).join('');\n      if (defaultKey) sel.value = defaultKey;\n    };\n    fill('recCodec',        VIDEO_CODECS,    MASTER_DEFAULT_VIDEO);\n    fill('recAudioCodec',   AUDIO_CODECS,    MASTER_DEFAULT_AUDIO);\n    fill('recContainer',    CONTAINER_FMT,   MASTER_DEFAULT_CONTAINER);\n    fill('proxyCodec',      VIDEO_CODECS,    PROXY_DEFAULT_VIDEO);\n    fill('proxyAudioCodec', AUDIO_CODECS,    PROXY_DEFAULT_AUDIO);\n    fill('proxyContainer',  CONTAINER_FMT,   PROXY_DEFAULT_CONTAINER);\n  }\n\n  // ── Codec tabs: simple per-block accordion of panels ──\n  function wireCodecTabs() {\n    document.querySelectorAll('.codec-tabs').forEach(tabs => {\n      const target = tabs.dataset.target;\n      tabs.querySelectorAll('.codec-tab').forEach(btn => {\n        btn.addEventListener('click', () => {\n          tabs.querySelectorAll('.codec-tab').forEach(b => b.classList.toggle('active', b === btn));\n          const want = `${target}:${btn.dataset.tab}`;\n          document.querySelectorAll(`.codec-tab-panel[data-panel^=\"${target}:\"]`).forEach(p => {\n            p.classList.toggle('active', p.dataset.panel === want);\n          });\n        });\n      });\n    });\n  }\n\n  // Show/hide bitrate inputs based on whether the selected codec supports\n  // bitrate control. ProRes & DNxHR (profile-driven) hide it; H.264/265 show it.\n  function wireCodecChangeHandlers() {\n    const sync = () => {\n      const masterV = VIDEO_CODECS[document.getElementById('recCodec').value] || {};\n      const masterA = AUDIO_CODECS[document.getElementById('recAudioCodec').value] || {};\n      document.getElementById('recVideoBitrateRow').style.display = masterV.bitrateControl ? 'grid' : 'none';\n      document.getElementById('recFramerateOnlyRow').style.display = masterV.bitrateControl ? 'none' : 'grid';\n      document.getElementById('recAudioBitrateRow').style.display = masterA.bitrateControl ? 'grid' : 'none';\n\n      const proxyV = VIDEO_CODECS[document.getElementById('proxyCodec').value] || {};\n      const proxyA = AUDIO_CODECS[document.getElementById('proxyAudioCodec').value] || {};\n      document.getElementById('proxyVideoBitrateRow').style.display = proxyV.bitrateControl ? 'grid' : 'none';\n      document.getElementById('proxyAudioBitrateRow').style.display = proxyA.bitrateControl ? 'grid' : 'none';\n    };\n    ['recCodec', 'recAudioCodec', 'proxyCodec', 'proxyAudioCodec'].forEach(id => {\n      const el = document.getElementById(id);\n      if (el) el.addEventListener('change', sync);\n    });\n    sync();\n  }\n\n  // ── Cluster: BMD devices ──────────────────\n  async function loadBmdDevices() {\n    try {\n      const resp = await fetch('/api/v1/cluster/devices/blackmagic', { credentials: 'include' });\n      if (!resp.ok) return;\n      pState.bmdDevices = await resp.json();\n      // Group by node so we can render the per-node card\n      const byNode = new Map();\n      for (const d of pState.bmdDevices) {\n        if (!byNode.has(d.node_id)) {\n          byNode.set(d.node_id, {\n            node_id:    d.node_id,\n            hostname:   d.hostname,\n            ip_address: d.ip_address,\n            role:       d.role,\n            online:     d.online,\n            model:      d.model,\n            devices:    [],\n          });\n        }\n        byNode.get(d.node_id).devices.push(d);\n      }\n      pState.sdiNodes = Array.from(byNode.values());\n      // Re-render SDI fields if currently visible\n      if (pState.sourceType === 'sdi') updateSourceFields();\n    } catch (err) {\n      console.error('[bmd] load failed', err);\n    }\n  }\n\n  // ── Load / render ─────────────────────────\n  async function loadRecorders() {\n    const r = await getRecorders();\n    if (!r.success) return;\n    pState.recorders = r.data;\n    renderRecorders();\n  }\n\n  function renderRecorders() {\n    const grid = document.getElementById('recorderGrid');\n    const empty = document.getElementById('recorderEmpty');\n\n    if (!pState.recorders.length) {\n      grid.innerHTML = ''; empty.style.display = 'flex'; return;\n    }\n    empty.style.display = 'none';\n\n    // Skip full DOM rebuild if structure is unchanged — the per-second timer\n    // and the signal poll will update the dynamic fields in place. Rebuilding\n    // every 5s was tearing down the live <video> element and the timer span.\n    const sig = pState.recorders.map(r => r.id + ':' + r.status + ':' + (r.live_asset_id || '') + ':' + (r.started_at || '')).join('|');\n    if (sig === pState._lastRenderSig && grid.children.length === pState.recorders.length) {\n      return;\n    }\n    pState._lastRenderSig = sig;\n\n    grid.innerHTML = pState.recorders.map(rec => {\n      const isRecording = rec.status === 'recording';\n      const cfg = rec.source_config || {};\n      const sourceTypeKey = (rec.source_type || 'sdi').toLowerCase();\n      const badgeClass = { sdi:'badge-sdi', srt:'badge-srt', rtmp:'badge-rtmp' }[sourceTypeKey] || 'badge-idle';\n      const statusClass = isRecording ? 'recording' : rec.status === 'error' ? 'error' : '';\n      const statusDotClass = isRecording ? 'status-dot--recording' : rec.status === 'error' ? 'status-dot--error' : 'status-dot--idle';\n\n      let sourceDisplay = '';\n      if (cfg.url) {\n        sourceDisplay = cfg.url;\n      } else if (sourceTypeKey === 'sdi') {\n        const idx = rec.device_index ?? cfg.device ?? 0;\n        // Resolve node hostname + model from the cluster list if we have it\n        const dev = pState.bmdDevices.find(d => d.node_id === rec.node_id && d.index === idx);\n        if (dev) {\n          sourceDisplay = `${dev.hostname} · ${dev.model || 'DeckLink'} · port ${idx + 1}`;\n        } else {\n          sourceDisplay = `DeckLink port ${idx + 1}`;\n        }\n      } else if (cfg.mode === 'listener') {\n        const port = cfg.listen_port || (sourceTypeKey === 'srt' ? 49001 : 41936);\n        sourceDisplay = `(legacy listener :${port})`;\n      }\n\n      let connectBanner = '';\n      if (!isRecording && cfg.mode === 'listener') {\n        const serverIp = location.hostname || '10.0.0.25';\n        if (sourceTypeKey === 'srt') {\n          const port = cfg.listen_port || 49001;\n          connectBanner = `<div class=\"info-banner recorder-connect-info\">\n            <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>\n            <span>Push to <code>srt://${serverIp}:${port}?mode=caller</code></span>\n          </div>`;\n        } else if (sourceTypeKey === 'rtmp') {\n          const port = cfg.listen_port || 41936;\n          const key = cfg.stream_key || 'stream';\n          connectBanner = `<div class=\"info-banner recorder-connect-info\">\n            <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>\n            <span>Push to <code>rtmp://${serverIp}:${port}/live/${key}</code></span>\n          </div>`;\n        }\n      }\n\n      const lastRec = rec.last_recording_at ? new Date(rec.last_recording_at).toLocaleString() : 'Never';\n\n      return `<div class=\"recorder-card ${statusClass}\" data-id=\"${rec.id}\">\n        <div class=\"recorder-header\">\n          <div class=\"recorder-id\">\n            <div class=\"recorder-name\">${esc(rec.name)}</div>\n            <div class=\"recorder-badges\">\n              <span class=\"badge ${badgeClass}\">${sourceTypeKey.toUpperCase()}</span>\n              ${rec.recording_codec ? `<span class=\"badge badge-idle\">${esc(rec.recording_codec)}</span>` : ''}\n              ${rec.recording_container ? `<span class=\"badge badge-idle\">${esc(rec.recording_container.toUpperCase())}</span>` : ''}\n            </div>\n          </div>\n          <div class=\"recorder-actions\">\n            ${!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;\">\n              <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>\n            </button>` : ''}\n            <button class=\"wd-btn wd-btn--ghost wd-btn--sm\" onclick=\"handleDeleteRecorder('${rec.id}')\" title=\"Delete recorder\" style=\"padding:0;width:28px;height:28px;\">\n              <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M3 4h10M6 4V2h4v2M5 4v9h6V4\"/></svg>\n            </button>\n          </div>\n        </div>\n\n        <div class=\"recorder-status-row\">\n          <span class=\"status-dot ${statusDotClass}\" id=\"statusDot-${rec.id}\"></span>\n          <span class=\"text-sm\" id=\"statusText-${rec.id}\" style=\"color:${isRecording ? 'var(--accent)' : rec.status === 'error' ? 'var(--status-red)' : 'var(--text-secondary)'}\">\n            ${(() => { 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'; })()}\n          </span>\n          ${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>` : ''}\n        </div>\n        ${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>` : ''}\n        ${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>` : ''}\n\n        <div class=\"recorder-source\">\n          <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>\n          <span>${esc(sourceDisplay)}</span>\n        </div>\n\n        ${connectBanner}\n\n        <div class=\"recorder-footer\">\n          <span class=\"recorder-footer-meta\">Last: ${lastRec}</span>\n          <div class=\"recorder-controls\">\n            ${isRecording\n              ? `<button class=\"wd-btn wd-btn--danger wd-btn--sm\" onclick=\"handleStop('${rec.id}')\">Stop</button>`\n              : `<button class=\"btn btn-record btn-sm\" onclick=\"handleStart('${rec.id}')\">\n                  <svg viewBox=\"0 0 14 14\" fill=\"currentColor\" width=\"10\" height=\"10\"><circle cx=\"7\" cy=\"7\" r=\"5\"/></svg>\n                  Record\n                 </button>`\n            }\n          </div>\n        </div>\n      </div>`;\n    }).join('');\n\n    // Start timers for recording recorders\n    pState.recorders.filter(r => r.status === 'recording').forEach(rec => {\n      if (!pState.timers[rec.id]) {\n        const startedAt = rec.started_at ? new Date(rec.started_at) : new Date();\n        pState.timers[rec.id] = setInterval(() => {\n          const el = document.getElementById(`timer-${rec.id}`);\n          if (!el) { clearInterval(pState.timers[rec.id]); delete pState.timers[rec.id]; return; }\n          const elapsed = Math.floor((Date.now() - startedAt) / 1000);\n          el.textContent = formatDur(elapsed);\n        }, 500);\n      }\n    });\n    Object.keys(pState.timers).forEach(id => {\n      if (!pState.recorders.find(r => r.id === id && r.status === 'recording')) {\n        clearInterval(pState.timers[id]); delete pState.timers[id];\n      }\n    });\n    // Attach an HLS source to each live-preview <video> on the page.\n    pState.recorders.filter(r => r.status === 'recording' && r.live_asset_id).forEach(rec => {\n      const v = document.getElementById('livevideo-' + rec.id);\n      if (!v || v.dataset.attached === '1') return;\n      const url = '/live/' + rec.live_asset_id + '/index.m3u8';\n      const attach = () => {\n        if (v.canPlayType('application/vnd.apple.mpegurl')) {\n          v.src = url; v.play().catch(() => {});\n        } else if (window.Hls && window.Hls.isSupported()) {\n          const hls = new Hls({ lowLatencyMode: true, liveSyncDuration: 2, liveMaxLatencyDuration: 6 });\n          hls.loadSource(url); hls.attachMedia(v);\n          hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(() => {}));\n          v._hls = hls;\n        }\n        v.dataset.attached = '1';\n      };\n      if (window.Hls) attach();\n      else {\n        const sc = document.createElement('script');\n        sc.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.0/dist/hls.min.js';\n        sc.onload = attach;\n        document.head.appendChild(sc);\n      }\n    });\n    document.querySelectorAll('video[data-live-id]').forEach(v => {\n      const id = v.id.replace('livevideo-', '');\n      const rec = pState.recorders.find(r => r.id === id);\n      if (!rec || rec.status !== 'recording') {\n        try { if (v._hls) { v._hls.destroy(); delete v._hls; } } catch (_) {}\n        v.removeAttribute('src');\n      }\n    });\n  }\n\n  function updateSignalBadge(rid, st) {\n    const el = document.getElementById('signal-' + rid);\n    const sig = st.signal || 'connecting';\n    if (el) {\n      const detail = {\n        connecting: 'Waiting for stream...',\n        receiving: 'Receiving • ' + (st.framesReceived || 0) + ' fr • ' + Math.round(st.currentFps || 0) + ' fps',\n        lost: 'No signal — stream dropped',\n        error: st.lastError ? st.lastError : 'Connection error',\n        stopped: 'Stopped',\n      };\n      const col = {\n        connecting: 'var(--status-yellow, oklch(82% 0.15 90))',\n        receiving: 'var(--status-green, oklch(68% 0.18 148))',\n        lost: 'var(--status-red, oklch(62% 0.22 25))',\n        error: 'var(--status-red, oklch(62% 0.22 25))',\n        stopped: 'var(--text-tertiary)',\n      };\n      el.textContent = detail[sig] || sig;\n      el.style.color = col[sig] || 'var(--text-tertiary)';\n      el.title = st.lastError || '';\n    }\n    const mainTxt = document.getElementById('statusText-' + rid);\n    const mainDot = document.getElementById('statusDot-' + rid);\n    if (mainTxt && mainDot) {\n      const mainLabel = { connecting: 'Connecting...', receiving: 'Recording', lost: 'Signal lost', error: 'Connection error' }[sig] || 'Recording';\n      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)';\n      mainTxt.textContent = mainLabel;\n      mainTxt.style.color = mainCol;\n      mainDot.style.background = mainCol;\n    }\n    const strip = document.getElementById('signalStrip-' + rid);\n    if (strip) {\n      strip.classList.remove('signal-strip--warn', 'signal-strip--bad', 'signal-strip--idle');\n      if (sig === 'connecting') strip.classList.add('signal-strip--warn');\n      else if (sig === 'lost' || sig === 'error') strip.classList.add('signal-strip--bad');\n    }\n  }\n\n  function formatDur(s) {\n    const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;\n    return [h, m, sec].map(v => String(v).padStart(2,'0')).join(':');\n  }\n\n  // ── Controls ──────────────────────────────\n  async function handleStart(id) {\n    const r = await startRecorder(id);\n    if (r.success) { toast('Recording started', '', 'success'); loadRecorders(); }\n    else toast('Failed to start', r.error, 'error');\n  }\n\n  async function handleStop(id) {\n    const r = await stopRecorder(id);\n    if (r.success) { toast('Recording stopped', '', 'success'); loadRecorders(); }\n    else toast('Failed to stop', r.error, 'error');\n  }\n\n  async function handleDeleteRecorder(id) {\n    if (!confirm('Delete this recorder?')) return;\n    const r = await deleteRecorder(id);\n    if (r.success) { toast('Recorder deleted', '', 'success'); loadRecorders(); }\n    else toast('Delete failed', r.error, 'error');\n  }\n\n  // ── Panel ─────────────────────────────────\n  function openPanel() {\n    pState.editingId = null;\n    document.getElementById('panelTitle').textContent = 'New recorder';\n    document.getElementById('saveRecorderBtn').textContent = 'Create recorder';\n    document.getElementById('probeBtn').style.display = '';\n\n    // Reset form to defaults\n    document.getElementById('recName').value = '';\n    document.getElementById('recCodec').value = MASTER_DEFAULT_VIDEO;\n    document.getElementById('recResolution').value = 'native';\n    document.getElementById('recVideoBitrate').value = '';\n    document.getElementById('recFramerate').value = '';\n    document.getElementById('recFramerateAlt').value = '';\n    document.getElementById('recAudioCodec').value = MASTER_DEFAULT_AUDIO;\n    document.getElementById('recAudioBitrate').value = '';\n    document.getElementById('recAudioChannels').value = '2';\n    document.getElementById('recContainer').value = MASTER_DEFAULT_CONTAINER;\n\n    document.getElementById('proxyToggle').checked = true;\n    document.getElementById('proxyBlock').style.display = '';\n    document.getElementById('proxyCodec').value = PROXY_DEFAULT_VIDEO;\n    document.getElementById('proxyResolution').value = '1920x1080';\n    document.getElementById('proxyVideoBitrate').value = '8M';\n    document.getElementById('proxyFramerate').value = '';\n    document.getElementById('proxyAudioCodec').value = PROXY_DEFAULT_AUDIO;\n    document.getElementById('proxyAudioBitrate').value = '192k';\n    document.getElementById('proxyAudioChannels').value = '2';\n    document.getElementById('proxyContainer').value = PROXY_DEFAULT_CONTAINER;\n\n    document.getElementById('recProject').value = '';\n    document.getElementById('recBin').innerHTML = '<option value=\"\">Project root</option>';\n    const pr = document.getElementById('probeResult');\n    if (pr) pr.remove();\n\n    pState.sourceType = 'sdi';\n    pState.selectedNodeId = null;\n    pState.selectedDeviceIndex = null;\n    document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === 'sdi'));\n    // Make sure tabs are on Video\n    document.querySelectorAll('.codec-tabs').forEach(tabs => {\n      tabs.querySelectorAll('.codec-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === 'video'));\n    });\n    document.querySelectorAll('.codec-tab-panel').forEach(p => p.classList.toggle('active', p.dataset.panel.endsWith(':video')));\n\n    document.getElementById('recorderPanel').classList.add('open');\n    document.getElementById('panelOverlay').classList.add('open');\n    wireCodecChangeHandlers(); // re-sync visibility\n    updateSourceFields();\n  }\n\n  function openEditPanel(recId) {\n    const rec = pState.recorders.find(r => r.id === recId);\n    if (!rec) return;\n    if (rec.status === 'recording') {\n      toast('Cannot edit while recording', 'Stop the recorder first', 'warning');\n      return;\n    }\n\n    pState.editingId = recId;\n    document.getElementById('panelTitle').textContent = 'Edit recorder';\n    document.getElementById('saveRecorderBtn').textContent = 'Save changes';\n    document.getElementById('probeBtn').style.display = '';\n    const pr = document.getElementById('probeResult');\n    if (pr) pr.remove();\n\n    // Basic fields\n    document.getElementById('recName').value = rec.name || '';\n\n    // Master codec\n    document.getElementById('recCodec').value          = rec.recording_codec        || MASTER_DEFAULT_VIDEO;\n    document.getElementById('recResolution').value     = rec.recording_resolution   || 'native';\n    document.getElementById('recVideoBitrate').value   = rec.recording_video_bitrate || '';\n    document.getElementById('recFramerate').value      = rec.recording_framerate     || '';\n    document.getElementById('recFramerateAlt').value   = rec.recording_framerate     || '';\n    document.getElementById('recAudioCodec').value     = rec.recording_audio_codec   || MASTER_DEFAULT_AUDIO;\n    document.getElementById('recAudioBitrate').value   = rec.recording_audio_bitrate || '';\n    document.getElementById('recAudioChannels').value  = String(rec.recording_audio_channels ?? 2);\n    document.getElementById('recContainer').value      = rec.recording_container     || MASTER_DEFAULT_CONTAINER;\n\n    // Proxy\n    const proxyEnabled = rec.proxy_enabled !== false;\n    document.getElementById('proxyToggle').checked = proxyEnabled;\n    document.getElementById('proxyBlock').style.display = proxyEnabled ? '' : 'none';\n    document.getElementById('proxyCodec').value         = rec.proxy_codec          || PROXY_DEFAULT_VIDEO;\n    document.getElementById('proxyResolution').value    = rec.proxy_resolution     || '1920x1080';\n    document.getElementById('proxyVideoBitrate').value  = rec.proxy_video_bitrate  || '8M';\n    document.getElementById('proxyFramerate').value     = rec.proxy_framerate      || '';\n    document.getElementById('proxyAudioCodec').value    = rec.proxy_audio_codec    || PROXY_DEFAULT_AUDIO;\n    document.getElementById('proxyAudioBitrate').value  = rec.proxy_audio_bitrate  || '192k';\n    document.getElementById('proxyAudioChannels').value = String(rec.proxy_audio_channels ?? 2);\n    document.getElementById('proxyContainer').value     = rec.proxy_container      || PROXY_DEFAULT_CONTAINER;\n\n    wireCodecChangeHandlers();\n\n    // Source type\n    const srcType = (rec.source_type || 'srt').toLowerCase();\n    pState.sourceType = srcType;\n    document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === srcType));\n\n    // Restore SDI selection if this is an SDI recorder\n    if (srcType === 'sdi') {\n      pState.selectedNodeId = rec.node_id || null;\n      pState.selectedDeviceIndex = rec.device_index ?? (rec.source_config?.device ?? 0);\n    } else {\n      pState.selectedNodeId = null;\n      pState.selectedDeviceIndex = null;\n    }\n    updateSourceFields();\n\n    // Populate source-specific fields after updateSourceFields injects DOM nodes\n    setTimeout(() => {\n      const cfg = rec.source_config || {};\n      if (srcType === 'srt') {\n        const u = document.getElementById('srtUrl');\n        if (u) u.value = cfg.url || '';\n      } else if (srcType === 'rtmp') {\n        const u = document.getElementById('rtmpUrl');\n        if (u) u.value = cfg.url || '';\n      }\n    }, 0);\n\n    // Project / bin\n    const projSel = document.getElementById('recProject');\n    projSel.value = rec.project_id || '';\n    document.getElementById('recBin').innerHTML = '<option value=\"\">Project root</option>';\n    if (rec.project_id) {\n      handleProjectChange().then(() => {\n        if (rec.bin_id) document.getElementById('recBin').value = rec.bin_id;\n      });\n    }\n\n    document.getElementById('recorderPanel').classList.add('open');\n    document.getElementById('panelOverlay').classList.add('open');\n  }\n\n  function closePanel() {\n    pState.editingId = null;\n    document.getElementById('recorderPanel').classList.remove('open');\n    document.getElementById('panelOverlay').classList.remove('open');\n    const pr = document.getElementById('probeResult');\n    if (pr) pr.remove();\n  }\n\n  // ── Source type ───────────────────────────\n  function setSourceType(type) {\n    pState.sourceType = type;\n    pState.mode = 'caller';\n    document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === type));\n    updateSourceFields();\n  }\n\n  function updateSourceFields() {\n    const container = document.getElementById('sourceConfigFields');\n    const type = pState.sourceType;\n    container.innerHTML = '';\n\n    if (type === 'sdi') {\n      renderSdiPicker(container);\n    } else if (type === 'srt') {\n      container.innerHTML = `\n        <div id=\"srtCallerFields\">\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"srtUrl\">Source URL</label>\n            <input type=\"url\" id=\"srtUrl\" placeholder=\"srt://192.168.1.100:4200\">\n            <div class=\"form-hint\">The recorder will connect out to this URL (caller mode). <code>?mode=caller</code> is appended automatically.</div>\n          </div>\n        </div>`;\n    } else if (type === 'rtmp') {\n      container.innerHTML = `\n        <div id=\"rtmpCallerFields\">\n          <div class=\"form-group\">\n            <label class=\"form-label\" for=\"rtmpUrl\">Source URL</label>\n            <input type=\"url\" id=\"rtmpUrl\" placeholder=\"rtmp://server/live/streamkey\">\n            <div class=\"form-hint\">The recorder will pull this RTMP stream. Must be an existing published stream on an RTMP server.</div>\n          </div>\n        </div>`;\n    }\n  }\n\n  // SDI picker = node dropdown + inline BMD card SVG\n  function renderSdiPicker(container) {\n    const wrap = document.createElement('div');\n    wrap.className = 'sdi-picker';\n\n    // Node selector\n    const nodeGroup = document.createElement('div');\n    nodeGroup.className = 'form-group';\n    nodeGroup.innerHTML = `\n      <label class=\"form-label\" for=\"sdiNode\">Capture node</label>\n      <select id=\"sdiNode\"></select>\n      <div class=\"form-hint\">Pick the cluster node hosting the DeckLink card, then click a port on the diagram below.</div>\n    `;\n    wrap.appendChild(nodeGroup);\n\n    // Card host\n    const cardWrap = document.createElement('div');\n    cardWrap.className = 'sdi-card-wrap';\n    cardWrap.id = 'sdiCardWrap';\n    wrap.appendChild(cardWrap);\n\n    container.appendChild(wrap);\n\n    // Populate node options\n    const nodeSel = document.getElementById('sdiNode');\n    if (!pState.sdiNodes.length) {\n      nodeSel.innerHTML = '<option value=\"\">No DeckLink-capable nodes detected</option>';\n      cardWrap.innerHTML = `<div class=\"sdi-card-empty\">\n        No nodes in the cluster reported a DeckLink card.<br>\n        Connect a worker with BMD hardware and refresh.\n      </div>`;\n      return;\n    }\n    nodeSel.innerHTML = pState.sdiNodes.map(n => {\n      const onlineMark = n.online ? '' : ' (offline)';\n      const modelStr   = n.model ? ` · ${n.model}` : '';\n      const portCount  = n.devices.length;\n      return `<option value=\"${n.node_id}\">${esc(n.hostname)}${modelStr} · ${portCount} port${portCount === 1 ? '' : 's'}${onlineMark}</option>`;\n    }).join('');\n\n    // Restore selection or pick first\n    if (pState.selectedNodeId && pState.sdiNodes.find(n => n.node_id === pState.selectedNodeId)) {\n      nodeSel.value = pState.selectedNodeId;\n    } else {\n      pState.selectedNodeId = pState.sdiNodes[0].node_id;\n      nodeSel.value = pState.selectedNodeId;\n    }\n    if (pState.selectedDeviceIndex == null) {\n      pState.selectedDeviceIndex = 0;\n    }\n\n    nodeSel.addEventListener('change', () => {\n      pState.selectedNodeId = nodeSel.value;\n      pState.selectedDeviceIndex = 0;\n      drawBmdCardForSelectedNode();\n    });\n\n    drawBmdCardForSelectedNode();\n  }\n\n  function drawBmdCardForSelectedNode() {\n    const wrap = document.getElementById('sdiCardWrap');\n    if (!wrap) return;\n    wrap.innerHTML = '';\n\n    const node = pState.sdiNodes.find(n => n.node_id === pState.selectedNodeId);\n    if (!node) {\n      wrap.innerHTML = `<div class=\"sdi-card-empty\">Select a node to see its DeckLink card.</div>`;\n      return;\n    }\n\n    // Meta strip above the card\n    const meta = document.createElement('div');\n    meta.className = 'sdi-card-meta';\n    meta.innerHTML = `\n      <span><strong>Host:</strong> ${esc(node.hostname)}</span>\n      ${node.ip_address ? `<span><strong>IP:</strong> ${esc(node.ip_address)}</span>` : ''}\n      ${node.model ? `<span><strong>Model:</strong> ${esc(node.model)}</span>` : ''}\n      <span><strong>Ports:</strong> ${node.devices.length}</span>\n      <span><strong>Status:</strong> ${node.online ? 'online' : 'offline'}</span>\n    `;\n    wrap.appendChild(meta);\n\n    // SVG render\n    if (!window.BMDCards) {\n      wrap.appendChild(Object.assign(document.createElement('div'), {\n        className: 'sdi-card-empty',\n        textContent: 'BMD card renderer failed to load.',\n      }));\n      return;\n    }\n    const svg = window.BMDCards.render({\n      model: node.model,\n      deviceCount: node.devices.length,\n      selectedIndex: pState.selectedDeviceIndex,\n      onSelect: (i) => {\n        pState.selectedDeviceIndex = i;\n        drawBmdCardForSelectedNode();\n      },\n    });\n    wrap.appendChild(svg);\n  }\n\n  function setMode(_mode) {\n    // Listener mode UI was removed - all recorders are caller (pull) mode now.\n    pState.mode = 'caller';\n  }\n\n  // ── Projects for recorder destination ─────\n  async function loadProjects() {\n    const r = await getProjects();\n    if (!r.success) return;\n    pState.projects = r.data;\n    const sel = document.getElementById('recProject');\n    sel.innerHTML = '<option value=\"\">None (manual assignment)</option>' +\n      r.data.map(p => `<option value=\"${p.id}\">${esc(p.name)}</option>`).join('');\n  }\n\n  async function handleProjectChange() {\n    const projectId = document.getElementById('recProject').value;\n    const binSel = document.getElementById('recBin');\n    binSel.innerHTML = '<option value=\"\">Project root</option>';\n    if (!projectId) return;\n    const r = await getBins(projectId);\n    if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value=\"${b.id}\">${esc(b.name)}</option>`);\n  }\n\n  // ── Probe ─────────────────────────────────\n  async function handleProbe() {\n    const btn = document.getElementById('probeBtn');\n    btn.disabled = true; btn.textContent = 'Probing...';\n    const type = pState.sourceType;\n    const payload = { source_type: type };\n    if (type === 'srt' && document.getElementById('srtUrl')) {\n      payload.source_url = document.getElementById('srtUrl').value.trim();\n    } else if (type === 'rtmp' && document.getElementById('rtmpUrl')) {\n      payload.source_url = document.getElementById('rtmpUrl').value.trim();\n    } else if (type === 'sdi') {\n      payload.device = pState.selectedDeviceIndex ?? 0;\n      if (pState.selectedNodeId) payload.node_id = pState.selectedNodeId;\n    }\n    try {\n      const r = await fetch('/api/v1/recorders/probe', {\n        method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',\n        body: JSON.stringify(payload),\n      });\n      const data = await r.json();\n      renderProbeResult(data);\n    } catch (err) {\n      renderProbeResult({ ok: false, error: 'Network error: ' + err.message });\n    } finally {\n      btn.disabled = false; btn.textContent = 'Probe source';\n    }\n  }\n\n  function renderProbeResult(d) {\n    let host = document.getElementById('probeResult');\n    if (!host) {\n      host = document.createElement('div');\n      host.id = 'probeResult';\n      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)';\n      const footer = document.querySelector('.slide-panel-footer');\n      footer.parentElement.insertBefore(host, footer);\n    }\n    if (!d.ok) {\n      host.style.borderColor = 'oklch(62% 0.22 25 / 0.5)';\n      host.style.background = 'oklch(62% 0.22 25 / 0.08)';\n      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>';\n      return;\n    }\n    host.style.borderColor = 'oklch(68% 0.18 148 / 0.5)';\n    host.style.background = 'oklch(68% 0.18 148 / 0.08)';\n    renderProbeOk(host, d);\n  }\n\n  function renderProbeOk(host, d) {\n    if (d.source_type === 'sdi') {\n      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>';\n      return;\n    }\n    const v = (d.streams || []).find(s => s.codec_type === 'video');\n    const a = (d.streams || []).find(s => s.codec_type === 'audio');\n    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\">';\n    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>';\n    if (a) html += '<div><strong>Audio:</strong> ' + esc(a.codec_name || '?') + ' • ' + (a.sample_rate || '?') + ' Hz • ' + (a.channels || '?') + ' ch</div>';\n    html += '</div>';\n    host.innerHTML = html;\n  }\n\n  // ── Save recorder (create or update) ──────\n  async function handleSaveRecorder() {\n    const name = document.getElementById('recName').value.trim();\n    if (!name) { toast('Enter a recorder name', '', 'warning'); return; }\n\n    const type = pState.sourceType;\n    const masterCodec = document.getElementById('recCodec').value;\n    const masterCodecMeta = VIDEO_CODECS[masterCodec] || {};\n    const proxyCodec = document.getElementById('proxyCodec').value;\n    const proxyCodecMeta = VIDEO_CODECS[proxyCodec] || {};\n    const masterAudioMeta = AUDIO_CODECS[document.getElementById('recAudioCodec').value] || {};\n    const proxyAudioMeta = AUDIO_CODECS[document.getElementById('proxyAudioCodec').value] || {};\n\n    // SDI requires a node + device index\n    let nodeId = null;\n    let deviceIndex = null;\n    let sourceConfig = {};\n    if (type === 'sdi') {\n      if (!pState.selectedNodeId) {\n        toast('Pick an SDI capture node', '', 'warning');\n        return;\n      }\n      nodeId = pState.selectedNodeId;\n      deviceIndex = pState.selectedDeviceIndex ?? 0;\n      sourceConfig.device = deviceIndex;\n    } else if (type === 'srt') {\n      sourceConfig.mode = 'caller';\n      sourceConfig.url = (document.getElementById('srtUrl')?.value || '').trim();\n      if (!sourceConfig.url) { toast('Enter an SRT source URL', '', 'warning'); return; }\n    } else if (type === 'rtmp') {\n      sourceConfig.mode = 'caller';\n      sourceConfig.url = (document.getElementById('rtmpUrl')?.value || '').trim();\n      if (!sourceConfig.url) { toast('Enter an RTMP source URL', '', 'warning'); return; }\n    }\n\n    const proxyEnabled = document.getElementById('proxyToggle').checked;\n\n    // Choose the right framerate field — bitrate-controlled codecs use the\n    // \"main\" framerate (in the same row as bitrate); profile-driven codecs\n    // use the alt framerate in its own row.\n    const masterFramerate = masterCodecMeta.bitrateControl\n      ? document.getElementById('recFramerate').value\n      : document.getElementById('recFramerateAlt').value;\n\n    const payload = {\n      name,\n      source_type: type,\n      source_config: sourceConfig,\n\n      // Master codec\n      recording_codec:           masterCodec,\n      recording_resolution:      document.getElementById('recResolution').value,\n      recording_video_bitrate:   masterCodecMeta.bitrateControl ? (document.getElementById('recVideoBitrate').value.trim() || null) : null,\n      recording_framerate:       masterFramerate || null,\n      recording_audio_codec:     document.getElementById('recAudioCodec').value,\n      recording_audio_bitrate:   masterAudioMeta.bitrateControl ? (document.getElementById('recAudioBitrate').value.trim() || null) : null,\n      recording_audio_channels:  parseInt(document.getElementById('recAudioChannels').value, 10),\n      recording_container:       document.getElementById('recContainer').value,\n\n      // Proxy\n      proxy_enabled:             proxyEnabled,\n      proxy_codec:               proxyEnabled ? proxyCodec : undefined,\n      proxy_resolution:          proxyEnabled ? document.getElementById('proxyResolution').value : undefined,\n      proxy_video_bitrate:       proxyEnabled && proxyCodecMeta.bitrateControl\n                                   ? (document.getElementById('proxyVideoBitrate').value.trim() || null)\n                                   : undefined,\n      proxy_framerate:           proxyEnabled ? (document.getElementById('proxyFramerate').value || null) : undefined,\n      proxy_audio_codec:         proxyEnabled ? document.getElementById('proxyAudioCodec').value : undefined,\n      proxy_audio_bitrate:       proxyEnabled && proxyAudioMeta.bitrateControl\n                                   ? (document.getElementById('proxyAudioBitrate').value.trim() || null)\n                                   : undefined,\n      proxy_audio_channels:      proxyEnabled ? parseInt(document.getElementById('proxyAudioChannels').value, 10) : undefined,\n      proxy_container:           proxyEnabled ? document.getElementById('proxyContainer').value : undefined,\n\n      // Pinning\n      project_id:   document.getElementById('recProject').value || null,\n      node_id:      nodeId,\n      device_index: deviceIndex,\n    };\n\n    if (pState.editingId) {\n      const r = await patchRecorder(pState.editingId, payload);\n      if (r.success) {\n        toast('Recorder updated', name, 'success');\n        closePanel();\n        await loadRecorders();\n      } else toast('Failed to update recorder', r.error, 'error');\n    } else {\n      const r = await createRecorder(payload);\n      if (r.success) {\n        toast('Recorder created', name, 'success');\n        closePanel();\n        await loadRecorders();\n      } else toast('Failed to create recorder', r.error, 'error');\n    }\n  }\n\n  function toast(title, msg, type = 'info') {\n    const el = document.createElement('div');\n    el.className = `toast toast--${type}`;\n    el.innerHTML = `<div class=\"toast-body\"><div class=\"toast-title\">${esc(title)}</div>${msg ? `<div class=\"toast-msg\">${esc(msg)}</div>` : ''}</div>`;\n    document.getElementById('toastContainer').appendChild(el);\n    setTimeout(() => el.remove(), 4000);\n  }\n\n  function esc(s) {\n    if (s === null || s === undefined) return '';\n    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;');\n  }\n</script>\n<script src=\"js/auth-guard.js\"></script>\n</body>\n</html>\n"}