ソースを参照

feat: add embedded web UI for live TX control

Embed a browser-based control surface into the HTTP server and document the live-control API. The new UI exposes TX start/stop, runtime telemetry, frequency, levels, toggles, and RDS text updates against the existing live-config endpoints.
tags/v0.9.0
Jan Svabenik 1ヶ月前
コミット
9daa481954
2個のファイルの変更890行の追加0行の削除
  1. +15
    -0
      internal/control/control.go
  2. +875
    -0
      internal/control/ui.html

+ 15
- 0
internal/control/control.go ファイルの表示

@@ -1,6 +1,7 @@
package control

import (
_ "embed"
"encoding/json"
"net/http"
"sync"
@@ -10,6 +11,9 @@ import (
"github.com/jan/fm-rds-tx/internal/platform"
)

//go:embed ui.html
var uiHTML []byte

// TXController is an optional interface the Server uses to start/stop TX
// and apply live config changes.
type TXController interface {
@@ -76,6 +80,7 @@ func (s *Server) SetDriver(drv platform.SoapyDriver) {

func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", s.handleUI)
mux.HandleFunc("/healthz", s.handleHealth)
mux.HandleFunc("/status", s.handleStatus)
mux.HandleFunc("/dry-run", s.handleDryRun)
@@ -91,6 +96,16 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}

func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Write(uiHTML)
}

func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
cfg := s.cfg


+ 875
- 0
internal/control/ui.html ファイルの表示

@@ -0,0 +1,875 @@
<!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>

読み込み中…
キャンセル
保存