|
- <!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;
- --surface: #111116;
- --surface2: #18181e;
- --border: #2a2a35;
- --text: #d4d4dc;
- --text-dim: #6a6a78;
- --accent: #ff3b30;
- --accent-glow: #ff3b3044;
- --green: #30d158;
- --green-glow: #30d15844;
- --amber: #ff9f0a;
- --amber-glow: #ff9f0a44;
- --blue: #0a84ff;
- --mono: 'JetBrains Mono', monospace;
- --display: 'Archivo Black', sans-serif;
- --radius: 6px;
- }
-
- * { box-sizing: border-box; margin: 0; padding: 0; }
-
- body {
- background: var(--bg);
- color: var(--text);
- font-family: var(--mono);
- font-size: 13px;
- line-height: 1.5;
- min-height: 100vh;
- overflow-x: hidden;
- }
-
- /* Scan lines overlay */
- body::before {
- content: '';
- position: fixed; inset: 0;
- background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px);
- pointer-events: none; z-index: 1000;
- }
-
- .app {
- max-width: 900px;
- margin: 0 auto;
- padding: 16px;
- }
-
- /* Header */
- .header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 16px 0 24px;
- border-bottom: 1px solid var(--border);
- margin-bottom: 20px;
- }
-
- .header h1 {
- font-family: var(--display);
- font-size: 22px;
- letter-spacing: 2px;
- text-transform: uppercase;
- color: var(--accent);
- text-shadow: 0 0 20px var(--accent-glow), 0 0 40px var(--accent-glow);
- }
-
- .header-status {
- display: flex;
- align-items: center;
- gap: 12px;
- }
-
- /* LED indicator */
- .led {
- width: 10px; height: 10px;
- border-radius: 50%;
- background: #333;
- box-shadow: none;
- transition: all 0.3s;
- }
- .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);
- }
-
- /* TX control bar */
- .tx-bar {
- display: flex;
- gap: 10px;
- align-items: center;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 12px 16px;
- margin-bottom: 16px;
- }
-
- .tx-bar .freq-display {
- font-family: var(--display);
- font-size: 32px;
- color: var(--green);
- text-shadow: 0 0 15px var(--green-glow);
- letter-spacing: 1px;
- min-width: 200px;
- }
- .tx-bar .freq-display .unit {
- font-family: var(--mono);
- font-size: 14px;
- color: var(--text-dim);
- margin-left: 4px;
- }
-
- .tx-btn {
- padding: 8px 20px;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--surface2);
- color: var(--text);
- font-family: var(--mono);
- font-size: 12px;
- font-weight: 600;
- cursor: pointer;
- text-transform: uppercase;
- letter-spacing: 1px;
- transition: all 0.15s;
- }
- .tx-btn:hover { border-color: var(--text-dim); }
- .tx-btn.start { border-color: var(--green); color: var(--green); }
- .tx-btn.start:hover { background: var(--green); color: var(--bg); }
- .tx-btn.stop { border-color: var(--accent); color: var(--accent); }
- .tx-btn.stop:hover { background: var(--accent); color: #fff; }
-
- .tx-state {
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 2px;
- color: var(--text-dim);
- margin-left: auto;
- }
- .tx-state.running { color: var(--green); }
- .tx-state.idle { color: var(--text-dim); }
-
- /* Telemetry strip */
- .telem {
- display: flex;
- gap: 1px;
- background: var(--border);
- border-radius: var(--radius);
- overflow: hidden;
- margin-bottom: 16px;
- }
- .telem-cell {
- flex: 1;
- background: var(--surface);
- padding: 10px 12px;
- text-align: center;
- }
- .telem-cell .label {
- font-size: 9px;
- text-transform: uppercase;
- letter-spacing: 1.5px;
- color: var(--text-dim);
- margin-bottom: 4px;
- }
- .telem-cell .value {
- font-size: 16px;
- font-weight: 700;
- color: var(--text);
- }
- .telem-cell .value.warn { color: var(--amber); }
- .telem-cell .value.err { color: var(--accent); }
-
- /* Section panels */
- .panel {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- margin-bottom: 12px;
- overflow: hidden;
- }
- .panel-head {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 10px 14px;
- border-bottom: 1px solid var(--border);
- background: var(--surface2);
- cursor: pointer;
- user-select: none;
- }
- .panel-head h2 {
- font-family: var(--mono);
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 2px;
- color: var(--text-dim);
- }
- .panel-head .chevron {
- margin-left: auto;
- color: var(--text-dim);
- transition: transform 0.2s;
- font-size: 10px;
- }
- .panel-head.collapsed .chevron { transform: rotate(-90deg); }
- .panel-body { padding: 14px; }
- .panel-body.collapsed { display: none; }
-
- /* Form controls */
- .ctrl-row {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 6px 0;
- border-bottom: 1px solid #1a1a22;
- }
- .ctrl-row:last-child { border-bottom: none; }
-
- .ctrl-label {
- font-size: 11px;
- color: var(--text-dim);
- min-width: 110px;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- }
-
- .ctrl-input {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 8px;
- }
-
- input[type="range"] {
- -webkit-appearance: none;
- appearance: none;
- flex: 1;
- height: 4px;
- background: var(--border);
- border-radius: 2px;
- outline: none;
- }
- input[type="range"]::-webkit-slider-thumb {
- -webkit-appearance: none;
- width: 14px; height: 14px;
- border-radius: 50%;
- background: var(--text);
- border: 2px solid var(--bg);
- cursor: pointer;
- transition: background 0.15s;
- }
- input[type="range"]::-webkit-slider-thumb:hover { background: var(--accent); }
-
- input[type="number"], input[type="text"] {
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: 4px;
- color: var(--text);
- font-family: var(--mono);
- font-size: 13px;
- padding: 5px 8px;
- width: 80px;
- outline: none;
- transition: border-color 0.15s;
- }
- input[type="text"] { width: 100%; }
- input:focus { border-color: var(--accent); }
-
- .val-display {
- font-size: 12px;
- font-weight: 600;
- min-width: 55px;
- text-align: right;
- color: var(--text);
- }
-
- /* Toggle switch */
- .toggle {
- position: relative;
- width: 36px; height: 20px;
- background: var(--border);
- border-radius: 10px;
- cursor: pointer;
- transition: background 0.2s;
- flex-shrink: 0;
- }
- .toggle.on { background: var(--green); }
- .toggle::after {
- content: '';
- position: absolute;
- top: 2px; left: 2px;
- width: 16px; height: 16px;
- background: var(--text);
- border-radius: 50%;
- transition: transform 0.2s;
- }
- .toggle.on::after { transform: translateX(16px); }
-
- /* RDS section */
- .rds-input {
- width: 100%;
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: 4px;
- color: var(--green);
- font-family: var(--mono);
- font-size: 15px;
- font-weight: 700;
- padding: 8px 10px;
- outline: none;
- letter-spacing: 2px;
- text-transform: uppercase;
- transition: border-color 0.15s;
- }
- .rds-input:focus { border-color: var(--accent); }
- .rds-input.rt {
- font-size: 12px;
- font-weight: 400;
- letter-spacing: 0.5px;
- text-transform: none;
- color: var(--text);
- }
- .rds-charcount {
- font-size: 10px;
- color: var(--text-dim);
- text-align: right;
- margin-top: 2px;
- }
-
- /* Apply button */
- .apply-btn {
- display: block;
- width: 100%;
- padding: 10px;
- margin-top: 8px;
- background: var(--accent);
- border: none;
- border-radius: var(--radius);
- color: #fff;
- font-family: var(--mono);
- font-size: 12px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 2px;
- cursor: pointer;
- transition: all 0.15s;
- opacity: 0;
- transform: translateY(-4px);
- pointer-events: none;
- }
- .apply-btn.visible {
- opacity: 1;
- transform: translateY(0);
- pointer-events: auto;
- }
- .apply-btn:hover { filter: brightness(1.2); }
- .apply-btn.sending {
- opacity: 0.6;
- pointer-events: none;
- }
- .apply-btn.ok {
- background: var(--green);
- }
-
- /* Toast notification */
- .toast {
- position: fixed;
- bottom: 20px;
- right: 20px;
- padding: 10px 16px;
- border-radius: var(--radius);
- font-size: 12px;
- font-weight: 600;
- z-index: 2000;
- transform: translateY(60px);
- opacity: 0;
- transition: all 0.3s;
- }
- .toast.show { transform: translateY(0); opacity: 1; }
- .toast.ok { background: var(--green); color: var(--bg); }
- .toast.err { background: var(--accent); color: #fff; }
-
- /* Log */
- .log {
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: 4px;
- padding: 8px 10px;
- font-size: 10px;
- color: var(--text-dim);
- max-height: 120px;
- overflow-y: auto;
- white-space: pre-wrap;
- word-break: break-all;
- }
- .log .entry { padding: 1px 0; }
- .log .entry.err { color: var(--accent); }
- .log .entry.ok { color: var(--green); }
-
- /* Responsive */
- @media (max-width: 600px) {
- .tx-bar { flex-wrap: wrap; }
- .tx-bar .freq-display { font-size: 24px; min-width: auto; }
- .telem { flex-wrap: wrap; }
- .telem-cell { flex: 1 1 30%; }
- .ctrl-row { flex-wrap: wrap; }
- .ctrl-label { min-width: auto; width: 100%; }
- }
- </style>
- </head>
- <body>
- <div class="app" id="app">
-
- <!-- Header -->
- <div class="header">
- <h1>FM-RDS-TX</h1>
- <div class="header-status">
- <div class="led" id="led-conn"></div>
- <span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px" id="conn-label">connecting</span>
- </div>
- </div>
-
- <!-- TX Control Bar -->
- <div class="tx-bar">
- <div class="freq-display" id="freq-display">---.--<span class="unit">MHz</span></div>
- <button class="tx-btn start" id="btn-start" onclick="txStart()">TX ON</button>
- <button class="tx-btn stop" id="btn-stop" onclick="txStop()">TX OFF</button>
- <div class="tx-state" id="tx-state">--</div>
- </div>
-
- <!-- Telemetry -->
- <div class="telem" id="telem">
- <div class="telem-cell"><div class="label">Chunks</div><div class="value" id="t-chunks">--</div></div>
- <div class="telem-cell"><div class="label">Samples</div><div class="value" id="t-samples">--</div></div>
- <div class="telem-cell"><div class="label">Underruns</div><div class="value" id="t-underruns">0</div></div>
- <div class="telem-cell"><div class="label">Uptime</div><div class="value" id="t-uptime">--</div></div>
- <div class="telem-cell"><div class="label">Rate</div><div class="value" id="t-rate">--</div></div>
- </div>
-
- <!-- Frequency -->
- <div class="panel">
- <div class="panel-head" onclick="togglePanel(this)">
- <div class="led on-green" style="width:6px;height:6px"></div>
- <h2>Frequency</h2>
- <span class="chevron">▼</span>
- </div>
- <div class="panel-body">
- <div class="ctrl-row">
- <span class="ctrl-label">TX Freq</span>
- <div class="ctrl-input">
- <input type="range" min="87.5" max="108.0" step="0.1" id="freq-slider"
- oninput="onFreqSlider(this.value)">
- <input type="number" min="65" max="110" step="0.1" id="freq-num"
- onchange="onFreqNum(this.value)">
- <span class="val-display">MHz</span>
- </div>
- </div>
- <button class="apply-btn" id="freq-apply" onclick="applyFreq()">Apply Frequency</button>
- </div>
- </div>
-
- <!-- Levels -->
- <div class="panel">
- <div class="panel-head" onclick="togglePanel(this)">
- <div class="led on-amber" style="width:6px;height:6px"></div>
- <h2>Levels</h2>
- <span class="chevron">▼</span>
- </div>
- <div class="panel-body">
- <div class="ctrl-row">
- <span class="ctrl-label">Output Drive</span>
- <div class="ctrl-input">
- <input type="range" min="0" max="3" step="0.01" id="drive-slider"
- oninput="onSlider('outputDrive', this.value, 'drive-val')">
- <span class="val-display" id="drive-val">--</span>
- </div>
- </div>
- <div class="ctrl-row">
- <span class="ctrl-label">Pilot Level</span>
- <div class="ctrl-input">
- <input type="range" min="0" max="0.2" step="0.001" id="pilot-slider"
- oninput="onSlider('pilotLevel', this.value, 'pilot-val')">
- <span class="val-display" id="pilot-val">--</span>
- </div>
- </div>
- <div class="ctrl-row">
- <span class="ctrl-label">RDS Inject</span>
- <div class="ctrl-input">
- <input type="range" min="0" max="0.15" step="0.001" id="rds-inj-slider"
- oninput="onSlider('rdsInjection', this.value, 'rds-inj-val')">
- <span class="val-display" id="rds-inj-val">--</span>
- </div>
- </div>
- <div class="ctrl-row">
- <span class="ctrl-label">Limiter Ceil</span>
- <div class="ctrl-input">
- <input type="range" min="0" max="2" step="0.01" id="ceil-slider"
- oninput="onSlider('limiterCeiling', this.value, 'ceil-val')">
- <span class="val-display" id="ceil-val">--</span>
- </div>
- </div>
- <button class="apply-btn" id="levels-apply" onclick="applyLevels()">Apply Levels</button>
- </div>
- </div>
-
- <!-- Switches -->
- <div class="panel">
- <div class="panel-head" onclick="togglePanel(this)">
- <div class="led on-green" style="width:6px;height:6px"></div>
- <h2>Switches</h2>
- <span class="chevron">▼</span>
- </div>
- <div class="panel-body">
- <div class="ctrl-row">
- <span class="ctrl-label">Stereo</span>
- <div class="ctrl-input">
- <div class="toggle" id="tog-stereo" onclick="applyToggle('stereoEnabled', this)"></div>
- <span class="val-display" id="stereo-label">--</span>
- </div>
- </div>
- <div class="ctrl-row">
- <span class="ctrl-label">RDS</span>
- <div class="ctrl-input">
- <div class="toggle" id="tog-rds" onclick="applyToggle('rdsEnabled', this)"></div>
- <span class="val-display" id="rds-label">--</span>
- </div>
- </div>
- <div class="ctrl-row">
- <span class="ctrl-label">Limiter</span>
- <div class="ctrl-input">
- <div class="toggle" id="tog-limiter" onclick="applyToggle('limiterEnabled', this)"></div>
- <span class="val-display" id="limiter-label">--</span>
- </div>
- </div>
- </div>
- </div>
-
- <!-- RDS -->
- <div class="panel">
- <div class="panel-head" onclick="togglePanel(this)">
- <div class="led on-amber" style="width:6px;height:6px"></div>
- <h2>RDS</h2>
- <span class="chevron">▼</span>
- </div>
- <div class="panel-body">
- <div class="ctrl-row" style="flex-direction:column;align-items:stretch;gap:4px">
- <span class="ctrl-label">Program Service (PS)</span>
- <input type="text" class="rds-input" id="rds-ps" maxlength="8" placeholder="STATION">
- <div class="rds-charcount"><span id="ps-count">0</span>/8</div>
- </div>
- <div class="ctrl-row" style="flex-direction:column;align-items:stretch;gap:4px;margin-top:8px">
- <span class="ctrl-label">RadioText (RT)</span>
- <input type="text" class="rds-input rt" id="rds-rt" maxlength="64" placeholder="Now playing...">
- <div class="rds-charcount"><span id="rt-count">0</span>/64</div>
- </div>
- <button class="apply-btn" id="rds-apply" onclick="applyRDS()">Apply RDS Text</button>
- </div>
- </div>
-
- <!-- Log -->
- <div class="panel">
- <div class="panel-head" onclick="togglePanel(this)">
- <h2>Log</h2>
- <span class="chevron">▼</span>
- </div>
- <div class="panel-body">
- <div class="log" id="log"></div>
- </div>
- </div>
-
- </div>
-
- <!-- Toast -->
- <div class="toast" id="toast"></div>
-
- <script>
- const $ = id => document.getElementById(id);
-
- // State
- let cfg = {};
- let pending = {};
- let pollTimer = null;
-
- // --- API ---
- async function api(path, opts) {
- try {
- const r = await fetch(path, opts);
- const text = await r.text();
- if (!r.ok) throw new Error(text.trim() || `HTTP ${r.status}`);
- try { return JSON.parse(text); }
- catch(e) { return {ok: true}; }
- } catch(e) {
- throw e;
- }
- }
-
- async function loadConfig() {
- try {
- cfg = await api('/config');
- $('led-conn').className = 'led on-green';
- $('conn-label').textContent = 'connected';
- syncUI();
- } catch(e) {
- $('led-conn').className = 'led on-red';
- $('conn-label').textContent = 'offline';
- log('config load failed: ' + e.message, 'err');
- }
- }
-
- async function loadRuntime() {
- try {
- const rt = await api('/runtime');
- const eng = rt.engine || {};
- const drv = rt.driver || {};
-
- // TX state
- const state = eng.state || 'idle';
- const el = $('tx-state');
- el.textContent = state.toUpperCase();
- el.className = 'tx-state ' + state;
-
- // Telemetry
- $('t-chunks').textContent = fmt(eng.chunksProduced || 0);
- $('t-samples').textContent = fmt(eng.totalSamples || 0);
- const ur = eng.underruns || 0;
- const urEl = $('t-underruns');
- urEl.textContent = ur;
- urEl.className = 'value' + (ur > 0 ? ' err' : '');
- $('t-uptime').textContent = fmtTime(eng.uptimeSeconds || 0);
- $('t-rate').textContent = drv.effectiveSampleRateHz ? (drv.effectiveSampleRateHz/1000).toFixed(0) + 'k' : '--';
-
- $('led-conn').className = 'led on-green';
- $('conn-label').textContent = 'connected';
- } catch(e) {
- // Silent on poll errors
- }
- }
-
- async function txStart() {
- try {
- await api('/tx/start', {method:'POST'});
- toast('TX started', 'ok');
- log('TX started', 'ok');
- } catch(e) { toast(e.message, 'err'); log('TX start failed: ' + e.message, 'err'); }
- }
-
- async function txStop() {
- try {
- await api('/tx/stop', {method:'POST'});
- toast('TX stopped', 'ok');
- log('TX stopped', 'ok');
- } catch(e) { toast(e.message, 'err'); log('TX stop failed: ' + e.message, 'err'); }
- }
-
- async function sendPatch(patch, btnId) {
- const btn = btnId ? $(btnId) : null;
- if (btn) btn.classList.add('sending');
- try {
- const r = await api('/config', {
- method: 'POST',
- headers: {'Content-Type':'application/json'},
- body: JSON.stringify(patch)
- });
- Object.assign(cfg, flatCfg(patch));
- toast('Applied' + (r.live ? ' (live)' : ''), 'ok');
- log('PATCH ' + JSON.stringify(patch) + (r.live ? ' [live]' : ''), 'ok');
- if (btn) { btn.classList.remove('sending'); btn.classList.add('ok'); setTimeout(()=>{btn.classList.remove('ok','visible')}, 800); }
- pending = {};
- } catch(e) {
- toast(e.message, 'err');
- log('PATCH failed: ' + e.message, 'err');
- if (btn) btn.classList.remove('sending');
- }
- }
-
- // --- UI sync ---
- function syncUI() {
- // Frequency
- const freq = cfg.fm?.frequencyMHz || 100;
- $('freq-display').innerHTML = freq.toFixed(1) + '<span class="unit">MHz</span>';
- $('freq-slider').value = freq;
- $('freq-num').value = freq;
-
- // Levels
- setSlider('drive-slider', 'drive-val', cfg.fm?.outputDrive, 2);
- setSlider('pilot-slider', 'pilot-val', cfg.fm?.pilotLevel, 3);
- setSlider('rds-inj-slider', 'rds-inj-val', cfg.fm?.rdsInjection, 3);
- setSlider('ceil-slider', 'ceil-val', cfg.fm?.limiterCeiling, 2);
-
- // Toggles
- setToggle('tog-stereo', 'stereo-label', cfg.fm?.stereoEnabled);
- setToggle('tog-rds', 'rds-label', cfg.rds?.enabled);
- setToggle('tog-limiter', 'limiter-label', cfg.fm?.limiterEnabled);
-
- // RDS text
- $('rds-ps').value = cfg.rds?.ps || '';
- $('rds-rt').value = cfg.rds?.radioText || '';
- $('ps-count').textContent = ($('rds-ps').value || '').length;
- $('rt-count').textContent = ($('rds-rt').value || '').length;
- }
-
- function setSlider(sliderId, valId, value, decimals) {
- const v = value ?? 0;
- $(sliderId).value = v;
- $(valId).textContent = v.toFixed(decimals || 2);
- }
-
- function setToggle(togId, labelId, on) {
- $(togId).className = 'toggle' + (on ? ' on' : '');
- $(labelId).textContent = on ? 'ON' : 'OFF';
- }
-
- // --- Handlers ---
- function onFreqSlider(v) {
- v = parseFloat(v);
- $('freq-num').value = v.toFixed(1);
- $('freq-display').innerHTML = v.toFixed(1) + '<span class="unit">MHz</span>';
- pending.frequencyMHz = v;
- showApply('freq-apply');
- }
- function onFreqNum(v) {
- v = parseFloat(v);
- if (isNaN(v)) return;
- $('freq-slider').value = v;
- $('freq-display').innerHTML = v.toFixed(1) + '<span class="unit">MHz</span>';
- pending.frequencyMHz = v;
- showApply('freq-apply');
- }
- function applyFreq() {
- if (pending.frequencyMHz != null) sendPatch({frequencyMHz: pending.frequencyMHz}, 'freq-apply');
- }
-
- function onSlider(key, v, valId) {
- v = parseFloat(v);
- $(valId).textContent = v.toFixed(key === 'outputDrive' || key === 'limiterCeiling' ? 2 : 3);
- pending[key] = v;
- showApply('levels-apply');
- }
- function applyLevels() {
- const patch = {};
- for (const k of ['outputDrive','pilotLevel','rdsInjection','limiterCeiling']) {
- if (pending[k] != null) patch[k] = pending[k];
- }
- if (Object.keys(patch).length) sendPatch(patch, 'levels-apply');
- }
-
- function applyToggle(key, el) {
- const isOn = el.classList.contains('on');
- const newVal = !isOn;
- const patch = {};
- patch[key] = newVal;
- sendPatch(patch);
- // Optimistic UI
- el.classList.toggle('on');
- const labelId = el.id.replace('tog-', '') + '-label';
- const lbl = document.getElementById(labelId);
- if (lbl) lbl.textContent = newVal ? 'ON' : 'OFF';
- }
-
- function applyRDS() {
- const ps = $('rds-ps').value;
- const rt = $('rds-rt').value;
- const patch = {};
- if (ps !== (cfg.rds?.ps || '')) patch.ps = ps;
- if (rt !== (cfg.rds?.radioText || '')) patch.radioText = rt;
- if (Object.keys(patch).length) sendPatch(patch, 'rds-apply');
- else toast('No changes', 'ok');
- }
-
- // RDS char counters
- $('rds-ps').addEventListener('input', function() {
- $('ps-count').textContent = this.value.length;
- showApply('rds-apply');
- });
- $('rds-rt').addEventListener('input', function() {
- $('rt-count').textContent = this.value.length;
- showApply('rds-apply');
- });
-
- // --- Panel toggle ---
- function togglePanel(head) {
- head.classList.toggle('collapsed');
- head.nextElementSibling.classList.toggle('collapsed');
- }
-
- // --- Apply button visibility ---
- function showApply(btnId) {
- $(btnId).classList.add('visible');
- }
-
- // --- Toast ---
- function toast(msg, type) {
- const t = $('toast');
- t.textContent = msg;
- t.className = 'toast ' + type + ' show';
- clearTimeout(t._timer);
- t._timer = setTimeout(() => t.classList.remove('show'), 2500);
- }
-
- // --- Log ---
- function log(msg, type) {
- const el = $('log');
- const ts = new Date().toLocaleTimeString();
- const d = document.createElement('div');
- d.className = 'entry ' + (type || '');
- d.textContent = ts + ' ' + msg;
- el.appendChild(d);
- el.scrollTop = el.scrollHeight;
- // Keep max 200 entries
- while (el.children.length > 200) el.removeChild(el.firstChild);
- }
-
- // --- Helpers ---
- function fmt(n) {
- 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 n.toString();
- }
- function fmtTime(s) {
- if (!s || s <= 0) return '--';
- const h = Math.floor(s/3600);
- const m = Math.floor((s%3600)/60);
- const sec = Math.floor(s%60);
- if (h > 0) return h + 'h ' + m + 'm';
- if (m > 0) return m + 'm ' + sec + 's';
- return sec + 's';
- }
- function flatCfg(patch) {
- // Update local cfg mirror from patch keys
- const map = {
- frequencyMHz: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.frequencyMHz=v; },
- outputDrive: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.outputDrive=v; },
- stereoEnabled: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.stereoEnabled=v; },
- pilotLevel: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.pilotLevel=v; },
- rdsInjection: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.rdsInjection=v; },
- rdsEnabled: (v) => { if(!cfg.rds) cfg.rds={}; cfg.rds.enabled=v; },
- limiterEnabled: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.limiterEnabled=v; },
- limiterCeiling: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.limiterCeiling=v; },
- ps: (v) => { if(!cfg.rds) cfg.rds={}; cfg.rds.ps=v; },
- radioText: (v) => { if(!cfg.rds) cfg.rds={}; cfg.rds.radioText=v; },
- };
- for (const [k,v] of Object.entries(patch)) { if (map[k]) map[k](v); }
- return {};
- }
-
- // --- Init ---
- async function init() {
- log('fm-rds-tx web control initializing');
- await loadConfig();
- // Poll runtime every 500ms
- setInterval(loadRuntime, 500);
- // Reload config every 5s (catch external changes)
- setInterval(loadConfig, 5000);
- log('polling active', 'ok');
- }
- init();
- </script>
- </body>
- </html>
|