Streaming (main feature this session): - New WebRadioConfig/WebRadioJson core types, ISecureStore-backed persistence - New webradio::WebRadioService (thread-safe live config) + GET/POST /api/streaming - web_radio_stream task now runtime-toggleable (no reboot), no hardcoded URL - Content-Type diagnostic: warns clearly when a URL is a webpage, not an audio stream Boot cleanup: - Removed boot-time auto FM/DAB tune, auto-beep, and the (now-concluded) Si4684 crystal IBIAS/CTUN empirical sweep from main.cpp — tuning/beep are on-demand via the existing REST API only Web UI: - Modernized styling (cards, gradients, toggle switches, light/dark theme) - New Stream tab wired to /api/streaming Fixes found via real idf.py build (not just clangd): - Restored wrongly-removed si4684/Si4684Tuner.hpp include in main.cpp - Fixed MP3Decode() argument types in web_radio_stream.cpp (unsigned char**/int*) Quality-gate fixes: - Host-test stub headers (esp_log.h, freertos/*) so TunerService.cpp's scanForStation logging/pacing compiles for station_service_test / integration_service_test instead of running stale binaries - Added WifiScanner and WebRadioService manual sections; filled in missing Doxygen docs on BluetoothService, i2s_sdata_probe, test_firmware, Bt1035At - Ignore clangd's .cache/ index directory Also includes prior uncommitted work carried in the tree: Wi-Fi/Bluetooth device scan REST API and UI (WifiScanner, BT scan), SigmaStudio TCP bridge, and the current ADAU1701 SigmaStudio DSP program export. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1774 lines
66 KiB
HTML
1774 lines
66 KiB
HTML
<!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: #ff9a3c;
|
||
--accent-2: #ff6a88;
|
||
--accent-dim: #c4701f;
|
||
--bg: #0b0c10;
|
||
--surface: #16181d;
|
||
--surface-2: #1e2128;
|
||
--surface-3: #262a33;
|
||
--border: #2a2e37;
|
||
--text: #f1f2f4;
|
||
--muted: #9aa0ac;
|
||
--ok: #6fd08c;
|
||
--err: #ff7a7a;
|
||
--radius: 0.85rem;
|
||
--radius-sm: 0.55rem;
|
||
--shadow: 0 1px 2px rgba(0,0,0,0.3), 0 8px 24px -12px rgba(0,0,0,0.5);
|
||
--font: "SF Pro Display", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
}
|
||
:root[data-theme="light"] {
|
||
--bg: #f3f2ef;
|
||
--surface: #ffffff;
|
||
--surface-2: #f6f5f2;
|
||
--surface-3: #ececea;
|
||
--border: #e4e2dd;
|
||
--text: #1c1c1e;
|
||
--muted: #6b6f76;
|
||
--shadow: 0 1px 2px rgba(20,20,20,0.05), 0 10px 24px -16px rgba(20,20,20,0.25);
|
||
}
|
||
@media (prefers-color-scheme: light) {
|
||
:root:not([data-theme="dark"]) {
|
||
--bg: #f3f2ef;
|
||
--surface: #ffffff;
|
||
--surface-2: #f6f5f2;
|
||
--surface-3: #ececea;
|
||
--border: #e4e2dd;
|
||
--text: #1c1c1e;
|
||
--muted: #6b6f76;
|
||
--shadow: 0 1px 2px rgba(20,20,20,0.05), 0 10px 24px -16px rgba(20,20,20,0.25);
|
||
}
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: var(--font);
|
||
font-size: var(--text-base);
|
||
line-height: 1.5;
|
||
background:
|
||
radial-gradient(1200px 480px at 15% -10%, color-mix(in srgb, var(--accent) 14%, transparent), transparent),
|
||
var(--bg);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
padding: var(--space-2);
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
.shell {
|
||
max-width: 42rem;
|
||
margin: 0 auto;
|
||
}
|
||
header {
|
||
background: linear-gradient(155deg, var(--surface) 0%, var(--surface-2) 100%);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: var(--space-3);
|
||
margin-bottom: var(--space-2);
|
||
box-shadow: var(--shadow);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
header::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0 0 auto 0;
|
||
height: 3px;
|
||
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
||
}
|
||
header h1 {
|
||
font-size: var(--text-xl);
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
background: linear-gradient(90deg, var(--text), var(--text) 60%, var(--accent));
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
}
|
||
.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.5rem;
|
||
height: 0.5rem;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
flex-shrink: 0;
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
|
||
}
|
||
.chips { display: flex; flex-wrap: wrap; gap: var(--space-1); }
|
||
.chip {
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
padding: 0.2rem 0.55rem;
|
||
border-radius: 999px;
|
||
background: var(--surface-3);
|
||
color: var(--muted);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.chip.on { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, var(--border)); }
|
||
.chip.off { opacity: 0.5; }
|
||
nav.tabs {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.3rem;
|
||
margin-bottom: var(--space-2);
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 0.3rem;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
nav.tabs button {
|
||
flex: 1 1 auto;
|
||
min-width: 4rem;
|
||
padding: 0.55rem 0.6rem;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
color: var(--muted);
|
||
font: inherit;
|
||
font-size: var(--text-sm);
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
nav.tabs button:hover { color: var(--text); }
|
||
nav.tabs button.active {
|
||
color: var(--bg);
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||
font-weight: 600;
|
||
box-shadow: 0 4px 12px -4px color-mix(in srgb, var(--accent) 60%, transparent);
|
||
}
|
||
.panel {
|
||
display: none;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: var(--space-3);
|
||
box-shadow: var(--shadow);
|
||
animation: panel-in 0.18s ease-out;
|
||
}
|
||
@keyframes panel-in {
|
||
from { opacity: 0; transform: translateY(4px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
.panel.active { display: block; }
|
||
h2 {
|
||
font-size: var(--text-lg);
|
||
font-weight: 700;
|
||
letter-spacing: -0.01em;
|
||
margin-bottom: var(--space-1);
|
||
}
|
||
h3.subhead {
|
||
font-size: var(--text-base);
|
||
font-weight: 600;
|
||
margin: var(--space-3) 0 var(--space-1);
|
||
padding-top: var(--space-2);
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.lede {
|
||
color: var(--muted);
|
||
font-size: var(--text-sm);
|
||
margin-bottom: var(--space-2);
|
||
}
|
||
.hint {
|
||
color: var(--muted);
|
||
font-size: var(--text-sm);
|
||
margin-bottom: var(--space-1);
|
||
}
|
||
.bt-guide {
|
||
margin-bottom: var(--space-2);
|
||
padding: var(--space-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-2);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.bt-guide summary { cursor: pointer; font-weight: 600; }
|
||
.bt-guide ol {
|
||
margin: var(--space-1) 0 0 1.25rem;
|
||
padding: 0;
|
||
}
|
||
.bt-guide li { margin-bottom: 0.35rem; }
|
||
label {
|
||
display: block;
|
||
font-size: var(--text-sm);
|
||
font-weight: 500;
|
||
color: var(--muted);
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
input, select {
|
||
width: 100%;
|
||
padding: 0.65rem var(--space-2);
|
||
margin-bottom: var(--space-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-2);
|
||
color: var(--text);
|
||
font: inherit;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
}
|
||
input:focus, select:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||
}
|
||
input[type="file"] {
|
||
padding: var(--space-1);
|
||
font-size: var(--text-sm);
|
||
}
|
||
input[type="range"] { padding: 0; accent-color: var(--accent); }
|
||
input[type="checkbox"] {
|
||
width: auto;
|
||
margin: 0 0.4rem 0 0;
|
||
accent-color: var(--accent);
|
||
vertical-align: middle;
|
||
}
|
||
button.action {
|
||
width: 100%;
|
||
padding: 0.75rem var(--space-2);
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||
color: #1a1206;
|
||
font: inherit;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: transform 0.1s, box-shadow 0.15s, opacity 0.15s;
|
||
box-shadow: 0 4px 14px -6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||
}
|
||
button.action:hover { transform: translateY(-1px); }
|
||
button.action:active { transform: translateY(0); }
|
||
button.action.secondary {
|
||
background: var(--surface-2);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
box-shadow: none;
|
||
}
|
||
button.action.secondary:hover { border-color: var(--accent); }
|
||
button.action:disabled { opacity: 0.5; cursor: wait; transform: none; }
|
||
button.action.compact {
|
||
width: auto;
|
||
padding: 0.35rem 0.65rem;
|
||
font-size: var(--text-sm);
|
||
font-weight: 600;
|
||
box-shadow: none;
|
||
}
|
||
.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(--surface-3);
|
||
padding: 0.1em 0.4em;
|
||
border-radius: 0.3rem;
|
||
}
|
||
.now-title {
|
||
font-size: var(--text-2xl);
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
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(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 0.6rem var(--space-2);
|
||
font-size: var(--text-sm);
|
||
font-weight: 600;
|
||
}
|
||
.stat span { display: block; color: var(--muted); font-size: 0.7rem; font-weight: 500; margin-bottom: 0.1rem; }
|
||
.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: 0.6rem 0;
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: var(--text-sm);
|
||
}
|
||
ul.list li:last-child { border-bottom: none; }
|
||
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;
|
||
}
|
||
ul.pick-list {
|
||
list-style: none;
|
||
margin-bottom: var(--space-2);
|
||
max-height: 16rem;
|
||
overflow-y: auto;
|
||
}
|
||
ul.pick-list li.pick-item {
|
||
display: block;
|
||
padding: var(--space-2);
|
||
margin-bottom: var(--space-1);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-2);
|
||
cursor: pointer;
|
||
font-size: var(--text-sm);
|
||
transition: border-color 0.15s, background 0.15s;
|
||
}
|
||
ul.pick-list li.pick-item:hover {
|
||
border-color: var(--accent);
|
||
}
|
||
ul.pick-list li.pick-item.selected {
|
||
border-color: var(--accent);
|
||
background: color-mix(in srgb, var(--accent) 12%, var(--surface-2));
|
||
box-shadow: inset 0 0 0 1px var(--accent);
|
||
}
|
||
ul.pick-list li.pick-item .pick-title {
|
||
font-weight: 600;
|
||
word-break: break-word;
|
||
}
|
||
ul.pick-list li.pick-item .pick-meta {
|
||
margin-top: 0.2rem;
|
||
color: var(--muted);
|
||
font-size: 0.75rem;
|
||
}
|
||
ul.pick-list li.pick-hint {
|
||
padding: var(--space-2);
|
||
color: var(--muted);
|
||
font-size: var(--text-sm);
|
||
border: 1px dashed var(--border);
|
||
border-radius: var(--radius-sm);
|
||
text-align: center;
|
||
}
|
||
.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);
|
||
}
|
||
.switch-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-2);
|
||
margin-bottom: var(--space-2);
|
||
}
|
||
.switch-row .switch-label { flex: 1; }
|
||
.switch-row .switch-label strong { display: block; color: var(--text); font-size: var(--text-base); font-weight: 600; }
|
||
.switch-row .switch-label span { display: block; color: var(--muted); font-size: var(--text-sm); margin-top: 0.15rem; }
|
||
.switch {
|
||
position: relative;
|
||
display: inline-block;
|
||
width: 3rem;
|
||
height: 1.75rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.switch input {
|
||
opacity: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
margin: 0;
|
||
position: absolute;
|
||
inset: 0;
|
||
cursor: pointer;
|
||
z-index: 1;
|
||
}
|
||
.switch .track {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: var(--surface-3);
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
transition: background 0.2s;
|
||
}
|
||
.switch .thumb {
|
||
position: absolute;
|
||
top: 0.2rem;
|
||
left: 0.2rem;
|
||
width: 1.25rem;
|
||
height: 1.25rem;
|
||
border-radius: 50%;
|
||
background: var(--muted);
|
||
transition: transform 0.2s, background 0.2s;
|
||
}
|
||
.switch input:checked ~ .track {
|
||
background: color-mix(in srgb, var(--accent) 35%, var(--surface-3));
|
||
border-color: var(--accent);
|
||
}
|
||
.switch input:checked ~ .thumb {
|
||
transform: translateX(1.25rem);
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||
}
|
||
</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="stream" role="tab">Stream</button>
|
||
<button type="button" data-tab="bt" role="tab">BT</button>
|
||
<button type="button" data-tab="wifi" role="tab">Wi‑Fi</button>
|
||
<button type="button" data-tab="system" role="tab">System</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 (0–37)</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-up">Seek up</button>
|
||
<button type="button" class="action secondary" id="seek-fm-down">Seek down</button>
|
||
</div>
|
||
</div>
|
||
<p class="msg" id="tuner-msg" aria-live="polite"></p>
|
||
<h3 class="subhead">Test — cerca stazione</h3>
|
||
<p class="hint">FM: seek automatico fino a trovare segnale (e nome RDS se indicato). DAB: prova gli ensemble.</p>
|
||
<label for="scan-name">Nome stazione (opzionale)</label>
|
||
<input id="scan-name" type="text" maxlength="32" placeholder="es. monte carlo">
|
||
<button type="button" class="action secondary" id="tuner-scan">Cerca stazione</button>
|
||
<p class="msg" id="scan-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 (1–20, 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-stream" class="panel" role="tabpanel" hidden>
|
||
<h2>Internet radio streaming</h2>
|
||
<p class="lede">Streams an HTTP MP3 URL, decodes it on the ESP32, and plays it through the ADAU1701 — independent of Si4684 FM/DAB. Takes effect immediately, no reboot needed.</p>
|
||
<div class="switch-row">
|
||
<div class="switch-label">
|
||
<strong>Streaming</strong>
|
||
<span id="stream-status-text">Loading…</span>
|
||
</div>
|
||
<label class="switch">
|
||
<input type="checkbox" id="stream-enabled">
|
||
<span class="track"></span>
|
||
<span class="thumb"></span>
|
||
</label>
|
||
</div>
|
||
<label for="stream-url">Stream URL (http://)</label>
|
||
<input id="stream-url" type="text" autocomplete="off" placeholder="http://example.com/stream.mp3">
|
||
<button type="button" class="action" id="stream-save">Save & apply</button>
|
||
<p class="hint">No sample-rate conversion — best results with 48 kHz MP3 streams.</p>
|
||
<p class="msg" id="stream-msg" aria-live="polite"></p>
|
||
</section>
|
||
|
||
<section id="panel-bt" class="panel" role="tabpanel" hidden>
|
||
<h2>Bluetooth</h2>
|
||
<p class="lede">Soundbar senza display (Bose): <strong>salva il MAC</strong> e connetti con <code>AT+A2DPCONN</code> — lo scan spesso non la trova.</p>
|
||
<h3 class="subhead">Soundbar predefinita (NVS)</h3>
|
||
<p class="hint" id="bt-saved-speaker">Caricamento…</p>
|
||
<div class="row">
|
||
<button type="button" class="action secondary" id="bt-reconnect-saved">Reconnect saved</button>
|
||
<button type="button" class="action secondary" id="bt-clear-saved">Rimuovi salvata</button>
|
||
</div>
|
||
<label><input type="checkbox" id="bt-save-default" checked> Salva come soundbar predefinita dopo Connect</label>
|
||
<details class="bt-guide" open>
|
||
<summary>Bose Solo II — accoppiamento (solo telecomando)</summary>
|
||
<ol>
|
||
<li>Sul telecomando Bose: tieni premuto <strong>Bluetooth</strong> finché la spia Bluetooth <strong>lampeggia blu</strong> (non bianca).</li>
|
||
<li>Sul telefono nelle vicinanze: <strong>Bluetooth spento</strong> (evita che la Bose si connetta al phone).</li>
|
||
<li>Spegni o allontana altri dispositivi BT (proiettori, casse).</li>
|
||
<li>Avvia <strong>Scan</strong> qui sotto entro 10 secondi.</li>
|
||
<li>Se compare <strong>Bose Solo</strong> o <strong>(no name)</strong>, selezionala e premi <strong>Connect</strong>.</li>
|
||
<li>Se non compare in scan: inserisci il <strong>MAC</strong> sotto e premi <strong>Connect MAC</strong> (con Bose in blu lampeggiante).</li>
|
||
<li>Spunta <strong>Salva come predefinita</strong> — all'accensione il DigiRadio farà <code>A2DPCONN</code> automatico.</li>
|
||
</ol>
|
||
<p class="hint">La spia arancione a sinistra = Dialogue mode (audio), non c’entra con il Bluetooth. Discoverable sul DigiRadio <em>non</em> serve per la Bose (non può scegliere dispositivi senza schermo).</p>
|
||
</details>
|
||
<div class="stat-grid" id="bt-stats"></div>
|
||
<h3 class="subhead">Nearby devices</h3>
|
||
<ul class="pick-list" id="bt-scan-list"></ul>
|
||
<div class="row">
|
||
<button type="button" class="action" id="bt-scan">Scan</button>
|
||
<button type="button" class="action" id="bt-connect-selected" disabled>Connect</button>
|
||
</div>
|
||
<h3 class="subhead">Connessione manuale (MAC)</h3>
|
||
<p class="hint">12 caratteri hex, senza due punti — es. <code>A1B2C3D4E5F6</code></p>
|
||
<div class="row">
|
||
<input id="bt-mac-manual" type="text" autocomplete="off" maxlength="17"
|
||
placeholder="MAC Bluetooth soundbar" pattern="[0-9A-Fa-f:]{12,17}">
|
||
<input id="bt-speaker-name" type="text" autocomplete="off" maxlength="31"
|
||
placeholder="Nome (es. Bose Solo II)">
|
||
<button type="button" class="action secondary" id="bt-connect-mac">Connect MAC</button>
|
||
</div>
|
||
<h3 class="subhead">Paired devices</h3>
|
||
<ul class="list" id="bt-paired-list"></ul>
|
||
<div class="slider-row">
|
||
<label for="bt-auto-reconnect">Auto-reconnect</label>
|
||
<output id="bt-auto-reconnect-out">0</output>
|
||
</div>
|
||
<input id="bt-auto-reconnect" type="range" min="0" max="15" step="1" value="0">
|
||
<div class="row">
|
||
<button type="button" class="action secondary" id="bt-load-paired">Paired list</button>
|
||
<button type="button" class="action secondary" id="bt-refresh">Refresh</button>
|
||
<button type="button" class="action secondary" id="bt-pair">Discoverable</button>
|
||
<button type="button" class="action secondary" id="bt-stop-pair">Stop discoverable</button>
|
||
</div>
|
||
<div class="row">
|
||
<button type="button" class="action secondary" id="bt-disconnect">Disconnect</button>
|
||
<button type="button" class="action secondary" id="bt-save-reconnect">Save reconnect</button>
|
||
</div>
|
||
<p class="msg" id="bt-msg" aria-live="polite"></p>
|
||
</section>
|
||
|
||
<section id="panel-wifi" class="panel" role="tabpanel" hidden>
|
||
<h2>Wi‑Fi</h2>
|
||
<p class="lede">Join the DigiRadio SoftAP and open <code>192.168.4.1</code>. Tap a network to select it, enter the password, then save.</p>
|
||
<h3 class="subhead">Nearby networks</h3>
|
||
<ul class="pick-list" id="wifi-scan-list"></ul>
|
||
<div class="row">
|
||
<button type="button" class="action" id="wifi-scan">Scan</button>
|
||
</div>
|
||
<h3 class="subhead">Selected network</h3>
|
||
<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 & connect</button>
|
||
</form>
|
||
<p class="msg" id="wifi-msg" aria-live="polite"></p>
|
||
</section>
|
||
|
||
<section id="panel-system" class="panel" role="tabpanel" hidden>
|
||
<h2>System</h2>
|
||
<p class="lede">Firmware and ADAU1701 program updates. Device reboots after a successful upload.</p>
|
||
<label for="ota-file">Firmware image (.bin)</label>
|
||
<input id="ota-file" type="file" accept=".bin,application/octet-stream">
|
||
<button type="button" class="action" id="ota-upload">Upload firmware OTA</button>
|
||
<label for="dsp-file" style="margin-top: var(--space-2);">ADAU1701 program (DRAD blob)</label>
|
||
<input id="dsp-file" type="file" accept=".bin,.dsp,application/octet-stream">
|
||
<button type="button" class="action secondary" id="dsp-upload">Upload DSP program</button>
|
||
<h3 class="subhead">ADAU1701 test tone</h3>
|
||
<p class="lede">Beep1 generator in the DSP program — bring-up aid, not saved to the audio profile.</p>
|
||
<button type="button" class="action secondary" id="beep-on">Start tone</button>
|
||
<button type="button" class="action secondary" id="beep-off">Stop tone</button>
|
||
<p class="msg" id="system-msg" aria-live="polite"></p>
|
||
</section>
|
||
</div>
|
||
|
||
<script>
|
||
var audioProfile = null;
|
||
var lastTunerStatus = null;
|
||
var pollTimer = null;
|
||
var selectedBtDevice = null;
|
||
var selectedWifiSsid = 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) {
|
||
if (r.status === 204 || r.headers.get("content-length") === "0") {
|
||
return { ok: r.ok, status: r.status, d: {} };
|
||
}
|
||
return r.text().then(function (text) {
|
||
var d = {};
|
||
if (text) {
|
||
try { d = JSON.parse(text); } catch (e) { d = { raw: text }; }
|
||
}
|
||
return { ok: r.ok, status: r.status, d: d };
|
||
});
|
||
});
|
||
}
|
||
|
||
function uploadBinary(path, file) {
|
||
return fetch(path, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/octet-stream" },
|
||
body: file
|
||
}).then(function (r) {
|
||
return r.text().then(function (text) {
|
||
var d = {};
|
||
if (text) {
|
||
try { d = JSON.parse(text); } catch (e) { d = { raw: text }; }
|
||
}
|
||
return { ok: r.ok, status: r.status, d: d };
|
||
});
|
||
});
|
||
}
|
||
|
||
function seekFm(direction) {
|
||
api("/api/tuner/seek", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ direction: direction })
|
||
})
|
||
.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 || res.d.error || "unknown"), false);
|
||
}
|
||
})
|
||
.catch(function () { showMsg($("tuner-msg"), "Seek failed.", false); });
|
||
}
|
||
|
||
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;
|
||
});
|
||
if (name === "wifi") {
|
||
loadWifiScan();
|
||
}
|
||
if (name === "stream") {
|
||
loadStreamingConfig();
|
||
}
|
||
}
|
||
|
||
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);
|
||
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"],
|
||
["Name", s.device_name || "—"],
|
||
["Pairing", s.pairing ? "yes" : "no"],
|
||
["A2DP", s.a2dp || "—"],
|
||
["Auto-reconnect", s.auto_reconnect != null ? s.auto_reconnect : "—"]
|
||
];
|
||
$("bt-stats").innerHTML = rows.map(function (pair) {
|
||
return '<div class="stat"><span>' + pair[0] +
|
||
'</span>' + pair[1] + "</div>";
|
||
}).join("");
|
||
if (s.auto_reconnect != null) {
|
||
$("bt-auto-reconnect").value = s.auto_reconnect;
|
||
$("bt-auto-reconnect-out").textContent = s.auto_reconnect;
|
||
}
|
||
}
|
||
|
||
function loadStreamingConfig() {
|
||
return api("/api/streaming")
|
||
.then(function (res) {
|
||
if (!res.ok) {
|
||
$("stream-status-text").textContent = "Unavailable.";
|
||
return;
|
||
}
|
||
$("stream-enabled").checked = !!res.d.enabled;
|
||
$("stream-url").value = res.d.url || "";
|
||
$("stream-status-text").textContent = res.d.enabled
|
||
? "Playing — audio replaces the DSP mixer's ESP32 input."
|
||
: "Off.";
|
||
})
|
||
.catch(function () {
|
||
$("stream-status-text").textContent = "Load failed.";
|
||
});
|
||
}
|
||
|
||
$("stream-save").addEventListener("click", function () {
|
||
var url = $("stream-url").value.trim();
|
||
var enabled = $("stream-enabled").checked;
|
||
if (enabled && url.indexOf("http://") !== 0) {
|
||
showMsg($("stream-msg"), "URL must start with http://", false);
|
||
return;
|
||
}
|
||
api("/api/streaming", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ enabled: enabled, url: url })
|
||
})
|
||
.then(function (res) {
|
||
if (res.ok) {
|
||
showMsg($("stream-msg"), "Saved and applied.", true);
|
||
loadStreamingConfig();
|
||
} else {
|
||
showMsg($("stream-msg"), "Save failed: " + (res.d.reason || "unknown"), false);
|
||
}
|
||
})
|
||
.catch(function () { showMsg($("stream-msg"), "Save request failed.", false); });
|
||
});
|
||
|
||
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);
|
||
})
|
||
.then(function () { return loadSavedSpeaker(); });
|
||
}
|
||
|
||
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 () {
|
||
$("band").value = s.band;
|
||
syncBandPanels();
|
||
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 })
|
||
});
|
||
}
|
||
|
||
function loadBluetoothPaired() {
|
||
return api("/api/bluetooth/paired")
|
||
.then(function (res) {
|
||
var list = $("bt-paired-list");
|
||
list.innerHTML = "";
|
||
if (!res.ok || !res.d.devices) {
|
||
showMsg($("bt-msg"), "Paired list failed.", false);
|
||
return;
|
||
}
|
||
res.d.devices.forEach(function (device) {
|
||
var li = document.createElement("li");
|
||
var label = document.createElement("span");
|
||
label.className = "label";
|
||
label.textContent = device.index + " · " + device.mac +
|
||
(device.name ? " · " + device.name : "");
|
||
li.appendChild(label);
|
||
list.appendChild(li);
|
||
});
|
||
showMsg($("bt-msg"), res.d.devices.length + " paired device(s).", true);
|
||
})
|
||
.catch(function () { showMsg($("bt-msg"), "Paired list failed.", false); });
|
||
}
|
||
|
||
function showPickHint(listEl, text) {
|
||
listEl.innerHTML = "";
|
||
var li = document.createElement("li");
|
||
li.className = "pick-hint";
|
||
li.textContent = text;
|
||
listEl.appendChild(li);
|
||
}
|
||
|
||
function markPickSelected(listEl, keyAttr, keyValue) {
|
||
listEl.querySelectorAll(".pick-item").forEach(function (item) {
|
||
item.classList.toggle("selected", item.getAttribute(keyAttr) === keyValue);
|
||
});
|
||
}
|
||
|
||
function updateBtConnectButton() {
|
||
var btn = $("bt-connect-selected");
|
||
btn.disabled = !selectedBtDevice;
|
||
btn.textContent = selectedBtDevice
|
||
? "Connect · " + (selectedBtDevice.name || selectedBtDevice.mac)
|
||
: "Connect";
|
||
}
|
||
|
||
function normalizeBtMac(raw) {
|
||
return String(raw || "").replace(/[^0-9A-Fa-f]/g, "").toUpperCase();
|
||
}
|
||
|
||
function loadSavedSpeaker() {
|
||
return api("/api/bluetooth/speaker")
|
||
.then(function (res) {
|
||
var el = $("bt-saved-speaker");
|
||
if (!res.ok || !res.d.configured) {
|
||
el.textContent = "Nessuna soundbar salvata — usa Connect MAC o Scan + Salva.";
|
||
return;
|
||
}
|
||
el.textContent = (res.d.name || "(no name)") + " · " + res.d.mac;
|
||
if (!$("bt-mac-manual").value) {
|
||
$("bt-mac-manual").value = res.d.mac;
|
||
}
|
||
})
|
||
.catch(function () {
|
||
$("bt-saved-speaker").textContent = "Impossibile leggere soundbar salvata.";
|
||
});
|
||
}
|
||
|
||
function connectBluetooth(mac, name) {
|
||
var normalized = normalizeBtMac(mac);
|
||
if (normalized.length !== 12) {
|
||
showMsg($("bt-msg"), "MAC non valido — servono 12 caratteri hex.", false);
|
||
return Promise.resolve();
|
||
}
|
||
var save = $("bt-save-default").checked;
|
||
var label = name || $("bt-speaker-name").value || normalized;
|
||
showMsg($("bt-msg"), "Connecting to " + label + "… (~30 s)", true);
|
||
return api("/api/bluetooth/connect", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ mac: normalized, name: label, save: save })
|
||
})
|
||
.then(function (res) {
|
||
if (res.ok) {
|
||
showMsg($("bt-msg"), "Connesso e " + (save ? "salvato in NVS." : "OK."), true);
|
||
loadSavedSpeaker();
|
||
return refreshBluetooth();
|
||
}
|
||
showMsg($("bt-msg"), "Connect failed: " + (res.d.reason || "error"), false);
|
||
})
|
||
.catch(function () { showMsg($("bt-msg"), "Connect request failed.", false); });
|
||
}
|
||
|
||
function loadBluetoothScan() {
|
||
var btn = $("bt-scan");
|
||
btn.disabled = true;
|
||
selectedBtDevice = null;
|
||
updateBtConnectButton();
|
||
showMsg($("bt-msg"), "Scanning classic Bluetooth (~45 s) — speaker must be in pairing mode now.", true);
|
||
console.log("[bt] scan start");
|
||
return api("/api/bluetooth/scan", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ seconds: 45 })
|
||
})
|
||
.then(function (res) {
|
||
console.log("[bt] scan response", res.status, res.d);
|
||
var list = $("bt-scan-list");
|
||
list.innerHTML = "";
|
||
if (!res.ok || !res.d.devices) {
|
||
showPickHint(list, "Scan failed — press Scan to retry.");
|
||
showMsg($("bt-msg"), "Scan failed: " + (res.d.reason || "error"), false);
|
||
return;
|
||
}
|
||
if (res.d.devices.length === 0) {
|
||
showPickHint(list, "No devices found — put the speaker in pairing mode (Bose: Bluetooth button until blue blink), then scan again.");
|
||
showMsg($("bt-msg"), "No devices found.", false);
|
||
return;
|
||
}
|
||
res.d.devices.forEach(function (device) {
|
||
var li = document.createElement("li");
|
||
li.className = "pick-item";
|
||
li.setAttribute("data-mac", device.mac);
|
||
li.setAttribute("role", "button");
|
||
li.setAttribute("tabindex", "0");
|
||
var title = document.createElement("div");
|
||
title.className = "pick-title";
|
||
title.textContent = device.name || "(no name)";
|
||
var meta = document.createElement("div");
|
||
meta.className = "pick-meta";
|
||
var rssi = device.rssi_dbm != null ? device.rssi_dbm + " dBm · " : "";
|
||
meta.textContent = rssi + device.mac;
|
||
li.appendChild(title);
|
||
li.appendChild(meta);
|
||
function selectDevice() {
|
||
selectedBtDevice = device;
|
||
markPickSelected(list, "data-mac", device.mac);
|
||
updateBtConnectButton();
|
||
showMsg($("bt-msg"), "Selected \"" + (device.name || device.mac) + "\" — press Connect.", true);
|
||
}
|
||
li.addEventListener("click", selectDevice);
|
||
li.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
selectDevice();
|
||
}
|
||
});
|
||
list.appendChild(li);
|
||
});
|
||
showMsg($("bt-msg"), res.d.devices.length + " device(s) — tap one to select.", true);
|
||
})
|
||
.catch(function () {
|
||
showPickHint($("bt-scan-list"), "Scan request failed — press Scan to retry.");
|
||
showMsg($("bt-msg"), "Scan request failed.", false);
|
||
})
|
||
.finally(function () { btn.disabled = false; });
|
||
}
|
||
|
||
function refreshHealth() {
|
||
return api("/api/health")
|
||
.then(function (res) {
|
||
if (res.ok && res.d.status === "ok") {
|
||
var serial = res.d.serialNumber || "unknown";
|
||
var ipLine = res.d.ip ? ' · IP <code>' + res.d.ip + "</code>" : "";
|
||
$("health-text").innerHTML =
|
||
'Status <code>' + res.d.status + "</code> · FW <code>" + res.d.fw +
|
||
"</code> · SN <code>" + serial + "</code>" + ipLine;
|
||
renderChips(res.d.chips);
|
||
} else {
|
||
$("health-text").textContent = "Health check failed — retrying…";
|
||
}
|
||
})
|
||
.catch(function () {
|
||
$("health-text").textContent = "Health check failed — retrying…";
|
||
});
|
||
}
|
||
|
||
refreshHealth()
|
||
.then(function () { return refreshTunerStatus(); })
|
||
.then(function () { return refreshBluetooth(); })
|
||
.then(function () { return loadPresets(); })
|
||
.then(function () { return loadAudioProfile(); })
|
||
.then(function () { return loadStreamingConfig(); });
|
||
|
||
setInterval(refreshHealth, 5000);
|
||
pollTimer = setInterval(function () { refreshTunerStatus(); }, 5000);
|
||
|
||
function loadWifiScan() {
|
||
var btn = $("wifi-scan");
|
||
btn.disabled = true;
|
||
showMsg($("wifi-msg"), "Scanning nearby networks (~5 s)…", true);
|
||
return api("/api/wifi/scan", { method: "POST" })
|
||
.then(function (res) {
|
||
var list = $("wifi-scan-list");
|
||
list.innerHTML = "";
|
||
if (!res.ok || !res.d.networks) {
|
||
showPickHint(list, "Scan failed — press Scan to retry.");
|
||
showMsg($("wifi-msg"), "Scan failed: " + (res.d.reason || "error"), false);
|
||
return;
|
||
}
|
||
if (res.d.networks.length === 0) {
|
||
showPickHint(list, "No networks found — press Scan to retry.");
|
||
showMsg($("wifi-msg"), "No networks found.", false);
|
||
return;
|
||
}
|
||
res.d.networks.forEach(function (network) {
|
||
var li = document.createElement("li");
|
||
li.className = "pick-item";
|
||
li.setAttribute("data-ssid", network.ssid);
|
||
li.setAttribute("role", "button");
|
||
li.setAttribute("tabindex", "0");
|
||
var title = document.createElement("div");
|
||
title.className = "pick-title";
|
||
title.textContent = network.ssid;
|
||
var meta = document.createElement("div");
|
||
meta.className = "pick-meta";
|
||
var rssi = network.rssi_dbm != null ? network.rssi_dbm + " dBm · " : "";
|
||
var auth = network.auth || "unknown";
|
||
var channel = network.channel != null ? " · ch " + network.channel : "";
|
||
meta.textContent = rssi + auth + channel;
|
||
li.appendChild(title);
|
||
li.appendChild(meta);
|
||
function selectNetwork() {
|
||
selectedWifiSsid = network.ssid;
|
||
$("ssid").value = network.ssid;
|
||
markPickSelected(list, "data-ssid", network.ssid);
|
||
$("password").focus();
|
||
showMsg($("wifi-msg"), "Selected \"" + network.ssid + "\" — enter password and save.", true);
|
||
}
|
||
li.addEventListener("click", selectNetwork);
|
||
li.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
selectNetwork();
|
||
}
|
||
});
|
||
list.appendChild(li);
|
||
});
|
||
showMsg($("wifi-msg"), res.d.networks.length + " network(s) — tap one to select.", true);
|
||
})
|
||
.catch(function () {
|
||
showPickHint($("wifi-scan-list"), "Scan request failed — press Scan to retry.");
|
||
showMsg($("wifi-msg"), "Scan request failed.", false);
|
||
})
|
||
.finally(function () { btn.disabled = false; });
|
||
}
|
||
|
||
showPickHint($("wifi-scan-list"), "Press Scan or open this tab to search networks.");
|
||
showPickHint($("bt-scan-list"), "Press Scan to search Bluetooth devices.");
|
||
|
||
$("wifi-scan").addEventListener("click", loadWifiScan);
|
||
|
||
$("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") {
|
||
var ssid = $("ssid").value;
|
||
showMsg(msg,
|
||
"Saved. DigiRadio reboots in " + res.d.reboot_in_sec +
|
||
"s — then connect THIS phone/PC to Wi‑Fi \"" + ssid +
|
||
"\" and open http://digiradio-CC4DB4.local/ (or the IP in serial log).",
|
||
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", function () { 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-up").addEventListener("click", function () { seekFm("up"); });
|
||
$("seek-fm-down").addEventListener("click", function () { seekFm("down"); });
|
||
|
||
$("tuner-scan").addEventListener("click", function () {
|
||
var btn = $("tuner-scan");
|
||
var band = $("band").value;
|
||
var name = $("scan-name").value.trim();
|
||
btn.disabled = true;
|
||
showMsg($("scan-msg"),
|
||
band === "fm"
|
||
? "Scansione FM in corso (~30–60 s)…"
|
||
: "Scansione DAB in corso (~1–2 min)…",
|
||
true);
|
||
var body = { band: band, max_steps: band === "fm" ? 45 : 38 };
|
||
if (name) body.name = name;
|
||
api("/api/tuner/scan", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body)
|
||
})
|
||
.then(function (res) {
|
||
btn.disabled = false;
|
||
if (!res.ok) {
|
||
showMsg($("scan-msg"), "Scan failed: " + (res.d.reason || "error"), false);
|
||
return;
|
||
}
|
||
if (res.d.status === "found") {
|
||
var label = res.d.station_name || "(no name)";
|
||
if (band === "fm" && res.d.frequency_khz) {
|
||
$("fm-khz").value = res.d.frequency_khz;
|
||
showMsg($("scan-msg"),
|
||
"Trovata: " + label + " · " + res.d.frequency_khz + " kHz (" + res.d.steps + " step)",
|
||
true);
|
||
} else if (band === "dab" && res.d.freq_index != null) {
|
||
$("dab-index").value = res.d.freq_index;
|
||
showMsg($("scan-msg"),
|
||
"Trovata: " + label + " · ensemble " + res.d.freq_index + " (" + res.d.steps + " step)",
|
||
true);
|
||
} else {
|
||
showMsg($("scan-msg"), "Trovata: " + label, true);
|
||
}
|
||
refreshTunerStatus();
|
||
setTab("now");
|
||
return;
|
||
}
|
||
showMsg($("scan-msg"),
|
||
"Nessuna stazione trovata dopo " + (res.d.steps || "?") + " tentativi.",
|
||
false);
|
||
})
|
||
.catch(function () {
|
||
btn.disabled = false;
|
||
showMsg($("scan-msg"), "Scan request 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-scan").addEventListener("click", loadBluetoothScan);
|
||
$("bt-connect-selected").addEventListener("click", function () {
|
||
if (!selectedBtDevice) return;
|
||
connectBluetooth(selectedBtDevice.mac, selectedBtDevice.name);
|
||
});
|
||
$("bt-connect-mac").addEventListener("click", function () {
|
||
connectBluetooth($("bt-mac-manual").value, $("bt-speaker-name").value);
|
||
});
|
||
$("bt-reconnect-saved").addEventListener("click", function () {
|
||
showMsg($("bt-msg"), "Reconnect saved speaker…", true);
|
||
api("/api/bluetooth/reconnect", { method: "POST" })
|
||
.then(function (res) {
|
||
showMsg($("bt-msg"), res.ok ? "Reconnect OK." : ("Failed: " + (res.d.reason || "error")), res.ok);
|
||
if (res.ok) refreshBluetooth();
|
||
})
|
||
.catch(function () { showMsg($("bt-msg"), "Reconnect failed.", false); });
|
||
});
|
||
$("bt-clear-saved").addEventListener("click", function () {
|
||
api("/api/bluetooth/speaker", { method: "DELETE" })
|
||
.then(function (res) {
|
||
showMsg($("bt-msg"), res.ok ? "Soundbar salvata rimossa." : "Clear failed.", res.ok);
|
||
loadSavedSpeaker();
|
||
});
|
||
});
|
||
$("bt-pair").addEventListener("click", function () {
|
||
api("/api/bluetooth/pair", { method: "POST" })
|
||
.then(function (res) {
|
||
showMsg($("bt-msg"), res.ok
|
||
? "Discoverable attivo — utile solo se un altro dispositivo deve trovare il DigiRadio (non per Bose)."
|
||
: "Discoverable mode 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 ? "Discoverable mode 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();
|
||
});
|
||
});
|
||
$("bt-load-paired").addEventListener("click", loadBluetoothPaired);
|
||
bindSlider("bt-auto-reconnect", "bt-auto-reconnect-out");
|
||
$("bt-save-reconnect").addEventListener("click", function () {
|
||
var level = parseInt($("bt-auto-reconnect").value, 10);
|
||
api("/api/bluetooth/auto-reconnect", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ times: level })
|
||
}).then(function (res) {
|
||
showMsg($("bt-msg"), res.ok ? "Auto-reconnect saved." : "Save failed.", res.ok);
|
||
if (res.ok) refreshBluetooth();
|
||
});
|
||
});
|
||
|
||
$("ota-upload").addEventListener("click", function () {
|
||
var file = $("ota-file").files[0];
|
||
var msg = $("system-msg");
|
||
if (!file) {
|
||
showMsg(msg, "Choose a firmware .bin file.", false);
|
||
return;
|
||
}
|
||
showMsg(msg, "Uploading firmware…", true);
|
||
uploadBinary("/api/system/ota", file)
|
||
.then(function (res) {
|
||
if (res.ok && res.d.status === "stored") {
|
||
showMsg(msg, "Firmware stored. Rebooting in " + res.d.reboot_sec + "s…", true);
|
||
} else {
|
||
showMsg(msg, "OTA failed: " + (res.d.error || res.d.reason || "unknown"), false);
|
||
}
|
||
})
|
||
.catch(function () { showMsg(msg, "OTA upload failed.", false); });
|
||
});
|
||
|
||
$("dsp-upload").addEventListener("click", function () {
|
||
var file = $("dsp-file").files[0];
|
||
var msg = $("system-msg");
|
||
if (!file) {
|
||
showMsg(msg, "Choose a DRAD blob.", false);
|
||
return;
|
||
}
|
||
showMsg(msg, "Uploading DSP program…", true);
|
||
uploadBinary("/api/dsp/program", file)
|
||
.then(function (res) {
|
||
if (res.ok && res.d.status === "stored") {
|
||
showMsg(msg, "DSP program stored. Rebooting in " + res.d.reboot_sec + "s…", true);
|
||
} else {
|
||
showMsg(msg, "DSP upload failed: " + (res.d.error || res.d.reason || "unknown"), false);
|
||
}
|
||
})
|
||
.catch(function () { showMsg(msg, "DSP upload failed.", false); });
|
||
});
|
||
|
||
function postBeep(enabled) {
|
||
var msg = $("system-msg");
|
||
return api("/api/audio/beep", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ enabled: enabled })
|
||
}).then(function (res) {
|
||
if (res.ok) {
|
||
showMsg(msg, enabled ? "Tone started." : "Tone stopped.", true);
|
||
} else {
|
||
showMsg(msg, "Beep failed: " + (res.d.error || res.d.reason || "unknown"), false);
|
||
}
|
||
}).catch(function () { showMsg(msg, "Beep request failed.", false); });
|
||
}
|
||
|
||
$("beep-on").addEventListener("click", function () { postBeep(true); });
|
||
$("beep-off").addEventListener("click", function () { postBeep(false); });
|
||
|
||
$("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");
|
||
</script>
|
||
</body>
|
||
</html>
|