|
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>fm-rds-tx</title>
- <style>
- @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Archivo+Black&display=swap');
-
- :root {
- --bg: #0a0a0c;
- --bg-2: #0f1015;
- --surface: #111116;
- --surface2: #18181e;
- --surface3: #1f2028;
- --border: #2a2a35;
- --border-strong: #3a3a49;
- --text: #d4d4dc;
- --text-dim: #8b8b99;
- --text-muted: #666674;
- --accent: #ff3b30;
- --accent-glow: #ff3b3044;
- --green: #30d158;
- --green-glow: #30d15844;
- --amber: #ff9f0a;
- --amber-glow: #ff9f0a44;
- --blue: #0a84ff;
- --blue-glow: #0a84ff33;
- --mono: 'JetBrains Mono', monospace;
- --display: 'Archivo Black', sans-serif;
- --radius: 8px;
- --shadow: 0 10px 30px rgba(0,0,0,.25);
- }
-
- * { box-sizing: border-box; margin: 0; padding: 0; }
- html { color-scheme: dark; }
- body {
- background:
- radial-gradient(circle at top right, rgba(10,132,255,.06), transparent 28%),
- radial-gradient(circle at top left, rgba(255,59,48,.06), transparent 30%),
- var(--bg);
- color: var(--text);
- font-family: var(--mono);
- font-size: 13px;
- line-height: 1.5;
- min-height: 100vh;
- overflow-x: hidden;
- }
-
- body::before {
- content: '';
- position: fixed; inset: 0;
- background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.015) 2px, rgba(255,255,255,0.015) 4px);
- pointer-events: none;
- z-index: 1000;
- }
-
- button, input { font: inherit; }
- button { user-select: none; }
-
- .app {
- max-width: 1120px;
- margin: 0 auto;
- padding: 18px;
- }
-
- .header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 18px;
- padding: 8px 0 22px;
- border-bottom: 1px solid var(--border);
- margin-bottom: 18px;
- }
- .header-main {
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
- .header h1 {
- font-family: var(--display);
- font-size: 24px;
- letter-spacing: 2px;
- text-transform: uppercase;
- color: var(--accent);
- text-shadow: 0 0 20px var(--accent-glow), 0 0 40px var(--accent-glow);
- }
- .header-sub {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- }
- .badge {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- min-height: 28px;
- padding: 0 10px;
- border: 1px solid var(--border);
- border-radius: 999px;
- background: rgba(255,255,255,0.02);
- color: var(--text-dim);
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: .8px;
- }
- .badge strong {
- color: var(--text);
- font-weight: 700;
- }
- .header-status {
- display: flex;
- align-items: center;
- gap: 10px;
- padding-top: 6px;
- }
-
- .led {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: #333;
- box-shadow: none;
- transition: all .25s ease;
- flex-shrink: 0;
- }
- .led.on-green {
- background: var(--green);
- box-shadow: 0 0 8px var(--green), 0 0 20px var(--green-glow);
- }
- .led.on-red {
- background: var(--accent);
- box-shadow: 0 0 8px var(--accent), 0 0 20px var(--accent-glow);
- }
- .led.on-amber {
- background: var(--amber);
- box-shadow: 0 0 8px var(--amber), 0 0 20px var(--amber-glow);
- }
- .led.on-blue {
- background: var(--blue);
- box-shadow: 0 0 8px var(--blue), 0 0 20px var(--blue-glow);
- }
-
- .status-text {
- font-size: 10px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 1.2px;
- }
-
- .layout {
- display: grid;
- grid-template-columns: minmax(0, 1.35fr) minmax(310px, .75fr);
- gap: 14px;
- align-items: start;
- }
- .stack { display: flex; flex-direction: column; gap: 12px; }
-
- .card {
- background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));
- border: 1px solid var(--border);
- border-radius: var(--radius);
- box-shadow: var(--shadow);
- }
-
- .hero {
- padding: 16px;
- position: relative;
- overflow: hidden;
- }
- .hero.tx-live::after {
- content: '';
- position: absolute;
- inset: -40%;
- background: radial-gradient(circle, rgba(48,209,88,.12), transparent 55%);
- animation: pulseGlow 2.8s ease-in-out infinite;
- pointer-events: none;
- }
- .hero.tx-busy::after {
- content: '';
- position: absolute;
- inset: -50%;
- background: conic-gradient(from 0deg, transparent, rgba(255,159,10,.12), transparent 45%);
- animation: spinWash 2s linear infinite;
- pointer-events: none;
- }
- @keyframes pulseGlow {
- 0%, 100% { transform: scale(.95); opacity: .45; }
- 50% { transform: scale(1.05); opacity: .8; }
- }
- @keyframes spinWash {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
- }
- @keyframes blinkSoft {
- 0%, 100% { opacity: 1; }
- 50% { opacity: .55; }
- }
-
- .tx-bar {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: minmax(180px, 250px) 1fr auto;
- gap: 14px;
- align-items: center;
- }
-
- .freq-display-wrap {
- display: flex;
- flex-direction: column;
- gap: 6px;
- }
- .freq-display-label {
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 1.4px;
- color: var(--text-dim);
- }
- .freq-display {
- font-family: var(--display);
- font-size: 38px;
- color: var(--green);
- text-shadow: 0 0 15px var(--green-glow);
- letter-spacing: 1px;
- line-height: 1;
- }
- .freq-display .unit {
- font-family: var(--mono);
- font-size: 14px;
- color: var(--text-dim);
- margin-left: 5px;
- }
-
- .tx-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- }
-
- .tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn {
- min-height: 40px;
- padding: 0 18px;
- border-radius: var(--radius);
- border: 1px solid var(--border);
- background: var(--surface2);
- color: var(--text);
- cursor: pointer;
- font-size: 12px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 1px;
- transition: all .16s ease;
- }
- .tx-btn:hover, .ghost-btn:hover, .apply-btn:hover, .preset-btn:hover, .danger-btn:hover {
- transform: translateY(-1px);
- border-color: var(--border-strong);
- }
- .tx-btn:disabled, .ghost-btn:disabled, .apply-btn:disabled, .preset-btn:disabled, .danger-btn:disabled {
- opacity: .45;
- cursor: not-allowed;
- transform: none;
- }
- .tx-btn.start { border-color: var(--green); color: var(--green); }
- .tx-btn.start:hover:not(:disabled) { background: rgba(48,209,88,.1); }
- .tx-btn.stop { border-color: var(--accent); color: var(--accent); }
- .tx-btn.stop:hover:not(:disabled) { background: rgba(255,59,48,.1); }
- .ghost-btn { color: var(--text-dim); }
- .danger-btn {
- border-color: rgba(255,59,48,.45);
- color: var(--accent);
- background: rgba(255,59,48,.04);
- }
- .danger-btn:hover:not(:disabled) {
- background: rgba(255,59,48,.12);
- }
-
- .tx-state-wrap {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: 6px;
- }
- .tx-state {
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 2px;
- color: var(--text-dim);
- }
- .tx-state.running { color: var(--green); animation: blinkSoft 2s ease-in-out infinite; }
- .tx-state.idle, .tx-state.stopped { color: var(--text-dim); }
- .tx-state.starting, .tx-state.stopping, .tx-state.working { color: var(--amber); animation: blinkSoft 1.1s ease-in-out infinite; }
- .tx-state.error { color: var(--accent); }
- .status-hint {
- font-size: 10px;
- color: var(--text-muted);
- text-align: right;
- }
-
- .quick-grid {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- gap: 10px;
- margin-top: 16px;
- }
- .quick-item {
- padding: 12px;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--bg-2);
- }
- .quick-item .label {
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 1.4px;
- color: var(--text-dim);
- margin-bottom: 6px;
- }
- .quick-item .value {
- font-size: 18px;
- font-weight: 700;
- color: var(--text);
- }
- .quick-item .value.warn { color: var(--amber); }
- .quick-item .value.err { color: var(--accent); }
- .quick-item .value.good { color: var(--green); }
-
- .signal-grid {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 10px;
- margin-top: 12px;
- }
- .signal-card {
- padding: 12px;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--bg-2);
- }
- .signal-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- margin-bottom: 8px;
- }
- .signal-title {
- font-size: 10px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 1.2px;
- }
- .signal-value {
- font-size: 11px;
- color: var(--text);
- font-weight: 700;
- }
- .meter {
- width: 100%;
- height: 10px;
- border-radius: 999px;
- background: #171821;
- border: 1px solid var(--border);
- overflow: hidden;
- }
- .meter-fill {
- height: 100%;
- width: 0%;
- transition: width .25s ease, background-color .25s ease;
- background: linear-gradient(90deg, var(--green), #5cff90);
- }
- .meter-fill.warn {
- background: linear-gradient(90deg, var(--amber), #ffc45b);
- }
- .meter-fill.err {
- background: linear-gradient(90deg, var(--accent), #ff6b63);
- }
- .spark {
- width: 100%;
- height: 34px;
- margin-top: 10px;
- border-radius: 6px;
- background: rgba(255,255,255,0.01);
- border: 1px solid rgba(255,255,255,0.03);
- }
- .spark path.line {
- fill: none;
- stroke-width: 2;
- stroke-linecap: round;
- stroke-linejoin: round;
- }
- .spark path.area {
- opacity: .14;
- }
- .spark.good path.line { stroke: var(--green); }
- .spark.good path.area { fill: var(--green); }
- .spark.warn path.line { stroke: var(--amber); }
- .spark.warn path.area { fill: var(--amber); }
- .spark.err path.line { stroke: var(--accent); }
- .spark.err path.area { fill: var(--accent); }
-
- .panel {
- overflow: hidden;
- }
- .panel-head {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 12px 14px;
- border-bottom: 1px solid var(--border);
- background: var(--surface2);
- cursor: pointer;
- user-select: none;
- }
- .panel-head h2 {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 1.6px;
- color: var(--text-dim);
- }
- .panel-head .meta {
- margin-left: auto;
- margin-right: 8px;
- font-size: 10px;
- color: var(--text-muted);
- text-transform: uppercase;
- letter-spacing: 1px;
- }
- .panel-head .chevron {
- color: var(--text-dim);
- transition: transform .2s ease;
- font-size: 10px;
- }
- .panel-head.collapsed .chevron { transform: rotate(-90deg); }
- .panel-body { padding: 14px; }
- .panel-body.collapsed { display: none; }
-
- .section-note {
- font-size: 11px;
- color: var(--text-muted);
- margin-bottom: 12px;
- }
- .shortcuts-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px 12px;
- }
- .shortcut-line {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 7px 0;
- border-bottom: 1px solid #1a1a22;
- }
- .shortcut-line:last-child { border-bottom: none; }
- .shortcut-line .name { font-size: 11px; color: var(--text-dim); }
- .shortcut-line .keys {
- display: inline-flex;
- gap: 6px;
- flex-wrap: wrap;
- }
- .kbd {
- min-width: 28px;
- padding: 3px 7px;
- border: 1px solid var(--border);
- border-bottom-width: 2px;
- border-radius: 6px;
- background: var(--bg-2);
- font-size: 10px;
- color: var(--text);
- text-align: center;
- }
-
- .preset-row {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-bottom: 12px;
- }
- .preset-btn {
- min-height: 34px;
- padding: 0 12px;
- font-size: 11px;
- letter-spacing: .8px;
- color: var(--text-dim);
- }
- .preset-btn.active {
- border-color: var(--blue);
- color: var(--blue);
- background: rgba(10,132,255,.08);
- }
- .preset-btn.rds {
- text-transform: none;
- font-weight: 600;
- }
-
- .ctrl-row {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 10px 0;
- border-bottom: 1px solid #1a1a22;
- }
- .ctrl-row:last-child { border-bottom: none; }
- .ctrl-label-wrap {
- min-width: 130px;
- display: flex;
- flex-direction: column;
- gap: 2px;
- }
- .ctrl-label {
- font-size: 11px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: .8px;
- }
- .ctrl-sub {
- font-size: 10px;
- color: var(--text-muted);
- }
- .ctrl-input {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 10px;
- }
-
- input[type="range"] {
- -webkit-appearance: none;
- appearance: none;
- flex: 1;
- height: 6px;
- background: linear-gradient(90deg, var(--border), var(--surface3));
- border-radius: 999px;
- outline: none;
- }
- input[type="range"]::-webkit-slider-thumb {
- -webkit-appearance: none;
- width: 16px;
- height: 16px;
- border-radius: 50%;
- background: var(--text);
- border: 2px solid var(--bg);
- cursor: pointer;
- transition: background .15s ease, transform .15s ease;
- }
- input[type="range"]::-webkit-slider-thumb:hover {
- background: var(--accent);
- transform: scale(1.06);
- }
- input[type="number"], input[type="text"] {
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: 6px;
- color: var(--text);
- padding: 8px 10px;
- outline: none;
- transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
- }
- input[type="number"] {
- width: 92px;
- text-align: right;
- }
- input[type="text"] { width: 100%; }
- input:focus {
- border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(255,59,48,.12);
- }
- input.input-dirty {
- border-color: var(--amber);
- box-shadow: 0 0 0 3px rgba(255,159,10,.08);
- }
- input.input-error {
- border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(255,59,48,.14);
- background: rgba(255,59,48,.04);
- }
- .val-display {
- min-width: 64px;
- text-align: right;
- font-size: 12px;
- font-weight: 700;
- color: var(--text);
- }
- .unit-label {
- font-size: 11px;
- color: var(--text-dim);
- min-width: 44px;
- }
- .field-error {
- display: none;
- margin-top: 8px;
- font-size: 11px;
- color: var(--accent);
- }
- .field-error.show { display: block; }
-
- .toggle-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 14px;
- padding: 12px 0;
- border-bottom: 1px solid #1a1a22;
- }
- .toggle-row:last-child { border-bottom: none; }
- .toggle-copy {
- display: flex;
- flex-direction: column;
- gap: 3px;
- }
- .toggle-copy .title {
- font-size: 12px;
- color: var(--text);
- font-weight: 700;
- }
- .toggle-copy .sub {
- font-size: 10px;
- color: var(--text-muted);
- }
- .toggle-ctl {
- display: flex;
- align-items: center;
- gap: 10px;
- }
- .toggle {
- position: relative;
- width: 42px;
- height: 24px;
- background: var(--border);
- border-radius: 999px;
- cursor: pointer;
- transition: all .2s ease;
- flex-shrink: 0;
- }
- .toggle::after {
- content: '';
- position: absolute;
- top: 3px;
- left: 3px;
- width: 18px;
- height: 18px;
- background: var(--text);
- border-radius: 50%;
- transition: transform .2s ease;
- }
- .toggle.on { background: var(--green); }
- .toggle.on::after { transform: translateX(18px); }
- .toggle.busy { opacity: .55; pointer-events: none; }
- .toggle-state {
- min-width: 52px;
- text-align: right;
- font-size: 11px;
- color: var(--text-dim);
- text-transform: uppercase;
- letter-spacing: 1px;
- }
-
- .rds-grid {
- display: grid;
- gap: 12px;
- }
- .rds-field {
- display: flex;
- flex-direction: column;
- gap: 6px;
- }
- .rds-input {
- width: 100%;
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: 6px;
- color: var(--green);
- font-family: var(--mono);
- font-size: 15px;
- font-weight: 700;
- padding: 10px 12px;
- outline: none;
- letter-spacing: 2px;
- text-transform: uppercase;
- }
- .rds-input.rt {
- color: var(--text);
- text-transform: none;
- letter-spacing: .5px;
- font-size: 12px;
- font-weight: 500;
- }
- .rds-charcount {
- font-size: 10px;
- color: var(--text-dim);
- text-align: right;
- }
-
- .actions-row {
- display: flex;
- gap: 10px;
- flex-wrap: wrap;
- margin-top: 14px;
- }
- .apply-btn {
- background: var(--accent);
- border-color: transparent;
- color: #fff;
- }
- .apply-btn.secondary {
- background: var(--surface2);
- color: var(--text-dim);
- border-color: var(--border);
- }
- .apply-btn.ok { background: var(--green); color: var(--bg); }
-
- .sidebar-card {
- padding: 14px;
- }
- .sidebar-section + .sidebar-section {
- margin-top: 14px;
- padding-top: 14px;
- border-top: 1px solid var(--border);
- }
- .sidebar-title {
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 1.4px;
- color: var(--text-dim);
- margin-bottom: 10px;
- }
- .kv {
- display: grid;
- grid-template-columns: auto 1fr;
- gap: 8px 12px;
- align-items: start;
- }
- .kv .k {
- font-size: 10px;
- color: var(--text-muted);
- text-transform: uppercase;
- letter-spacing: 1px;
- }
- .kv .v {
- font-size: 12px;
- color: var(--text);
- word-break: break-word;
- }
- .health-line {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- padding: 8px 0;
- border-bottom: 1px solid #1a1a22;
- }
- .health-line:last-child { border-bottom: none; }
- .health-line .name {
- font-size: 11px;
- color: var(--text-dim);
- }
- .health-line .val {
- font-size: 11px;
- color: var(--text);
- text-align: right;
- }
- .health-line .val.good { color: var(--green); }
- .health-line .val.warn { color: var(--amber); }
- .health-line .val.err { color: var(--accent); }
-
- .fault-history {
- margin-top: 12px;
- padding: 10px;
- border: 1px solid var(--border);
- border-radius: 6px;
- background: var(--surface1);
- font-size: 11px;
- max-height: 180px;
- overflow-y: auto;
- line-height: 1.3;
- }
- .fault-history-entry {
- display: flex;
- justify-content: space-between;
- gap: 10px;
- padding: 4px 0;
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
- }
- .fault-history-entry:last-child {
- border-bottom: none;
- }
- .fault-history-entry .fault-history-time {
- color: var(--text-dim);
- }
- .fault-history-entry.ok { color: var(--green); }
- .fault-history-entry.warn { color: var(--amber); }
- .fault-history-entry.err { color: var(--accent); }
- .fault-history-desc {
- font-size: 10px;
- flex: 1;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- }
- .fault-history-empty {
- padding: 6px 0;
- color: var(--text-muted);
- font-size: 11px;
- }
- .section-note.reset-hint {
- font-size: 11px;
- color: var(--text-dim);
- margin-top: 10px;
- }
-
- .log {
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: 6px;
- padding: 10px;
- font-size: 10px;
- color: var(--text-dim);
- max-height: 220px;
- overflow-y: auto;
- white-space: pre-wrap;
- word-break: break-word;
- }
- .log .entry { padding: 3px 0; }
- .log .entry.err { color: var(--accent); }
- .log .entry.ok { color: var(--green); }
- .log .entry.warn { color: var(--amber); }
- .log .entry.info { color: var(--blue); }
- .empty-log {
- color: var(--text-muted);
- }
-
- .toast {
- position: fixed;
- right: 16px;
- bottom: 16px;
- max-width: min(420px, calc(100vw - 24px));
- padding: 12px 15px;
- border-radius: var(--radius);
- font-size: 12px;
- font-weight: 700;
- z-index: 2000;
- transform: translateY(60px);
- opacity: 0;
- transition: all .25s ease;
- box-shadow: var(--shadow);
- }
- .toast.show { transform: translateY(0); opacity: 1; }
- .toast.ok { background: var(--green); color: var(--bg); }
- .toast.err { background: var(--accent); color: #fff; }
- .toast.info { background: var(--blue); color: #fff; }
- .toast.warn { background: var(--amber); color: #141414; }
-
- @media (max-width: 980px) {
- .layout { grid-template-columns: 1fr; }
- .tx-bar {
- grid-template-columns: 1fr;
- align-items: stretch;
- }
- .tx-state-wrap {
- align-items: flex-start;
- }
- .status-hint { text-align: left; }
- .quick-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .signal-grid { grid-template-columns: 1fr; }
- }
-
- @media (max-width: 640px) {
- .app { padding: 12px; }
- .header { flex-direction: column; align-items: stretch; gap: 10px; }
- .header h1 { font-size: 22px; }
- .header-sub { gap: 6px; }
- .badge { width: 100%; justify-content: space-between; }
- .quick-grid { grid-template-columns: 1fr 1fr; gap: 8px; }
- .quick-item { padding: 10px; }
- .quick-item .value { font-size: 16px; }
- .ctrl-row { flex-direction: column; align-items: stretch; }
- .ctrl-label-wrap { min-width: auto; }
- .ctrl-input { flex-wrap: wrap; }
- input[type="number"] { width: 100%; text-align: left; }
- .actions-row, .tx-actions { flex-direction: column; }
- .tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn { width: 100%; }
- .panel-head { padding: 11px 12px; }
- .panel-body, .sidebar-card { padding: 12px; }
- .freq-display { font-size: 31px; }
- .preset-row { flex-direction: column; }
- .shortcuts-grid { grid-template-columns: 1fr; }
- }
- </style>
- </head>
- <body>
- <div class="app">
- <div class="header">
- <div class="header-main">
- <h1>FM-RDS-TX</h1>
- <div class="header-sub">
- <div class="badge"><span>Backend</span><strong id="badge-backend">--</strong></div>
- <div class="badge"><span>Mode</span><strong id="badge-mode">Control Plane</strong></div>
- <div class="badge"><span>Live Config</span><strong id="badge-live">--</strong></div>
- </div>
- </div>
- <div class="header-status">
- <div class="led" id="led-conn"></div>
- <div class="status-text" id="conn-label">connecting</div>
- </div>
- </div>
-
- <div class="layout">
- <div class="stack">
- <div class="card hero" id="hero-card">
- <div class="tx-bar">
- <div class="freq-display-wrap">
- <div class="freq-display-label">Carrier</div>
- <div class="freq-display" id="freq-display">---.-<span class="unit">MHz</span></div>
- </div>
-
- <div class="tx-actions">
- <button class="tx-btn start" id="btn-start" type="button">TX ON</button>
- <button class="tx-btn stop" id="btn-stop" type="button">TX OFF</button>
- <button class="ghost-btn" id="btn-refresh" type="button">Refresh</button>
- </div>
-
- <div class="tx-state-wrap">
- <div class="tx-state idle" id="tx-state">IDLE</div>
- <div class="status-hint" id="tx-hint">Awaiting runtime data</div>
- </div>
- </div>
-
- <div class="quick-grid">
- <div class="quick-item">
- <div class="label">Chunks</div>
- <div class="value" id="t-chunks">--</div>
- </div>
- <div class="quick-item">
- <div class="label">Samples</div>
- <div class="value" id="t-samples">--</div>
- </div>
- <div class="quick-item">
- <div class="label">Underruns</div>
- <div class="value" id="t-underruns">--</div>
- </div>
- <div class="quick-item">
- <div class="label">Uptime</div>
- <div class="value" id="t-uptime">--</div>
- </div>
- <div class="quick-item">
- <div class="label">Rate</div>
- <div class="value" id="t-rate">--</div>
- </div>
- </div>
-
- <div class="signal-grid">
- <div class="signal-card">
- <div class="signal-head">
- <div class="signal-title">Audio Buffer</div>
- <div class="signal-value" id="meter-audio-text">--</div>
- </div>
- <div class="meter"><div class="meter-fill" id="meter-audio-fill"></div></div>
- <svg class="spark good" id="spark-audio" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
- </div>
- <div class="signal-card">
- <div class="signal-head">
- <div class="signal-title">Stream Health</div>
- <div class="signal-value" id="meter-stream-text">--</div>
- </div>
- <div class="meter"><div class="meter-fill" id="meter-stream-fill"></div></div>
- <svg class="spark warn" id="spark-underruns" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
- </div>
- <div class="signal-card">
- <div class="signal-head">
- <div class="signal-title">TX Activity</div>
- <div class="signal-value" id="meter-tx-text">--</div>
- </div>
- <div class="meter"><div class="meter-fill" id="meter-tx-fill"></div></div>
- <svg class="spark good" id="spark-tx" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
- </div>
- </div>
- </div>
-
- <div class="card panel" data-panel-key="frequency">
- <div class="panel-head" data-panel>
- <div class="led on-green" style="width:6px;height:6px"></div>
- <h2>Frequency</h2>
- <div class="meta" id="freq-meta">Live-tunable</div>
- <span class="chevron">▼</span>
- </div>
- <div class="panel-body">
- <div class="section-note">Tune the RF carrier without restarting the control plane. Draft values stay local until you apply them.</div>
-
- <div class="preset-row" id="freq-presets">
- <button class="preset-btn" type="button" data-freq-preset="87.6">87.6 MHz</button>
- <button class="preset-btn" type="button" data-freq-preset="94.5">94.5 MHz</button>
- <button class="preset-btn" type="button" data-freq-preset="99.5">99.5 MHz</button>
- <button class="preset-btn" type="button" data-freq-preset="100.0">100.0 MHz</button>
- <button class="preset-btn" type="button" data-freq-preset="107.9">107.9 MHz</button>
- </div>
-
- <div class="ctrl-row">
- <div class="ctrl-label-wrap">
- <span class="ctrl-label">TX Freq</span>
- <span class="ctrl-sub">Valid range 65–110 MHz</span>
- </div>
- <div class="ctrl-input">
- <input type="range" min="65" max="110" step="0.1" id="freq-slider">
- <input type="number" min="65" max="110" step="0.1" id="freq-num">
- <span class="unit-label">MHz</span>
- </div>
- </div>
- <div class="field-error" id="freq-error"></div>
- <div class="actions-row">
- <button class="apply-btn" id="freq-apply" type="button">Apply Frequency</button>
- <button class="apply-btn secondary" id="freq-reset" type="button">Reset</button>
- </div>
- </div>
- </div>
-
- <div class="card panel" data-panel-key="switches">
- <div class="panel-head" data-panel>
- <div class="led on-green" style="width:6px;height:6px"></div>
- <h2>Switches</h2>
- <div class="meta">Live</div>
- <span class="chevron">▼</span>
- </div>
- <div class="panel-body">
- <div class="section-note">These switches apply immediately and show a busy state while the request is in flight.</div>
-
- <div class="toggle-row">
- <div class="toggle-copy">
- <div class="title">Stereo</div>
- <div class="sub">19 kHz pilot + 38 kHz DSB-SC</div>
- </div>
- <div class="toggle-ctl">
- <div class="toggle" id="tog-stereo" data-toggle="stereoEnabled" role="switch" aria-checked="false" tabindex="0"></div>
- <div class="toggle-state" id="stereo-label">--</div>
- </div>
- </div>
-
- <div class="toggle-row">
- <div class="toggle-copy">
- <div class="title">RDS</div>
- <div class="sub">57 kHz subcarrier encoder</div>
- </div>
- <div class="toggle-ctl">
- <div class="toggle" id="tog-rds" data-toggle="rdsEnabled" role="switch" aria-checked="false" tabindex="0"></div>
- <div class="toggle-state" id="rds-label">--</div>
- </div>
- </div>
-
- <div class="toggle-row">
- <div class="toggle-copy">
- <div class="title">Limiter</div>
- <div class="sub">MPX peak protection</div>
- </div>
- <div class="toggle-ctl">
- <div class="toggle" id="tog-limiter" data-toggle="limiterEnabled" role="switch" aria-checked="false" tabindex="0"></div>
- <div class="toggle-state" id="limiter-label">--</div>
- </div>
- </div>
- </div>
- </div>
-
- <div class="card panel" data-panel-key="rds">
- <div class="panel-head" data-panel>
- <div class="led on-amber" style="width:6px;height:6px"></div>
- <h2>RDS Text</h2>
- <div class="meta" id="rds-meta">PS + RT</div>
- <span class="chevron">▼</span>
- </div>
- <div class="panel-body">
- <div class="section-note">Edit Program Service and RadioText without losing in-progress typing when the page refreshes itself.</div>
-
- <div class="preset-row" id="rds-presets">
- <button class="preset-btn rds" type="button" data-rds-ps="FMRTX" data-rds-rt="fm-rds-tx live">Station ID</button>
- <button class="preset-btn rds" type="button" data-rds-ps="ONAIR" data-rds-rt="Now broadcasting">On Air</button>
- <button class="preset-btn rds" type="button" data-rds-ps="LIVE" data-rds-rt="Live set in progress">Live Set</button>
- <button class="preset-btn rds" type="button" data-rds-ps="TEST" data-rds-rt="RDS test transmission">Test</button>
- </div>
-
- <div class="rds-grid">
- <div class="rds-field">
- <span class="ctrl-label">Program Service (PS)</span>
- <input type="text" class="rds-input" id="rds-ps" maxlength="8" placeholder="STATION" spellcheck="false">
- <div class="rds-charcount"><span id="ps-count">0</span>/8</div>
- <div class="field-error" id="ps-error"></div>
- </div>
- <div class="rds-field">
- <span class="ctrl-label">RadioText (RT)</span>
- <input type="text" class="rds-input rt" id="rds-rt" maxlength="64" placeholder="Now playing..." spellcheck="false">
- <div class="rds-charcount"><span id="rt-count">0</span>/64</div>
- <div class="field-error" id="rt-error"></div>
- </div>
- </div>
- <div class="actions-row">
- <button class="apply-btn" id="rds-apply" type="button">Apply RDS Text</button>
- <button class="apply-btn secondary" id="rds-reset" type="button">Reset</button>
- </div>
- </div>
- </div>
- </div>
-
- <div class="stack">
- <div class="card sidebar-card">
- <div class="sidebar-section">
- <div class="sidebar-title">System Snapshot</div>
- <div class="kv">
- <div class="k">Backend</div><div class="v" id="info-backend">--</div>
- <div class="k">Frequency</div><div class="v" id="info-freq">--</div>
- <div class="k">Pre-emphasis</div><div class="v" id="info-preemph">--</div>
- <div class="k">FM Mod</div><div class="v" id="info-fmmod">--</div>
- <div class="k">Live Config</div><div class="v" id="info-live">--</div>
- </div>
- </div>
-
- <div class="sidebar-section">
- <div class="sidebar-title">Health</div>
- <div class="health-line"><div class="name">HTTP</div><div class="val" id="health-http">--</div></div>
- <div class="health-line"><div class="name">Runtime</div><div class="val" id="health-runtime">--</div></div>
- <div class="health-line"><div class="name">Runtime Signal</div><div class="val" id="health-indicator">--</div></div>
- <div class="health-line"><div class="name">Runtime Alert</div><div class="val" id="health-alert">--</div></div>
- <div class="health-line"><div class="name">Transitions (D/M/F)</div><div class="val" id="health-transitions">--</div></div>
- <div class="health-line"><div class="name">Fault Count</div><div class="val" id="health-fault-count">--</div></div>
- <div class="health-line"><div class="name">Last Fault</div><div class="val" id="health-last-fault">--</div></div>
- <div class="health-line"><div class="name">Audio Buffer</div><div class="val" id="health-audio">--</div></div>
- <div class="health-line"><div class="name">Last Update</div><div class="val" id="health-last">--</div></div>
- </div>
- </div>
-
- <div class="card panel" data-panel-key="shortcuts">
- <div class="panel-head" data-panel>
- <h2>Shortcuts</h2>
- <div class="meta">keyboard</div>
- <span class="chevron">▼</span>
- </div>
- <div class="panel-body">
- <div class="section-note">Fast control, as long as you're not typing in an input field.</div>
- <div class="shortcuts-grid">
- <div>
- <div class="shortcut-line"><span class="name">Start TX</span><span class="keys"><span class="kbd">t</span></span></div>
- <div class="shortcut-line"><span class="name">Stop TX</span><span class="keys"><span class="kbd">Shift</span><span class="kbd">t</span></span></div>
- <div class="shortcut-line"><span class="name">Refresh</span><span class="keys"><span class="kbd">r</span></span></div>
- </div>
- <div>
- <div class="shortcut-line"><span class="name">Next Freq Preset</span><span class="keys"><span class="kbd">]</span></span></div>
- <div class="shortcut-line"><span class="name">Prev Freq Preset</span><span class="keys"><span class="kbd">[</span></span></div>
- <div class="shortcut-line"><span class="name">Apply Draft</span><span class="keys"><span class="kbd">Enter</span></span></div>
- </div>
- </div>
- </div>
- </div>
-
- <div class="card panel" data-panel-key="danger">
- <div class="panel-head" data-panel>
- <h2>Danger Zone</h2>
- <div class="meta">tx control</div>
- <span class="chevron">▼</span>
- </div>
- <div class="panel-body">
- <div class="section-note">Fast emergency controls. Nothing hidden here — just clearer separation from normal controls.</div>
- <div class="actions-row" style="margin-top:0">
- <button class="danger-btn" id="danger-stop" type="button">Emergency Stop TX</button>
- <button class="danger-btn" id="danger-refresh" type="button">Hard Refresh Runtime</button>
- <button class="danger-btn secondary" id="danger-reset-fault" type="button">Reset Fault</button>
-
- </div>
- <div class="section-note reset-hint" id="reset-hint">
- Reset Fault moves the runtime back to DEGRADED while the queue settles before running again.
- </div>
- </div>
- </div>
-
- <div class="card panel" data-panel-key="fault-history">
- <div class="panel-head" data-panel>
- <h2>Fault History</h2>
- <div class="meta">recent faults</div>
- <span class="chevron">▼</span>
- </div>
- <div class="panel-body">
- <div class="section-note">Recent fault events for quick ops situational awareness.</div>
- <div class="fault-history" id="fault-history">
- <div class="fault-history-empty">No faults yet.</div>
- </div>
- </div>
- </div>
-
- <div class="card panel" data-panel-key="log">
- <div class="panel-head" data-panel>
- <h2>Activity Log</h2>
- <div class="meta" id="log-meta">recent events</div>
- <span class="chevron">▼</span>
- </div>
- <div class="panel-body">
- <div class="actions-row" style="margin-top:0;margin-bottom:12px">
- <button class="ghost-btn" id="btn-clear-log" type="button">Clear Log</button>
- </div>
- <div class="log" id="log"><div class="empty-log">No events yet.</div></div>
- </div>
- </div>
- </div>
- </div>
- </div>
-
- <div class="toast" id="toast"></div>
-
- <script>
- const $ = (id) => document.getElementById(id);
-
- const runtimePollMs = 1000;
- const configPollMs = 8000;
- const mobileMq = window.matchMedia('(max-width: 640px)');
- const freqPresetValues = [87.6, 94.5, 99.5, 100.0, 107.9];
- const sparkHistoryLimit = 40;
-
- const state = {
- server: {
- config: null,
- runtime: null,
- lastConfigAt: 0,
- lastRuntimeAt: 0,
- configOk: false,
- runtimeOk: false,
- },
- lastRuntimeState: '',
- draft: {
- frequencyMHz: undefined,
- ps: undefined,
- radioText: undefined,
- },
- errors: {
- frequencyMHz: '',
- ps: '',
- radioText: '',
- },
- dirty: new Set(),
- pendingRequests: 0,
- txBusy: false,
- faultResetBusy: false,
- toggleBusy: {},
- pollersStarted: false,
- mobilePanelsApplied: false,
- charts: {
- audio: [],
- underruns: [],
- tx: [],
- },
- freqPresetIndex: 0,
- };
-
- const fields = {
- frequencyMHz: { section: 'freq', equal: (a,b) => nearlyEqual(a,b,0.0001) },
- ps: { section: 'rds', equal: (a,b) => String(a ?? '') === String(b ?? '') },
- radioText: { section: 'rds', equal: (a,b) => String(a ?? '') === String(b ?? '') },
- };
-
- function nearlyEqual(a, b, eps = 1e-9) {
- if (a == null && b == null) return true;
- if (a == null || b == null) return false;
- return Math.abs(Number(a) - Number(b)) <= eps;
- }
-
- function nowTs() { return Date.now(); }
-
- function serverValue(key) {
- const cfg = state.server.config;
- if (!cfg) return undefined;
- switch (key) {
- case 'frequencyMHz': return cfg.fm?.frequencyMHz;
- case 'ps': return cfg.rds?.ps ?? '';
- case 'radioText': return cfg.rds?.radioText ?? '';
- case 'stereoEnabled': return cfg.fm?.stereoEnabled;
- case 'rdsEnabled': return cfg.rds?.enabled;
- case 'limiterEnabled': return cfg.fm?.limiterEnabled;
- default: return undefined;
- }
- }
-
- function effectiveValue(key) {
- return state.dirty.has(key) ? state.draft[key] : serverValue(key);
- }
-
- function validateField(key, value) {
- switch (key) {
- case 'frequencyMHz': {
- if (value == null || Number.isNaN(Number(value))) return 'Enter a valid number.';
- const num = Number(value);
- if (num < 65 || num > 110) return 'Frequency must stay between 65 and 110 MHz.';
- return '';
- }
- case 'ps': {
- const text = String(value ?? '');
- if (text.length > 8) return 'PS is limited to 8 characters.';
- return '';
- }
- case 'radioText': {
- const text = String(value ?? '');
- if (text.length > 64) return 'RadioText is limited to 64 characters.';
- return '';
- }
- default:
- return '';
- }
- }
-
- function sectionHasErrors(section) {
- return Object.entries(fields).some(([key, meta]) => meta.section === section && state.errors[key]);
- }
-
- function setDirty(key, value) {
- state.draft[key] = value;
- state.errors[key] = validateField(key, value);
- const current = serverValue(key);
- const equal = !state.errors[key] && fields[key].equal(value, current);
- if (equal) {
- state.dirty.delete(key);
- state.draft[key] = undefined;
- } else {
- state.dirty.add(key);
- }
- if (key === 'frequencyMHz' && typeof value === 'number') syncFreqPresetIndex(value);
- render();
- }
-
- function clearDirty(keys) {
- for (const key of keys) {
- state.dirty.delete(key);
- state.draft[key] = undefined;
- state.errors[key] = '';
- }
- render();
- }
-
- function isDirtySection(section) {
- for (const key of state.dirty) {
- if (fields[key]?.section === section) return true;
- }
- return false;
- }
-
- function getSectionPatch(section) {
- const patch = {};
- for (const key of state.dirty) {
- if (fields[key]?.section === section && !state.errors[key]) patch[key] = state.draft[key];
- }
- return patch;
- }
-
- async function api(path, opts) {
- const response = await fetch(path, opts);
- const text = await response.text();
- if (!response.ok) {
- throw new Error(text.trim() || `HTTP ${response.status}`);
- }
- if (!text) return {};
- try {
- return JSON.parse(text);
- } catch {
- return { ok: true, raw: text };
- }
- }
-
- function setConnection(ok, mode) {
- const led = $('led-conn');
- const label = $('conn-label');
- if (ok) {
- led.className = 'led on-green';
- label.textContent = mode || 'connected';
- } else {
- led.className = 'led on-red';
- label.textContent = mode || 'offline';
- }
- }
-
- async function loadConfig({ silent = false } = {}) {
- try {
- const cfg = await api('/config');
- state.server.config = cfg;
- state.server.configOk = true;
- state.server.lastConfigAt = nowTs();
- syncFreqPresetIndex(cfg.fm?.frequencyMHz);
- setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected');
- render();
- if (!silent) log('Config synced from server', 'info');
- return cfg;
- } catch (error) {
- state.server.configOk = false;
- if (!state.server.runtimeOk) setConnection(false, 'offline');
- render();
- if (!silent) log('Config load failed: ' + error.message, 'err');
- throw error;
- }
- }
-
- async function loadRuntime({ silent = true } = {}) {
- try {
- const runtime = await api('/runtime');
- state.server.runtime = runtime;
- state.server.runtimeOk = true;
- state.server.lastRuntimeAt = nowTs();
- notifyRuntimeTransition(runtime.engine);
- pushHistory(runtime);
- setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected');
- render();
- return runtime;
- } catch (error) {
- state.server.runtimeOk = false;
- if (!state.server.configOk) setConnection(false, 'offline');
- render();
- if (!silent) log('Runtime load failed: ' + error.message, 'err');
- throw error;
- }
- }
-
- function pushHistory(runtime) {
- const engine = runtime.engine || {};
- const driver = runtime.driver || {};
- const audio = runtime.audioStream || {};
- pushChart(state.charts.audio, typeof audio.buffered === 'number' ? audio.buffered : 0);
- pushChart(state.charts.underruns, Number(engine.underruns ?? driver.underruns ?? 0));
- const txState = String(engine.state || 'idle').toLowerCase();
- pushChart(state.charts.tx, txState === 'running' ? 1 : state.txBusy ? 0.55 : 0.05);
- }
-
- function pushChart(arr, value) {
- arr.push(Number.isFinite(value) ? value : 0);
- if (arr.length > sparkHistoryLimit) arr.splice(0, arr.length - sparkHistoryLimit);
- }
-
- function setConnectionBusy() {
- setConnection(true, 'busy');
- }
-
- function beginRequest() {
- state.pendingRequests += 1;
- setConnectionBusy();
- render();
- }
-
- function endRequest() {
- state.pendingRequests = Math.max(0, state.pendingRequests - 1);
- if (state.server.configOk || state.server.runtimeOk) {
- setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected');
- }
- render();
- }
-
- async function sendPatch(patch, { successMessage = 'Applied', clearKeys = [] } = {}) {
- beginRequest();
- try {
- const result = await api('/config', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(patch),
- });
- if (state.server.config) mergeLocalConfigPatch(patch);
- clearDirty(clearKeys);
- render();
- toast(successMessage + (result.live ? ' · live' : ''), 'ok');
- log('PATCH ' + JSON.stringify(patch) + (result.live ? ' [live]' : ' [saved]'), 'ok');
- await Promise.allSettled([
- loadConfig({ silent: true }),
- loadRuntime({ silent: true }),
- ]);
- return result;
- } catch (error) {
- toast(error.message, 'err');
- log('PATCH failed: ' + error.message, 'err');
- throw error;
- } finally {
- endRequest();
- }
- }
-
- function mergeLocalConfigPatch(patch) {
- const cfg = state.server.config || {};
- cfg.fm ||= {};
- cfg.rds ||= {};
- for (const [key, value] of Object.entries(patch)) {
- switch (key) {
- case 'frequencyMHz': cfg.fm.frequencyMHz = value; break;
- case 'stereoEnabled': cfg.fm.stereoEnabled = value; break;
- case 'limiterEnabled': cfg.fm.limiterEnabled = value; break;
- case 'rdsEnabled': cfg.rds.enabled = value; break;
- case 'ps': cfg.rds.ps = value; break;
- case 'radioText': cfg.rds.radioText = value; break;
- }
- }
- }
-
- async function applySection(section) {
- if (sectionHasErrors(section)) {
- toast('Fix the highlighted fields first', 'warn');
- log(section.toUpperCase() + ' apply blocked by validation errors', 'warn');
- render();
- return;
- }
- const patch = getSectionPatch(section);
- const keys = Object.keys(patch);
- if (!keys.length) {
- toast('No changes to apply', 'info');
- return;
- }
- let message = 'Applied';
- if (section === 'freq') message = 'Frequency updated';
- if (section === 'rds') message = 'RDS text updated';
- await sendPatch(patch, { successMessage: message, clearKeys: keys });
- }
-
- function resetSection(section) {
- const keys = Object.keys(fields).filter((key) => fields[key].section === section);
- clearDirty(keys);
- log(section.toUpperCase() + ' draft reset', 'warn');
- toast('Draft reset', 'info');
- }
-
- async function setToggle(key, nextValue) {
- if (state.toggleBusy[key]) return;
- state.toggleBusy[key] = true;
- render();
- try {
- await sendPatch({ [key]: nextValue }, {
- successMessage: key.replace(/Enabled$/, '') + ' ' + (nextValue ? 'enabled' : 'disabled'),
- clearKeys: [],
- });
- } finally {
- state.toggleBusy[key] = false;
- render();
- }
- }
-
- async function txAction(action) {
- if (state.txBusy) return;
- state.txBusy = true;
- render();
- beginRequest();
- try {
- await api(`/tx/${action}`, { method: 'POST' });
- toast(action === 'start' ? 'TX started' : 'TX stopped', 'ok');
- log('TX ' + action + ' request accepted', 'ok');
- await Promise.allSettled([
- loadRuntime({ silent: true }),
- loadConfig({ silent: true }),
- ]);
- } catch (error) {
- toast(error.message, 'err');
- log('TX ' + action + ' failed: ' + error.message, 'err');
- } finally {
- state.txBusy = false;
- endRequest();
- render();
- }
- }
-
- async function resetFaultAction() {
- if (state.faultResetBusy) return;
- state.faultResetBusy = true;
- render();
- beginRequest();
- try {
- await api('/runtime/fault/reset', { method: 'POST' });
- toast('Fault reset', 'ok');
- log('Fault reset request accepted', 'ok');
- await loadRuntime({ silent: true });
- } catch (error) {
- toast(error.message, 'err');
- log('Fault reset failed: ' + error.message, 'err');
- } finally {
- state.faultResetBusy = false;
- endRequest();
- render();
- }
- }
-
- function fmt(n) {
- if (n == null) return '--';
- if (n >= 1e9) return (n / 1e9).toFixed(2) + 'G';
- if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
- if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
- return String(n);
- }
-
- function fmtTime(seconds) {
- if (!seconds || seconds <= 0) return '--';
- const h = Math.floor(seconds / 3600);
- const m = Math.floor((seconds % 3600) / 60);
- const s = Math.floor(seconds % 60);
- if (h > 0) return `${h}h ${m}m`;
- if (m > 0) return `${m}m ${s}s`;
- return `${s}s`;
- }
-
- function fmtBool(v) {
- return v == null ? '--' : (v ? 'ON' : 'OFF');
- }
-
- function fmtFreq(v) {
- return typeof v === 'number' ? v.toFixed(1) + ' MHz' : '--';
- }
-
- function fmtPercent(v) {
- if (typeof v !== 'number') return '--';
- return (v * 100).toFixed(0) + '%';
- }
-
- function ageString(ts) {
- if (!ts) return '--';
- const diff = Math.max(0, Math.floor((nowTs() - ts) / 1000));
- if (diff < 2) return 'just now';
- if (diff < 60) return diff + 's ago';
- const m = Math.floor(diff / 60);
- if (m < 60) return m + 'm ago';
- const h = Math.floor(m / 60);
- return h + 'h ago';
- }
-
- function updateText(id, text) {
- const el = $(id);
- if (el && el.textContent !== String(text)) el.textContent = text;
- }
-
- function updateHTML(id, html) {
- const el = $(id);
- if (el && el.innerHTML !== html) el.innerHTML = html;
- }
-
- function syncDirtyInput(id, key, transform = (v) => v) {
- const el = $(id);
- if (!el) return;
- const dirty = state.dirty.has(key);
- const desired = dirty ? transform(state.draft[key]) : transform(serverValue(key));
- const asString = desired == null ? '' : String(desired);
- const isFocused = document.activeElement === el;
- if (!isFocused || !dirty) {
- if (el.value !== asString) el.value = asString;
- }
- el.classList.toggle('input-dirty', dirty && !state.errors[key]);
- el.classList.toggle('input-error', !!state.errors[key]);
- }
-
- function renderFieldErrors() {
- renderFieldError('freq-error', state.errors.frequencyMHz);
- renderFieldError('ps-error', state.errors.ps);
- renderFieldError('rt-error', state.errors.radioText);
- }
-
- function renderFieldError(id, message) {
- const el = $(id);
- el.textContent = message || '';
- el.classList.toggle('show', !!message);
- }
-
- function setMeter(fillId, textId, ratio, text, mode = 'good') {
- const fill = $(fillId);
- const pct = Math.max(0, Math.min(100, Math.round((ratio ?? 0) * 100)));
- fill.style.width = pct + '%';
- fill.className = 'meter-fill' + (mode === 'warn' ? ' warn' : mode === 'err' ? ' err' : '');
- updateText(textId, text);
- }
-
- function applyMobilePanelDefaults() {
- if (!mobileMq.matches || state.mobilePanelsApplied) return;
- state.mobilePanelsApplied = true;
- document.querySelectorAll('.panel[data-panel-key]').forEach((panel) => {
- const key = panel.dataset.panelKey;
- const head = panel.querySelector('.panel-head');
- const body = panel.querySelector('.panel-body');
- const keepOpen = key === 'frequency' || key === 'danger';
- head.classList.toggle('collapsed', !keepOpen);
- body.classList.toggle('collapsed', !keepOpen);
- });
- }
-
- function syncFreqPresetIndex(value) {
- if (typeof value !== 'number') return;
- let closestIndex = 0;
- let closestDistance = Infinity;
- freqPresetValues.forEach((freq, idx) => {
- const dist = Math.abs(freq - value);
- if (dist < closestDistance) {
- closestDistance = dist;
- closestIndex = idx;
- }
- });
- state.freqPresetIndex = closestIndex;
- }
-
- function cycleFreqPreset(direction) {
- state.freqPresetIndex = (state.freqPresetIndex + direction + freqPresetValues.length) % freqPresetValues.length;
- const next = freqPresetValues[state.freqPresetIndex];
- setDirty('frequencyMHz', next);
- toast(`Preset ${next.toFixed(1)} MHz loaded`, 'info');
- }
-
- function refreshPresetButtons() {
- const effectiveFreq = effectiveValue('frequencyMHz');
- document.querySelectorAll('[data-freq-preset]').forEach((btn) => {
- const value = Number(btn.dataset.freqPreset);
- btn.classList.toggle('active', typeof effectiveFreq === 'number' && nearlyEqual(value, effectiveFreq, 0.05));
- });
- }
-
- function updateHeroState(engineState) {
- const hero = $('hero-card');
- hero.classList.remove('tx-live', 'tx-busy');
- if (state.txBusy || ['starting', 'stopping'].includes(engineState)) {
- hero.classList.add('tx-busy');
- } else if (engineState === 'running') {
- hero.classList.add('tx-live');
- }
- }
-
- function drawSparkline(svgId, values, mode = 'good', maxOverride = null) {
- const svg = $(svgId);
- if (!svg) return;
- svg.className = `spark ${mode}`;
- const width = 160;
- const height = 34;
- const points = values.length ? values : [0, 0];
- const max = maxOverride != null ? maxOverride : Math.max(...points, 1);
- const min = 0;
- const step = points.length <= 1 ? width : width / (points.length - 1);
- const coords = points.map((value, i) => {
- const x = i * step;
- const norm = max === min ? 0 : (value - min) / (max - min || 1);
- const y = height - 4 - norm * (height - 8);
- return [x, y];
- });
- const line = coords.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`).join(' ');
- const area = `${line} L ${width},${height} L 0,${height} Z`;
- svg.innerHTML = `<path class="area" d="${area}"></path><path class="line" d="${line}"></path>`;
- }
-
- function render() {
- const cfg = state.server.config || {};
- const runtime = state.server.runtime || {};
- const engine = runtime.engine || {};
- const driver = runtime.driver || {};
- const audioStream = runtime.audioStream || null;
-
- const freq = effectiveValue('frequencyMHz') ?? cfg.fm?.frequencyMHz;
- updateHTML('freq-display', `${typeof freq === 'number' ? freq.toFixed(1) : '---.-'}<span class="unit">MHz</span>`);
-
- updateText('badge-backend', cfg.backend?.kind || cfg.backend || '--');
- updateText('badge-mode', engine.state && engine.state !== 'idle' ? 'TX Active' : 'Control Plane');
- updateText('badge-live', state.server.runtimeOk ? 'Connected' : 'Waiting');
-
- updateText('t-chunks', fmt(engine.chunksProduced));
- updateText('t-samples', fmt(engine.totalSamples));
- updateText('t-uptime', fmtTime(engine.uptimeSeconds));
- updateText('t-rate', driver.effectiveSampleRateHz ? (driver.effectiveSampleRateHz / 1000).toFixed(0) + 'k' : '--');
- const underruns = engine.underruns ?? driver.underruns;
- const underrunEl = $('t-underruns');
- underrunEl.textContent = underruns == null ? '--' : String(underruns);
- underrunEl.className = 'value' + (underruns > 0 ? ' err' : underruns === 0 ? ' good' : '');
-
- const txStateValue = String(engine.state || 'idle').toLowerCase();
- const txState = state.txBusy ? 'WORKING…' : txStateValue.toUpperCase();
- const txClass = state.txBusy ? 'working' : txStateValue;
- $('tx-state').textContent = txState;
- $('tx-state').className = 'tx-state ' + txClass;
- updateText('tx-hint', engine.lastError ? `Last error: ${engine.lastError}` : (state.txBusy ? 'Command in progress' : 'Runtime polled every 1s'));
- updateHeroState(txStateValue);
-
- const startDisabled = state.txBusy || txStateValue === 'running';
- const stopDisabled = state.txBusy || ['idle', 'stopped', ''].includes(txStateValue);
- $('btn-start').disabled = startDisabled;
- $('btn-stop').disabled = stopDisabled;
- $('btn-refresh').disabled = state.pendingRequests > 0;
- $('danger-stop').disabled = stopDisabled;
- $('danger-refresh').disabled = state.pendingRequests > 0;
- const resetFaultBtn = $('danger-reset-fault');
- if (resetFaultBtn) {
- const resetDisabled = state.faultResetBusy || !state.server.runtimeOk;
- resetFaultBtn.disabled = resetDisabled;
- resetFaultBtn.textContent = state.faultResetBusy ? 'Resetting…' : 'Reset Fault';
- }
-
- syncDirtyInput('freq-slider', 'frequencyMHz', (v) => typeof v === 'number' ? v.toFixed(1) : '100.0');
- syncDirtyInput('freq-num', 'frequencyMHz', (v) => typeof v === 'number' ? v.toFixed(1) : '100.0');
- syncDirtyInput('rds-ps', 'ps', (v) => String(v ?? ''));
- syncDirtyInput('rds-rt', 'radioText', (v) => String(v ?? ''));
-
- const psValue = String(effectiveValue('ps') ?? cfg.rds?.ps ?? '');
- const rtValue = String(effectiveValue('radioText') ?? cfg.rds?.radioText ?? '');
- updateText('ps-count', psValue.length);
- updateText('rt-count', rtValue.length);
- renderFieldErrors();
- refreshPresetButtons();
-
- renderToggle('stereoEnabled', 'tog-stereo', 'stereo-label');
- renderToggle('rdsEnabled', 'tog-rds', 'rds-label');
- renderToggle('limiterEnabled', 'tog-limiter', 'limiter-label');
-
- const freqDirty = isDirtySection('freq');
- $('freq-apply').disabled = !freqDirty || sectionHasErrors('freq');
- $('freq-reset').disabled = !freqDirty;
- const rdsDirty = isDirtySection('rds');
- $('rds-apply').disabled = !rdsDirty || sectionHasErrors('rds');
- $('rds-reset').disabled = !rdsDirty;
-
- updateText('freq-meta', sectionHasErrors('freq') ? 'Validation error' : (freqDirty ? 'Unsaved changes' : 'Live-tunable'));
- updateText('rds-meta', sectionHasErrors('rds') ? 'Validation error' : (rdsDirty ? `${Object.keys(getSectionPatch('rds')).length} unsaved` : 'PS + RT'));
-
- updateText('info-backend', cfg.backend?.kind || cfg.backend || '--');
- updateText('info-freq', fmtFreq(cfg.fm?.frequencyMHz));
- updateText('info-preemph', cfg.fm?.preEmphasisTauUS ? `${cfg.fm.preEmphasisTauUS} µs` : 'Off');
- updateText('info-fmmod', fmtBool(cfg.fm?.fmModulationEnabled));
- updateText('info-live', engine.state ? `${String(engine.state).toUpperCase()} / ${state.server.runtimeOk ? 'runtime ok' : 'runtime pending'}` : (state.server.configOk ? 'config only' : '--'));
-
- updateHealth(engine, audioStream);
- updateFaultHistory(engine);
- updateResetHint(engine);
- updateMeters(engine, driver, audioStream);
- drawSparkline('spark-audio', state.charts.audio, 'good', 1);
- drawSparkline('spark-underruns', state.charts.underruns, underruns > 0 ? 'err' : 'warn');
- drawSparkline('spark-tx', state.charts.tx, txStateValue === 'running' ? 'good' : 'warn', 1);
- applyMobilePanelDefaults();
- }
-
- function renderToggle(key, toggleId, labelId) {
- const on = !!serverValue(key);
- const busy = !!state.toggleBusy[key];
- const el = $(toggleId);
- el.className = 'toggle' + (on ? ' on' : '') + (busy ? ' busy' : '');
- el.setAttribute('aria-checked', on ? 'true' : 'false');
- updateText(labelId, busy ? '...' : (on ? 'ON' : 'OFF'));
- }
-
- function runtimeStateClass(engineState) {
- const normalized = String(engineState || '').toLowerCase();
- if (!normalized) {
- return 'warn';
- }
- switch (normalized) {
- case 'faulted':
- return 'err';
- case 'muted':
- case 'degraded':
- case 'prebuffering':
- case 'arming':
- case 'stopping':
- case 'idle':
- case 'unknown':
- return 'warn';
- default:
- return 'good';
- }
- }
-
-
-
- function normalizeRuntimeState(stateName) {
- const normalized = (typeof stateName === 'string' ? stateName.trim().toLowerCase() : '');
- return normalized || 'idle';
- }
-
- function runtimeStateSeverity(stateName) {
- const normalized = normalizeRuntimeState(stateName);
- switch (normalized) {
- case 'running':
- return 'ok';
- case 'degraded':
- case 'muted':
- return 'warn';
- case 'faulted':
- return 'err';
- default:
- return 'info';
- }
- }
-
- function notifyRuntimeTransition(engine) {
- if (!engine) return;
- const next = normalizeRuntimeState(engine.state);
- const prev = state.lastRuntimeState;
- state.lastRuntimeState = next;
- if (!prev || prev === next) return;
- const message = `Runtime ${prev.toUpperCase()} → ${next.toUpperCase()}`;
- const severity = runtimeStateSeverity(next);
- const logLevel = severity === 'err' ? 'err' : (severity === 'warn' ? 'warn' : 'info');
- toast(message, severity);
- log(message, logLevel);
- }
-
- function updateHealth(engine, audioStream) {
- engine = engine || {};
- updateText('health-http', state.server.configOk ? 'OK' : 'OFFLINE');
- $('health-http').className = 'val ' + (state.server.configOk ? 'good' : 'err');
-
- let runtimeLabel = 'WAITING';
- let runtimeClass = 'warn';
- if (state.server.runtimeOk) {
- const engineStateName = String(engine.state || 'unknown');
- runtimeLabel = engineStateName.toUpperCase();
- runtimeClass = runtimeStateClass(engineStateName);
- }
- updateText('health-runtime', runtimeLabel);
- $('health-runtime').className = 'val ' + runtimeClass;
-
- const runtimeIndicator = engine.runtimeIndicator;
- const indicatorLabels = {
- normal: 'Normal',
- degraded: 'Degraded',
- queueCritical: 'Queue critical',
- };
- const indicatorText = indicatorLabels[runtimeIndicator] || (runtimeIndicator ? runtimeIndicator : '--');
- let indicatorSeverity = '';
- if (runtimeIndicator === 'queueCritical') indicatorSeverity = 'err';
- else if (runtimeIndicator === 'degraded') indicatorSeverity = 'warn';
- else if (runtimeIndicator === 'normal') indicatorSeverity = 'good';
- const indicatorEl = $('health-indicator');
- if (indicatorEl) {
- indicatorEl.className = 'val' + (indicatorSeverity ? ' ' + indicatorSeverity : '');
- }
- updateText('health-indicator', indicatorText);
-
- const runtimeAlertRaw = (engine.runtimeAlert || '').trim();
- const hasAlert = !!runtimeAlertRaw;
- const alertEl = $('health-alert');
- if (alertEl) {
- alertEl.className = 'val ' + (hasAlert ? 'warn' : 'good');
- }
- updateText('health-alert', hasAlert ? runtimeAlertRaw : 'None');
-
- let audioLabel = 'N/A';
- let audioClass = 'val';
- if (audioStream) {
- const fill = typeof audioStream.buffered === 'number' ? audioStream.buffered : null;
- audioLabel = fill == null ? 'Unknown' : fmtPercent(fill);
- if (fill == null) audioClass = 'val';
- else if (fill < 0.05) audioClass = 'val err';
- else if (fill < 0.2) audioClass = 'val warn';
- else audioClass = 'val good';
- }
- updateText('health-audio', audioLabel);
- $('health-audio').className = audioClass;
-
- const last = Math.max(state.server.lastConfigAt || 0, state.server.lastRuntimeAt || 0);
- updateText('health-last', ageString(last));
-
- const transitionsAvailable = engine.degradedTransitions != null || engine.mutedTransitions != null || engine.faultedTransitions != null;
- const transitionsText = transitionsAvailable ? `${Number(engine.degradedTransitions ?? 0)} / ${Number(engine.mutedTransitions ?? 0)} / ${Number(engine.faultedTransitions ?? 0)}` : '--';
- updateText('health-transitions', transitionsText);
-
- const faultCountValue = engine.faultCount != null ? Number(engine.faultCount) : 0;
- const hasFaultCount = engine.faultCount != null;
- updateText('health-fault-count', hasFaultCount ? String(faultCountValue) : '--');
- const faultCountEl = $('health-fault-count');
- if (faultCountEl) {
- faultCountEl.className = 'val' + (hasFaultCount ? (faultCountValue > 0 ? ' warn' : ' good') : '');
- }
-
- const lastFaultEl = $('health-last-fault');
- const lastFault = engine.lastFault;
- if (lastFaultEl) {
- if (lastFault) {
- const severity = String(lastFault.severity || '').toLowerCase();
- const severityClass = severity === 'faulted' ? 'err' : 'warn';
- const severityLabel = (lastFault.severity || 'Fault').toUpperCase();
- const reasonLabel = lastFault.reason ? ` ${lastFault.reason}` : '';
- const messageLabel = lastFault.message ? ` - ${lastFault.message}` : '';
- let whenLabel = '';
- if (lastFault.time) {
- const parsed = new Date(lastFault.time);
- if (!Number.isNaN(parsed.getTime())) {
- whenLabel = ` @ ${parsed.toLocaleTimeString()}`;
- }
- }
- const title = `${severityLabel}${reasonLabel}`;
- updateText('health-last-fault', `${title}${messageLabel}${whenLabel}`);
- lastFaultEl.className = 'val ' + severityClass;
- } else {
- lastFaultEl.className = 'val good';
- updateText('health-last-fault', 'None');
- }
- }
- }
-
-
- function updateFaultHistory(engine) {
- const container = $('fault-history');
- if (!container) return;
- const history = Array.isArray(engine?.faultHistory) ? engine.faultHistory : [];
- if (!history.length) {
- container.innerHTML = '<div class="fault-history-empty">No faults recorded yet.</div>';
- return;
- }
- const rows = history.slice().reverse().map((entry) => {
- const when = entry?.time ? new Date(entry.time) : null;
- const timeLabel = when && !Number.isNaN(when.getTime()) ? when.toLocaleTimeString() : '--:--';
- const severity = String(entry?.severity || 'warn').toLowerCase();
- const severityLabel = String(entry?.severity || 'Fault').toUpperCase();
- const reasonLabel = entry?.reason ? ` ${entry.reason}` : '';
- const messageLabel = entry?.message ? ` · ${entry.message}` : '';
- return `<div class="fault-history-entry ${severity}"><span class="fault-history-time">${timeLabel}</span><span class="fault-history-desc">${severityLabel}${reasonLabel}${messageLabel}</span></div>`;
- });
- container.innerHTML = rows.join('');
- }
-
- function updateResetHint(engine) {
- const hint = $('reset-hint');
- if (!hint) return;
- const stateName = String(engine?.state || '').toLowerCase();
- let text = 'Manual fault reset drops runtime to DEGRADED while the queue recovers.';
- if (stateName === 'faulted') {
- text = 'Faulted: reset moves runtime back to DEGRADED until the queue settles.';
- } else if (stateName === 'muted' || stateName === 'degraded') {
- text = 'Reset Fault keeps the runtime in DEGRADED so the queue can recover before running again.';
- }
- hint.textContent = text;
- }
-
- function updateMeters(engine, driver, audioStream) {
- if (audioStream && typeof audioStream.buffered === 'number') {
- const ratio = Math.max(0, Math.min(1, audioStream.buffered));
- const mode = ratio < 0.05 ? 'err' : ratio < 0.2 ? 'warn' : 'good';
- setMeter('meter-audio-fill', 'meter-audio-text', ratio, fmtPercent(ratio), mode);
- } else {
- setMeter('meter-audio-fill', 'meter-audio-text', 0, 'N/A', 'warn');
- }
-
- const underruns = Number(engine.underruns ?? driver.underruns ?? 0);
- const healthRatio = underruns <= 0 ? 1 : Math.max(0, 1 - Math.min(underruns, 10) / 10);
- const healthMode = underruns === 0 ? 'good' : underruns < 3 ? 'warn' : 'err';
- setMeter('meter-stream-fill', 'meter-stream-text', healthRatio, underruns === 0 ? 'Clean' : `${underruns} underrun${underruns === 1 ? '' : 's'}`, healthMode);
-
- const txState = String(engine.state || 'idle').toLowerCase();
- const activeRatio = txState === 'running' ? 1 : state.txBusy ? 0.55 : 0.08;
- const activeMode = txState === 'running' ? 'good' : state.txBusy ? 'warn' : 'err';
- const activeText = txState === 'running' ? 'Live' : state.txBusy ? 'Working' : 'Idle';
- setMeter('meter-tx-fill', 'meter-tx-text', activeRatio, activeText, activeMode);
- }
-
- function toast(msg, type = 'info') {
- const t = $('toast');
- t.textContent = msg;
- t.className = 'toast ' + type + ' show';
- clearTimeout(t._timer);
- t._timer = setTimeout(() => t.classList.remove('show'), 2600);
- }
-
- function log(message, type = '') {
- const logEl = $('log');
- const empty = logEl.querySelector('.empty-log');
- if (empty) empty.remove();
- const row = document.createElement('div');
- row.className = 'entry ' + type;
- row.textContent = `${new Date().toLocaleTimeString()} ${message}`;
- logEl.appendChild(row);
- while (logEl.children.length > 250) logEl.removeChild(logEl.firstChild);
- logEl.scrollTop = logEl.scrollHeight;
- }
-
- function bindPanels() {
- document.querySelectorAll('[data-panel]').forEach((head) => {
- head.addEventListener('click', () => {
- head.classList.toggle('collapsed');
- head.nextElementSibling.classList.toggle('collapsed');
- });
- });
- }
-
- function bindInputs() {
- $('freq-slider').addEventListener('input', (e) => {
- const value = Number(e.target.value);
- setDirty('frequencyMHz', value);
- });
- $('freq-num').addEventListener('input', (e) => {
- const value = Number(e.target.value);
- if (!Number.isNaN(value)) setDirty('frequencyMHz', value);
- else {
- state.errors.frequencyMHz = 'Enter a valid number.';
- render();
- }
- });
-
- $('rds-ps').addEventListener('input', (e) => setDirty('ps', e.target.value.toUpperCase().slice(0, 8)));
- $('rds-rt').addEventListener('input', (e) => setDirty('radioText', e.target.value.slice(0, 64)));
-
- $('freq-apply').addEventListener('click', () => applySection('freq'));
- $('rds-apply').addEventListener('click', () => applySection('rds'));
- $('freq-reset').addEventListener('click', () => resetSection('freq'));
- $('rds-reset').addEventListener('click', () => resetSection('rds'));
-
- $('btn-start').addEventListener('click', () => txAction('start'));
- $('btn-stop').addEventListener('click', () => txAction('stop'));
- $('danger-stop').addEventListener('click', () => txAction('stop'));
- $('btn-refresh').addEventListener('click', manualRefresh);
- $('danger-refresh').addEventListener('click', manualRefresh);
- $('danger-reset-fault').addEventListener('click', () => resetFaultAction());
-
- document.querySelectorAll('.toggle[data-toggle]').forEach((toggle) => {
- const key = toggle.dataset.toggle;
- const handler = () => setToggle(key, !serverValue(key));
- toggle.addEventListener('click', handler);
- toggle.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- handler();
- }
- });
- });
-
- document.querySelectorAll('[data-freq-preset]').forEach((btn) => {
- btn.addEventListener('click', () => {
- const value = Number(btn.dataset.freqPreset);
- setDirty('frequencyMHz', value);
- toast(`Frequency preset ${value.toFixed(1)} MHz loaded`, 'info');
- });
- });
-
- document.querySelectorAll('[data-rds-ps]').forEach((btn) => {
- btn.addEventListener('click', () => {
- setDirty('ps', btn.dataset.rdsPs || '');
- setDirty('radioText', btn.dataset.rdsRt || '');
- toast('RDS preset loaded', 'info');
- });
- });
-
- $('btn-clear-log').addEventListener('click', () => {
- $('log').innerHTML = '<div class="empty-log">No events yet.</div>';
- toast('Log cleared', 'info');
- });
- }
-
- async function manualRefresh() {
- beginRequest();
- try {
- await Promise.allSettled([
- loadConfig({ silent: true }),
- loadRuntime({ silent: true }),
- ]);
- toast('Refreshed', 'info');
- log('Manual refresh completed', 'info');
- } finally {
- endRequest();
- }
- }
-
- function startPollers() {
- if (state.pollersStarted) return;
- state.pollersStarted = true;
- setInterval(() => { loadRuntime({ silent: true }); }, runtimePollMs);
- setInterval(() => { loadConfig({ silent: true }); }, configPollMs);
- }
-
- function bindResponsiveBehavior() {
- const listener = () => {
- if (!mobileMq.matches) {
- state.mobilePanelsApplied = false;
- return;
- }
- applyMobilePanelDefaults();
- };
- if (mobileMq.addEventListener) mobileMq.addEventListener('change', listener);
- else mobileMq.addListener(listener);
- }
-
- function isTypingContext(target) {
- if (!target) return false;
- const tag = (target.tagName || '').toLowerCase();
- return tag === 'input' || tag === 'textarea' || target.isContentEditable;
- }
-
- function bindKeyboardShortcuts() {
- window.addEventListener('keydown', (e) => {
- if (isTypingContext(e.target)) {
- if (e.key !== 'Enter') return;
- if (state.dirty.has('frequencyMHz')) {
- e.preventDefault();
- applySection('freq');
- } else if (isDirtySection('rds')) {
- e.preventDefault();
- applySection('rds');
- }
- return;
- }
-
- if (e.key === 't' && !e.shiftKey) {
- e.preventDefault();
- txAction('start');
- return;
- }
- if ((e.key === 'T') || (e.key === 't' && e.shiftKey)) {
- e.preventDefault();
- txAction('stop');
- return;
- }
- if (e.key.toLowerCase() === 'r') {
- e.preventDefault();
- manualRefresh();
- return;
- }
- if (e.key === '[') {
- e.preventDefault();
- cycleFreqPreset(-1);
- return;
- }
- if (e.key === ']') {
- e.preventDefault();
- cycleFreqPreset(1);
- return;
- }
- if (e.key === 'Enter') {
- e.preventDefault();
- if (state.dirty.has('frequencyMHz')) applySection('freq');
- else if (isDirtySection('rds')) applySection('rds');
- }
- });
- }
-
- async function init() {
- bindPanels();
- bindInputs();
- bindResponsiveBehavior();
- bindKeyboardShortcuts();
- render();
- log('fm-rds-tx control UI booting', 'info');
-
- await Promise.allSettled([
- loadConfig({ silent: false }),
- loadRuntime({ silent: true }),
- ]);
-
- render();
- startPollers();
- log('Polling active: runtime 1s, config 8s', 'ok');
- log('Keyboard shortcuts armed', 'info');
- }
-
- init();
- </script>
- </body>
- </html>
|