Files
DigiRadio/Software/components/net/www/index.html
T
micheleandCursor 4b931b0aad Add tuner controls to the embedded setup web UI.
Extends the gzipped page with DAB/FM tune, service list, play, and FM seek
against the /api/tuner endpoints.

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

403 lines
14 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-base: 1rem;
--text-lg: 1.25rem;
--text-xl: 1.75rem;
--accent: #c47a2c;
--bg: #0f1114;
--surface: #1a1d22;
--text: #e8eaed;
--muted: #9aa0a6;
--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;
display: flex;
align-items: flex-start;
justify-content: center;
padding: var(--space-3);
}
main {
background: var(--surface);
border-radius: var(--radius);
padding: var(--space-4);
max-width: 36rem;
width: 100%;
border-top: 3px solid var(--accent);
}
h1 { font-size: var(--text-xl); font-weight: 600; margin-bottom: var(--space-2); }
h2 { font-size: var(--text-lg); font-weight: 600; margin-bottom: var(--space-2); }
p { color: var(--muted); margin-bottom: var(--space-2); }
label {
display: block;
font-size: 0.875rem;
color: var(--muted);
margin-bottom: var(--space-1);
}
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;
}
button {
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.secondary {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
}
button:disabled { opacity: 0.6; cursor: wait; }
.status {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-lg);
margin-bottom: var(--space-3);
}
.dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--accent);
}
.msg { font-size: 0.875rem; margin-top: var(--space-2); min-height: 1.25rem; }
.msg.ok { color: #7cb342; }
.msg.err { color: #e57373; }
code {
font-size: 0.9em;
background: var(--bg);
padding: 0.1em 0.35em;
border-radius: 0.25rem;
}
section + section {
margin-top: var(--space-4);
padding-top: var(--space-3);
border-top: 1px solid #333;
}
.row { display: flex; gap: var(--space-2); margin-bottom: var(--space-2); }
.row button { flex: 1; }
.tuner-status {
background: var(--bg);
border-radius: var(--radius);
padding: var(--space-2);
font-size: 0.875rem;
margin-bottom: var(--space-2);
white-space: pre-wrap;
word-break: break-word;
}
.panel { display: none; }
.panel.active { display: block; }
ul.services {
list-style: none;
margin-bottom: var(--space-2);
max-height: 12rem;
overflow-y: auto;
}
ul.services li {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
border-bottom: 1px solid #2a2d33;
font-size: 0.875rem;
}
ul.services button {
width: auto;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
}
</style>
</head>
<body>
<main>
<h1>DigiRadio</h1>
<div class="status" id="health">
<span class="dot" aria-hidden="true"></span>
<span>Checking health…</span>
</div>
<section id="wifi-section">
<h2>WiFi</h2>
<p>Connect this radio to your network (setup mode).</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" id="save-btn">Save &amp; connect</button>
</form>
<p class="msg" id="wifi-msg" aria-live="polite"></p>
</section>
<section id="tuner-section">
<h2>Radio</h2>
<p>Control the DAB/FM tuner (available after companion chips boot).</p>
<div class="tuner-status" id="tuner-status">No status yet.</div>
<div class="row">
<button type="button" class="secondary" id="refresh-status">Refresh status</button>
</div>
<label for="band">Band</label>
<select id="band">
<option value="dab">DAB</option>
<option value="fm">FM</option>
</select>
<div id="panel-dab" class="panel active">
<label for="dab-index">Ensemble index (037)</label>
<input id="dab-index" type="number" min="0" max="37" value="12">
<button type="button" id="tune-dab">Tune DAB</button>
<div class="row" style="margin-top: var(--space-2);">
<button type="button" class="secondary" id="load-services">Load services</button>
</div>
<ul class="services" id="service-list"></ul>
</div>
<div id="panel-fm" class="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" id="tune-fm">Tune FM</button>
<div class="row" style="margin-top: var(--space-2);">
<button type="button" class="secondary" id="seek-fm">Seek up</button>
</div>
</div>
<p class="msg" id="tuner-msg" aria-live="polite"></p>
</section>
</main>
<script>
function showMsg(el, text, ok) {
el.textContent = text;
el.className = "msg " + (ok ? "ok" : "err");
}
function formatTunerStatus(s) {
var lines = [
"Booted: " + s.booted,
"Band: " + s.band,
"Locked: " + s.locked,
"Volume: " + s.volume
];
if (s.dab) {
lines.push("DAB index: " + (s.dab.freq_index != null ? s.dab.freq_index : "—"));
if (s.dab.fic_quality != null) lines.push("FIC: " + s.dab.fic_quality);
}
if (s.fm) {
if (s.fm.frequency_khz != null) lines.push("FM: " + s.fm.frequency_khz + " kHz");
if (s.fm.rssi_dbuv != null) lines.push("RSSI: " + s.fm.rssi_dbuv + " dBµV");
if (s.fm.stereo != null) lines.push("Stereo: " + s.fm.stereo);
}
return lines.join("\n");
}
fetch("/api/health")
.then(function (r) { return r.json(); })
.then(function (d) {
document.getElementById("health").innerHTML =
'<span class="dot"></span><span>Status: <code>' +
d.status + "</code> · FW <code>" + d.fw + "</code></span>";
})
.catch(function () {
document.getElementById("health").textContent = "Health check failed.";
});
document.getElementById("wifi-form").addEventListener("submit", function (e) {
e.preventDefault();
var btn = document.getElementById("save-btn");
var msg = document.getElementById("wifi-msg");
btn.disabled = true;
showMsg(msg, "Saving…", true);
fetch("/api/wifi", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ssid: document.getElementById("ssid").value,
password: document.getElementById("password").value
})
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.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;
});
});
document.getElementById("band").addEventListener("change", function () {
var dab = document.getElementById("panel-dab");
var fm = document.getElementById("panel-fm");
if (this.value === "dab") {
dab.classList.add("active");
fm.classList.remove("active");
} else {
fm.classList.add("active");
dab.classList.remove("active");
}
});
function refreshTunerStatus() {
var msg = document.getElementById("tuner-msg");
return fetch("/api/tuner/status")
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (res) {
if (res.ok && res.d.booted != null) {
document.getElementById("tuner-status").textContent = formatTunerStatus(res.d);
if (res.d.band === "fm") {
document.getElementById("band").value = "fm";
document.getElementById("band").dispatchEvent(new Event("change"));
}
showMsg(msg, "", true);
} else {
showMsg(msg, "Status error: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg(msg, "Status request failed.", false); });
}
document.getElementById("refresh-status").addEventListener("click", refreshTunerStatus);
document.getElementById("tune-dab").addEventListener("click", function () {
var msg = document.getElementById("tuner-msg");
var idx = parseInt(document.getElementById("dab-index").value, 10);
fetch("/api/tuner/tune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ band: "dab", freq_index: idx })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (res) {
if (res.ok && res.d.booted != null) {
document.getElementById("tuner-status").textContent = formatTunerStatus(res.d);
showMsg(msg, "DAB tuned.", true);
} else {
showMsg(msg, "Tune failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg(msg, "Tune request failed.", false); });
});
document.getElementById("tune-fm").addEventListener("click", function () {
var msg = document.getElementById("tuner-msg");
var khz = parseInt(document.getElementById("fm-khz").value, 10);
fetch("/api/tuner/tune", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ band: "fm", frequency_khz: khz })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (res) {
if (res.ok && res.d.booted != null) {
document.getElementById("tuner-status").textContent = formatTunerStatus(res.d);
showMsg(msg, "FM tuned.", true);
} else {
showMsg(msg, "Tune failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg(msg, "Tune request failed.", false); });
});
document.getElementById("seek-fm").addEventListener("click", function () {
var msg = document.getElementById("tuner-msg");
fetch("/api/tuner/seek", { method: "POST" })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (res) {
if (res.ok && res.d.frequency_khz != null) {
document.getElementById("fm-khz").value = res.d.frequency_khz;
showMsg(msg, "Seeked to " + res.d.frequency_khz + " kHz.", true);
refreshTunerStatus();
} else {
showMsg(msg, "Seek failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg(msg, "Seek request failed.", false); });
});
document.getElementById("load-services").addEventListener("click", function () {
var msg = document.getElementById("tuner-msg");
var list = document.getElementById("service-list");
list.innerHTML = "";
fetch("/api/tuner/services")
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (res) {
if (!res.ok || !res.d.services) {
showMsg(msg, "Services error: " + (res.d.reason || "unknown"), false);
return;
}
res.d.services.forEach(function (svc) {
var li = document.createElement("li");
var label = document.createElement("span");
label.textContent = svc.label || ("SID " + svc.service_id);
var btn = document.createElement("button");
btn.textContent = "Play";
btn.addEventListener("click", function () {
fetch("/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 (r) { return r.json(); })
.then(function (d) {
if (d.status === "playing") {
showMsg(msg, "Playing " + (svc.label || svc.service_id), true);
} else {
showMsg(msg, "Play failed: " + (d.reason || "unknown"), false);
}
})
.catch(function () { showMsg(msg, "Play request failed.", false); });
});
li.appendChild(label);
li.appendChild(btn);
list.appendChild(li);
});
showMsg(msg, res.d.services.length + " service(s).", true);
})
.catch(function () { showMsg(msg, "Services request failed.", false); });
});
refreshTunerStatus();
</script>
</body>
</html>