Files
DigiRadio/Software/components/net/www/index.html
T
micheleandCursor 8d826b4d73 Complete configuration Web UI with tabbed SPA (fw 0.8.2).
Add now-playing view with metadata polling, six-band EQ controls, full REST coverage, companion-chip badges, and gzip-www helper for the embedded setup page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 07:36:06 +02:00

998 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DigiRadio</title>
<style>
:root {
--space-1: 0.5rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2.5rem;
--text-sm: 0.8125rem;
--text-base: 1rem;
--text-lg: 1.25rem;
--text-xl: 1.75rem;
--text-2xl: 2.25rem;
--accent: #c47a2c;
--accent-dim: #8f5a1f;
--bg: #0f1114;
--surface: #1a1d22;
--surface-2: #242830;
--text: #e8eaed;
--muted: #9aa0a6;
--ok: #7cb342;
--err: #e57373;
--radius: 0.5rem;
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
font-size: var(--text-base);
line-height: 1.5;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: var(--space-2);
}
.shell {
max-width: 40rem;
margin: 0 auto;
}
header {
background: var(--surface);
border-radius: var(--radius);
padding: var(--space-3);
border-top: 3px solid var(--accent);
margin-bottom: var(--space-2);
}
header h1 { font-size: var(--text-xl); font-weight: 600; }
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1) var(--space-2);
margin-top: var(--space-1);
font-size: var(--text-sm);
color: var(--muted);
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
}
.chips { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.chip {
font-size: 0.75rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
background: var(--surface-2);
color: var(--muted);
}
.chip.on { color: var(--ok); border: 1px solid var(--ok); }
.chip.off { opacity: 0.55; }
nav.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: var(--space-2);
}
nav.tabs button {
flex: 1 1 auto;
min-width: 4.5rem;
padding: 0.55rem 0.65rem;
border: 1px solid #333;
border-radius: var(--radius);
background: var(--surface);
color: var(--muted);
font: inherit;
font-size: var(--text-sm);
cursor: pointer;
}
nav.tabs button.active {
color: var(--text);
border-color: var(--accent);
background: var(--surface-2);
}
.panel {
display: none;
background: var(--surface);
border-radius: var(--radius);
padding: var(--space-3);
}
.panel.active { display: block; }
h2 {
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: var(--space-1);
}
.lede {
color: var(--muted);
font-size: var(--text-sm);
margin-bottom: var(--space-2);
}
label {
display: block;
font-size: var(--text-sm);
color: var(--muted);
margin-bottom: 0.25rem;
}
input, select {
width: 100%;
padding: var(--space-1) var(--space-2);
margin-bottom: var(--space-2);
border: 1px solid #333;
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
}
input[type="range"] { padding: 0; accent-color: var(--accent); }
button.action {
width: 100%;
padding: var(--space-2);
border: none;
border-radius: var(--radius);
background: var(--accent);
color: var(--bg);
font: inherit;
font-weight: 600;
cursor: pointer;
}
button.action.secondary {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
}
button.action:disabled { opacity: 0.55; cursor: wait; }
button.action.compact {
width: auto;
padding: 0.35rem 0.65rem;
font-size: var(--text-sm);
font-weight: 500;
}
.row { display: flex; gap: var(--space-2); margin-bottom: var(--space-2); }
.row .action { flex: 1; }
.msg {
font-size: var(--text-sm);
margin-top: var(--space-2);
min-height: 1.25rem;
}
.msg.ok { color: var(--ok); }
.msg.err { color: var(--err); }
code {
font-size: 0.9em;
background: var(--bg);
padding: 0.1em 0.35em;
border-radius: 0.25rem;
}
.now-title {
font-size: var(--text-2xl);
font-weight: 600;
line-height: 1.2;
margin-bottom: var(--space-1);
word-break: break-word;
}
.now-sub {
color: var(--muted);
font-size: var(--text-base);
margin-bottom: var(--space-2);
min-height: 1.5rem;
word-break: break-word;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.stat {
background: var(--bg);
border-radius: var(--radius);
padding: var(--space-1) var(--space-2);
font-size: var(--text-sm);
}
.stat span { display: block; color: var(--muted); font-size: 0.75rem; }
.band-panel { display: none; }
.band-panel.show { display: block; }
ul.list {
list-style: none;
margin-bottom: var(--space-2);
max-height: 14rem;
overflow-y: auto;
}
ul.list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) 0;
border-bottom: 1px solid #2a2d33;
font-size: var(--text-sm);
}
ul.list li .label {
flex: 1;
min-width: 0;
word-break: break-word;
}
ul.list li .actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
justify-content: flex-end;
}
.eq-band {
display: grid;
grid-template-columns: 4rem 1fr 3rem;
align-items: center;
gap: var(--space-1);
margin-bottom: var(--space-1);
}
.eq-band label { margin: 0; font-size: 0.75rem; }
.eq-band output {
font-size: var(--text-sm);
text-align: right;
color: var(--muted);
}
.slider-row {
display: grid;
grid-template-columns: 1fr 3.5rem;
align-items: center;
gap: var(--space-1);
margin-bottom: var(--space-1);
}
.slider-row output {
font-size: var(--text-sm);
text-align: right;
color: var(--muted);
}
</style>
</head>
<body>
<div class="shell">
<header>
<h1>DigiRadio</h1>
<div class="meta" id="health-meta">
<span class="dot" aria-hidden="true"></span>
<span id="health-text">Checking…</span>
<div class="chips" id="chip-badges" hidden></div>
</div>
</header>
<nav class="tabs" role="tablist" aria-label="Sections">
<button type="button" class="active" data-tab="now" role="tab">Now</button>
<button type="button" data-tab="radio" role="tab">Radio</button>
<button type="button" data-tab="presets" role="tab">Presets</button>
<button type="button" data-tab="audio" role="tab">Audio</button>
<button type="button" data-tab="bt" role="tab">BT</button>
<button type="button" data-tab="wifi" role="tab">WiFi</button>
</nav>
<section id="panel-now" class="panel active" role="tabpanel">
<h2>Now playing</h2>
<p class="lede">Live metadata from RDS (FM) or Dynamic Label (DAB).</p>
<div class="now-title" id="now-title"></div>
<div class="now-sub" id="now-sub"></div>
<div class="stat-grid" id="now-stats"></div>
<div class="row">
<button type="button" class="action secondary" id="now-refresh">Refresh</button>
</div>
</section>
<section id="panel-radio" class="panel" role="tabpanel" hidden>
<h2>Tuner</h2>
<p class="lede">DAB ensemble tune, service play, or FM frequency and seek.</p>
<label for="band">Band</label>
<select id="band">
<option value="dab">DAB</option>
<option value="fm">FM</option>
</select>
<div id="panel-dab" class="band-panel show">
<label for="dab-index">Ensemble index (037)</label>
<input id="dab-index" type="number" min="0" max="37" value="12">
<button type="button" class="action" id="tune-dab">Tune DAB</button>
<div class="row">
<button type="button" class="action secondary" id="load-services">Load services</button>
</div>
<ul class="list" id="service-list"></ul>
</div>
<div id="panel-fm" class="band-panel">
<label for="fm-khz">Frequency (kHz)</label>
<input id="fm-khz" type="number" min="64000" max="108000" step="100" value="101500">
<button type="button" class="action" id="tune-fm">Tune FM</button>
<div class="row">
<button type="button" class="action secondary" id="seek-fm">Seek up</button>
</div>
</div>
<p class="msg" id="tuner-msg" aria-live="polite"></p>
</section>
<section id="panel-presets" class="panel" role="tabpanel" hidden>
<h2>Presets</h2>
<p class="lede">Saved stations in NVS — recall runs tuner + audio profile.</p>
<ul class="list" id="preset-list"></ul>
<label for="preset-name">Name</label>
<input id="preset-name" maxlength="32" placeholder="My station">
<label for="preset-slot">Preset slot (120, optional)</label>
<input id="preset-slot" type="number" min="1" max="20">
<button type="button" class="action" id="save-preset">Save current tune target</button>
<p class="msg" id="preset-msg" aria-live="polite"></p>
</section>
<section id="panel-audio" class="panel" role="tabpanel" hidden>
<h2>Audio</h2>
<p class="lede">ADAU1701 mixer, six-band EQ, and enhancement overlays.</p>
<div class="slider-row">
<label for="master-db">Master (dB)</label>
<output id="master-db-out">0</output>
</div>
<input id="master-db" type="range" min="-60" max="12" step="0.5" value="0">
<div class="slider-row">
<label for="si4684-db">Radio path (dB)</label>
<output id="si4684-db-out">0</output>
</div>
<input id="si4684-db" type="range" min="-60" max="12" step="0.5" value="0">
<div class="slider-row">
<label for="esp32-db">ESP32 path (dB)</label>
<output id="esp32-db-out">0</output>
</div>
<input id="esp32-db" type="range" min="-60" max="12" step="0.5" value="0">
<h2 style="margin-top: var(--space-2); font-size: var(--text-base);">EQ bands</h2>
<div id="eq-sliders"></div>
<div class="slider-row">
<label for="stereo-level">Stereo depth</label>
<output id="stereo-level-out">0</output>
</div>
<input id="stereo-level" type="range" min="0" max="100" step="1" value="0">
<div class="slider-row">
<label for="bass-level">Bass enhance</label>
<output id="bass-level-out">0</output>
</div>
<input id="bass-level" type="range" min="0" max="100" step="1" value="0">
<div class="row">
<button type="button" class="action" id="save-audio">Save profile</button>
<button type="button" class="action secondary" id="reset-audio">Reset flat</button>
</div>
<p class="msg" id="audio-msg" aria-live="polite"></p>
</section>
<section id="panel-bt" class="panel" role="tabpanel" hidden>
<h2>Bluetooth</h2>
<p class="lede">FSC-BT1035 aptX — pair headphones or speakers.</p>
<div class="stat-grid" id="bt-stats"></div>
<div class="row">
<button type="button" class="action" id="bt-refresh">Refresh</button>
<button type="button" class="action secondary" id="bt-pair">Pair</button>
</div>
<div class="row">
<button type="button" class="action secondary" id="bt-stop-pair">Stop pairing</button>
<button type="button" class="action secondary" id="bt-disconnect">Disconnect</button>
</div>
<p class="msg" id="bt-msg" aria-live="polite"></p>
</section>
<section id="panel-wifi" class="panel" role="tabpanel" hidden>
<h2>WiFi</h2>
<p class="lede">Provision STA credentials (setup mode: join <code>DigiRadio-setup</code>, open <code>192.168.4.1</code>).</p>
<form id="wifi-form">
<label for="ssid">Network name (SSID)</label>
<input id="ssid" name="ssid" autocomplete="off" required maxlength="32">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="off" maxlength="63">
<button type="submit" class="action" id="save-btn">Save &amp; connect</button>
</form>
<p class="msg" id="wifi-msg" aria-live="polite"></p>
</section>
</div>
<script>
var audioProfile = null;
var lastTunerStatus = null;
var pollTimer = null;
function $(id) { return document.getElementById(id); }
function showMsg(el, text, ok) {
el.textContent = text;
el.className = "msg " + (ok ? "ok" : "err");
}
function bindSlider(inputId, outId) {
var input = $(inputId);
var out = $(outId);
function sync() { out.textContent = input.value; }
input.addEventListener("input", sync);
sync();
}
function api(path, opts) {
opts = opts || {};
return fetch(path, opts).then(function (r) {
return r.json().then(function (d) {
return { ok: r.ok, status: r.status, d: d };
});
});
}
function setTab(name) {
document.querySelectorAll("nav.tabs button").forEach(function (btn) {
var on = btn.getAttribute("data-tab") === name;
btn.classList.toggle("active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
document.querySelectorAll(".panel").forEach(function (panel) {
var on = panel.id === "panel-" + name;
panel.classList.toggle("active", on);
panel.hidden = !on;
});
}
document.querySelectorAll("nav.tabs button").forEach(function (btn) {
btn.addEventListener("click", function () {
setTab(btn.getAttribute("data-tab"));
});
});
function renderChips(chips) {
var wrap = $("chip-badges");
if (!chips) {
wrap.hidden = true;
return;
}
wrap.hidden = false;
wrap.innerHTML = "";
[
["Si4684", chips.si4684],
["ADAU1701", chips.adau1701],
["BT1035", chips.bt1035]
].forEach(function (pair) {
var span = document.createElement("span");
span.className = "chip " + (pair[1] ? "on" : "off");
span.textContent = pair[0] + (pair[1] ? " ✓" : " —");
wrap.appendChild(span);
});
}
function nowPlayingFromStatus(s) {
if (!s || !s.booted) {
return { title: "Tuner not ready", sub: "" };
}
if (s.band === "fm" && s.fm) {
return {
title: s.fm.station_name || (s.fm.frequency_khz + " kHz"),
sub: s.fm.radiotext || ""
};
}
if (s.band === "dab" && s.dab) {
return {
title: s.dab.dynamic_label || ("Ensemble " + s.dab.freq_index),
sub: s.dab.playing_service_id != null
? "Service " + s.dab.playing_service_id : ""
};
}
return { title: "—", sub: "" };
}
function renderNowPlaying(s) {
lastTunerStatus = s;
var np = nowPlayingFromStatus(s);
$("now-title").textContent = np.title;
$("now-sub").textContent = np.sub;
var stats = [];
if (s && s.booted != null) {
stats.push(["Band", s.band]);
stats.push(["Locked", s.locked ? "yes" : "no"]);
stats.push(["Volume", s.volume]);
if (s.dab) {
if (s.dab.freq_index != null) stats.push(["DAB idx", s.dab.freq_index]);
if (s.dab.fic_quality != null) stats.push(["FIC", s.dab.fic_quality]);
if (s.dab.cnr_db != null) stats.push(["CNR", s.dab.cnr_db + " dB"]);
}
if (s.fm) {
if (s.fm.frequency_khz != null) stats.push(["FM", s.fm.frequency_khz + " kHz"]);
if (s.fm.rssi_dbuv != null) stats.push(["RSSI", s.fm.rssi_dbuv + " dBµV"]);
if (s.fm.stereo != null) stats.push(["Stereo", s.fm.stereo ? "yes" : "no"]);
}
} else {
stats.push(["Status", "unavailable"]);
}
$("now-stats").innerHTML = stats.map(function (pair) {
return '<div class="stat"><span>' + pair[0] +
'</span>' + pair[1] + "</div>";
}).join("");
}
function refreshTunerStatus() {
return api("/api/tuner/status")
.then(function (res) {
if (res.ok && res.d.booted != null) {
renderNowPlaying(res.d);
if (res.d.band === "fm") {
$("band").value = "fm";
} else if (res.d.band === "dab") {
$("band").value = "dab";
}
syncBandPanels();
showMsg($("tuner-msg"), "", true);
} else {
showMsg($("tuner-msg"), "Status: " + (res.d.reason || "error"), false);
}
})
.catch(function () {
showMsg($("tuner-msg"), "Status request failed.", false);
});
}
function syncBandPanels() {
var dab = $("panel-dab");
var fm = $("panel-fm");
if ($("band").value === "dab") {
dab.classList.add("show");
fm.classList.remove("show");
} else {
fm.classList.add("show");
dab.classList.remove("show");
}
}
$("band").addEventListener("change", syncBandPanels);
function buildEqSliders(eq) {
var root = $("eq-sliders");
root.innerHTML = "";
(eq || []).forEach(function (band, i) {
var row = document.createElement("div");
row.className = "eq-band";
var hz = band.center_hz >= 1000
? (band.center_hz / 1000) + "k" : band.center_hz;
row.innerHTML =
'<label for="eq-' + i + '">' + hz + " Hz</label>" +
'<input id="eq-' + i + '" type="range" min="-12" max="12" step="0.5" value="' +
band.gain_db + '">' +
'<output id="eq-' + i + '-out">' + band.gain_db + "</output>";
root.appendChild(row);
bindSlider("eq-" + i, "eq-" + i + "-out");
});
}
function loadAudioProfile() {
return api("/api/audio/profile")
.then(function (res) {
if (!res.ok) {
showMsg($("audio-msg"), "Load failed.", false);
return;
}
audioProfile = res.d;
$("master-db").value = res.d.master.left_db;
$("si4684-db").value = res.d.mixer.si4684_left_db;
$("esp32-db").value = res.d.mixer.esp32_left_db;
$("master-db-out").textContent = res.d.master.left_db;
$("si4684-db-out").textContent = res.d.mixer.si4684_left_db;
$("esp32-db-out").textContent = res.d.mixer.esp32_left_db;
if (res.d.enhancements) {
$("stereo-level").value = res.d.enhancements.stereo_level;
$("bass-level").value = res.d.enhancements.bass_level;
$("stereo-level-out").textContent = res.d.enhancements.stereo_level;
$("bass-level-out").textContent = res.d.enhancements.bass_level;
}
buildEqSliders(res.d.eq);
showMsg($("audio-msg"), "", true);
})
.catch(function () {
showMsg($("audio-msg"), "Audio profile load failed.", false);
});
}
function collectAudioProfile() {
if (!audioProfile) return null;
var master = parseFloat($("master-db").value, 10);
var si4684 = parseFloat($("si4684-db").value, 10);
var esp32 = parseFloat($("esp32-db").value, 10);
audioProfile.master.left_db = master;
audioProfile.master.right_db = master;
audioProfile.mixer.si4684_left_db = si4684;
audioProfile.mixer.si4684_right_db = si4684;
audioProfile.mixer.esp32_left_db = esp32;
audioProfile.mixer.esp32_right_db = esp32;
if (!audioProfile.enhancements) {
audioProfile.enhancements = { stereo_level: 0, bass_level: 0 };
}
audioProfile.enhancements.stereo_level =
parseInt($("stereo-level").value, 10);
audioProfile.enhancements.bass_level =
parseInt($("bass-level").value, 10);
if (audioProfile.eq) {
audioProfile.eq.forEach(function (band, i) {
var el = $("eq-" + i);
if (el) band.gain_db = parseFloat(el.value, 10);
});
}
return audioProfile;
}
function renderBtStats(s) {
if (!s || s.booted == null) {
$("bt-stats").innerHTML = "";
return;
}
var rows = [
["Booted", s.booted ? "yes" : "no"],
["Pairing", s.pairing ? "yes" : "no"],
["A2DP", s.a2dp || "—"]
];
$("bt-stats").innerHTML = rows.map(function (pair) {
return '<div class="stat"><span>' + pair[0] +
'</span>' + pair[1] + "</div>";
}).join("");
}
function refreshBluetooth() {
return api("/api/bluetooth/status")
.then(function (res) {
if (res.ok && res.d.booted != null) {
renderBtStats(res.d);
showMsg($("bt-msg"), "", true);
} else {
showMsg($("bt-msg"), "BT: " + (res.d.reason || "error"), false);
}
})
.catch(function () {
showMsg($("bt-msg"), "BT request failed.", false);
});
}
function renderPresets(data) {
var list = $("preset-list");
list.innerHTML = "";
(data.stations || []).forEach(function (s, idx) {
var li = document.createElement("li");
var label = document.createElement("span");
label.className = "label";
var text = s.name + " · " + s.band;
if (s.fm_frequency_khz) text += " · " + s.fm_frequency_khz + " kHz";
if (s.dab_freq_index != null) text += " · idx " + s.dab_freq_index;
if (s.dab_service_id != null) text += " · svc " + s.dab_service_id;
label.textContent = text;
var actions = document.createElement("div");
actions.className = "actions";
function mkBtn(caption, secondary, fn) {
var b = document.createElement("button");
b.type = "button";
b.className = "action compact" + (secondary ? " secondary" : "");
b.textContent = caption;
b.addEventListener("click", fn);
return b;
}
actions.appendChild(mkBtn("Tune", false, function () {
api("/api/stations/tune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ index: idx })
}).then(function () { refreshTunerStatus(); setTab("now"); });
}));
actions.appendChild(mkBtn("Up", true, function () {
if (idx === 0) return;
api("/api/stations/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: idx, to: idx - 1 })
}).then(function () { loadPresets(); });
}));
actions.appendChild(mkBtn("Dn", true, function () {
api("/api/stations/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: idx, to: idx + 1 })
}).then(function () { loadPresets(); });
}));
actions.appendChild(mkBtn("Del", true, function () {
api("/api/stations/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ index: idx })
}).then(function () { loadPresets(); });
}));
li.appendChild(label);
li.appendChild(actions);
list.appendChild(li);
});
}
function loadPresets() {
return api("/api/stations")
.then(function (res) {
if (res.ok) renderPresets(res.d);
})
.catch(function () {});
}
function buildPresetBody(name, band, status) {
var body = { name: name, band: band };
if (band === "fm") {
body.fm_frequency_khz = status && status.fm && status.fm.frequency_khz != null
? status.fm.frequency_khz
: parseInt($("fm-khz").value, 10);
} else {
body.dab_freq_index = status && status.dab && status.dab.freq_index != null
? status.dab.freq_index
: parseInt($("dab-index").value, 10);
if (status && status.dab && status.dab.playing_service_id != null) {
body.dab_service_id = status.dab.playing_service_id;
}
if (status && status.dab && status.dab.playing_component_id != null) {
body.dab_component_id = status.dab.playing_component_id;
}
}
var slotVal = $("preset-slot").value;
if (slotVal) body.preset_slot = parseInt(slotVal, 10);
return body;
}
function postEnhance(url, level) {
return api(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ level: level })
});
}
api("/api/health")
.then(function (res) {
if (res.ok) {
$("health-text").innerHTML =
'Status <code>' + res.d.status + "</code> · FW <code>" + res.d.fw + "</code>";
renderChips(res.d.chips);
} else {
$("health-text").textContent = "Health check failed.";
}
})
.catch(function () {
$("health-text").textContent = "Health check failed.";
});
$("wifi-form").addEventListener("submit", function (e) {
e.preventDefault();
var btn = $("save-btn");
var msg = $("wifi-msg");
btn.disabled = true;
showMsg(msg, "Saving…", true);
api("/api/wifi", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ssid: $("ssid").value,
password: $("password").value
})
})
.then(function (res) {
if (res.ok && res.d.status === "saved") {
showMsg(msg, "Saved. Rebooting in " + res.d.reboot_in_sec + "s…", true);
} else {
showMsg(msg, "Error: " + (res.d.reason || "unknown"), false);
btn.disabled = false;
}
})
.catch(function () {
showMsg(msg, "Request failed.", false);
btn.disabled = false;
});
});
$("now-refresh").addEventListener("click", refreshTunerStatus);
$("tune-dab").addEventListener("click", function () {
var idx = parseInt($("dab-index").value, 10);
api("/api/tuner/tune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ band: "dab", freq_index: idx })
})
.then(function (res) {
if (res.ok && res.d.booted != null) {
renderNowPlaying(res.d);
showMsg($("tuner-msg"), "DAB tuned.", true);
} else {
showMsg($("tuner-msg"), "Tune failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("tuner-msg"), "Tune failed.", false); });
});
$("tune-fm").addEventListener("click", function () {
var khz = parseInt($("fm-khz").value, 10);
api("/api/tuner/tune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ band: "fm", frequency_khz: khz })
})
.then(function (res) {
if (res.ok && res.d.booted != null) {
renderNowPlaying(res.d);
showMsg($("tuner-msg"), "FM tuned.", true);
} else {
showMsg($("tuner-msg"), "Tune failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("tuner-msg"), "Tune failed.", false); });
});
$("seek-fm").addEventListener("click", function () {
api("/api/tuner/seek", { method: "POST" })
.then(function (res) {
if (res.ok && res.d.frequency_khz != null) {
$("fm-khz").value = res.d.frequency_khz;
showMsg($("tuner-msg"), "Seeked to " + res.d.frequency_khz + " kHz.", true);
refreshTunerStatus();
} else {
showMsg($("tuner-msg"), "Seek failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("tuner-msg"), "Seek failed.", false); });
});
$("load-services").addEventListener("click", function () {
var list = $("service-list");
list.innerHTML = "";
api("/api/tuner/services")
.then(function (res) {
if (!res.ok || !res.d.services) {
showMsg($("tuner-msg"), "Services: " + (res.d.reason || "error"), false);
return;
}
res.d.services.forEach(function (svc) {
var li = document.createElement("li");
var label = document.createElement("span");
label.className = "label";
label.textContent = svc.label || ("SID " + svc.service_id);
var btn = document.createElement("button");
btn.type = "button";
btn.className = "action compact";
btn.textContent = "Play";
btn.addEventListener("click", function () {
api("/api/tuner/play", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
service_id: svc.service_id,
component_id: svc.component_id
})
})
.then(function (res) {
if (res.ok && res.d.status === "playing") {
showMsg($("tuner-msg"), "Playing " + (svc.label || svc.service_id), true);
refreshTunerStatus();
} else {
showMsg($("tuner-msg"), "Play failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("tuner-msg"), "Play failed.", false); });
});
li.appendChild(label);
li.appendChild(btn);
list.appendChild(li);
});
showMsg($("tuner-msg"), res.d.services.length + " service(s).", true);
})
.catch(function () { showMsg($("tuner-msg"), "Services failed.", false); });
});
$("save-audio").addEventListener("click", function () {
var body = collectAudioProfile();
if (!body) {
showMsg($("audio-msg"), "Profile not loaded.", false);
return;
}
api("/api/audio/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
})
.then(function (res) {
if (res.ok && res.d.status === "saved") {
showMsg($("audio-msg"), "Audio profile saved.", true);
} else {
showMsg($("audio-msg"), "Save failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("audio-msg"), "Save failed.", false); });
});
$("stereo-level").addEventListener("change", function () {
var level = parseInt($("stereo-level").value, 10);
postEnhance("/api/audio/stereo-enhance", level)
.then(function (res) {
showMsg($("audio-msg"), res.ok ? "Stereo overlay applied." : "Enhance failed.", res.ok);
});
});
$("bass-level").addEventListener("change", function () {
var level = parseInt($("bass-level").value, 10);
postEnhance("/api/audio/bass-enhance", level)
.then(function (res) {
showMsg($("audio-msg"), res.ok ? "Bass overlay applied." : "Enhance failed.", res.ok);
});
});
$("reset-audio").addEventListener("click", function () {
api("/api/audio/reset", { method: "POST" })
.then(function (res) {
if (res.ok && res.d.status === "saved") {
showMsg($("audio-msg"), "Factory-flat profile applied.", true);
loadAudioProfile();
} else {
showMsg($("audio-msg"), "Reset failed.", false);
}
})
.catch(function () { showMsg($("audio-msg"), "Reset failed.", false); });
});
$("bt-refresh").addEventListener("click", refreshBluetooth);
$("bt-pair").addEventListener("click", function () {
api("/api/bluetooth/pair", { method: "POST" })
.then(function (res) {
showMsg($("bt-msg"), res.ok ? "Discoverable — pair from your phone." : "Pair failed.", res.ok);
if (res.ok) refreshBluetooth();
});
});
$("bt-stop-pair").addEventListener("click", function () {
api("/api/bluetooth/pair/stop", { method: "POST" })
.then(function (res) {
showMsg($("bt-msg"), res.ok ? "Pairing stopped." : "Stop failed.", res.ok);
if (res.ok) refreshBluetooth();
});
});
$("bt-disconnect").addEventListener("click", function () {
api("/api/bluetooth/disconnect", { method: "POST" })
.then(function (res) {
showMsg($("bt-msg"), res.ok ? "A2DP disconnected." : "Disconnect failed.", res.ok);
if (res.ok) refreshBluetooth();
});
});
$("save-preset").addEventListener("click", function () {
var name = $("preset-name").value.trim();
if (!name) {
showMsg($("preset-msg"), "Enter a name.", false);
return;
}
var band = $("band").value;
api("/api/tuner/status")
.then(function (res) { return res.d; })
.then(function (status) {
return api("/api/stations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildPresetBody(name, band, status))
});
})
.then(function (res) {
if (res.ok && res.d.status === "saved") {
showMsg($("preset-msg"), "Preset saved.", true);
loadPresets();
} else {
showMsg($("preset-msg"), "Save failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("preset-msg"), "Save failed.", false); });
});
bindSlider("master-db", "master-db-out");
bindSlider("si4684-db", "si4684-db-out");
bindSlider("esp32-db", "esp32-db-out");
bindSlider("stereo-level", "stereo-level-out");
bindSlider("bass-level", "bass-level-out");
refreshTunerStatus();
refreshBluetooth();
loadPresets();
loadAudioProfile();
pollTimer = setInterval(refreshTunerStatus, 5000);
</script>
</body>
</html>