feat(ui): improve radio operator experience
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 1s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 0s

This commit was merged in pull request #15.
This commit is contained in:
sjg
2026-08-01 02:24:47 +02:00
parent 6676b66993
commit a0bdaa2c4b
21 changed files with 528 additions and 65 deletions
@@ -1317,6 +1317,9 @@ function showHint(msg, duration) {
powerHint.textContent = msg;
if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration);
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
window.trxUi?.notify(msg, { kind: "error" });
}
}
let supportedModes = [];
let supportedBands = [];
@@ -2831,7 +2834,7 @@ function showUnsupportedFreqPopup(hz) {
const now = Date.now();
if (now - lastUnsupportedFreqPopupAt < 1200) return;
lastUnsupportedFreqPopupAt = now;
window.alert(message);
window.trxUi?.notify(message.replaceAll("\n", " "), { kind: "error", duration: 7000 });
}
// Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit).
@@ -3429,7 +3432,11 @@ function render(update) {
if (update.status && typeof update.status.tx_en === "boolean" && update.status.tx_en !== prevRenderData.txEn) {
prevRenderData.txEn = update.status.tx_en;
lastTxEn = update.status.tx_en;
pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off";
window.trxUi?.setButtonState(pttBtn, {
active: update.status.tx_en,
activeLabel: "Stop TX",
inactiveLabel: "Start TX",
});
if (update.status.tx_en) {
pttBtn.style.background = "var(--accent-red)";
pttBtn.style.borderColor = "var(--accent-red)";
@@ -3538,11 +3545,15 @@ function render(update) {
bandLabel.textContent = typeof update.band === "string" ? update.band : "--";
}
if (typeof update.enabled === "boolean") {
powerBtn.disabled = false;
powerBtn.textContent = update.enabled ? "Power Off" : "Power On";
window.trxUi?.setButtonState(powerBtn, {
active: update.enabled,
activeLabel: "Power Off",
inactiveLabel: "Power On",
});
} else {
powerBtn.disabled = true;
powerBtn.textContent = "Toggle Power";
powerBtn.textContent = "Power unavailable";
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown";
}
lastControl = update.enabled;
@@ -3657,7 +3668,11 @@ function render(update) {
}
powerHint.textContent = readyText();
lastLocked = update.status && update.status.lock === true;
lockBtn.textContent = lastLocked ? "Unlock" : "Lock";
window.trxUi?.setButtonState(lockBtn, {
active: lastLocked,
activeLabel: "Unlock Tuning",
inactiveLabel: "Lock Tuning",
});
const tx = update.status && update.status.tx ? update.status.tx : null;
txMeters.style.display = lastHasTx ? "" : "none";
@@ -3930,8 +3945,15 @@ if (headerRigSwitchSelect) {
headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); });
}
function setControlPending(control, pending) {
if (!control) return;
control.disabled = pending;
control.classList.toggle("is-busy", pending);
control.setAttribute("aria-busy", String(pending));
}
powerBtn.addEventListener("click", async () => {
powerBtn.disabled = true;
setControlPending(powerBtn, true);
showHint("Sending...");
try {
await postPath("/toggle_power");
@@ -3940,12 +3962,12 @@ powerBtn.addEventListener("click", async () => {
showHint("Toggle failed", 2000);
console.error(err);
} finally {
powerBtn.disabled = false;
setControlPending(powerBtn, false);
}
});
pttBtn.addEventListener("click", async () => {
pttBtn.disabled = true;
setControlPending(pttBtn, true);
showHint("Toggling PTT…");
try {
const desired = lastTxEn ? "false" : "true";
@@ -3955,7 +3977,7 @@ pttBtn.addEventListener("click", async () => {
showHint("PTT toggle failed", 2000);
console.error(err);
} finally {
pttBtn.disabled = false;
setControlPending(pttBtn, false);
}
});
@@ -3988,7 +4010,7 @@ async function applyCenterFreqFromInput() {
return;
}
centerFreqDirty = false;
centerFreqEl.disabled = true;
setControlPending(centerFreqEl, true);
showHint("Setting central frequency…");
try {
await postPath(`/set_center_freq?hz=${parsed}`);
@@ -3997,7 +4019,7 @@ async function applyCenterFreqFromInput() {
showHint("Set central freq failed", 2000);
console.error(err);
} finally {
centerFreqEl.disabled = false;
setControlPending(centerFreqEl, false);
}
}
@@ -4198,7 +4220,7 @@ async function applyModeFromPicker() {
return;
}
updateWfmControls();
modeEl.disabled = true;
setControlPending(modeEl, true);
showHint("Setting mode…");
try {
if (typeof vchanInterceptMode === "function" && await vchanInterceptMode(mode)) {
@@ -4216,7 +4238,7 @@ async function applyModeFromPicker() {
showHint("Set mode failed", 2000);
console.error(err);
} finally {
modeEl.disabled = false;
setControlPending(modeEl, false);
}
}
@@ -4235,7 +4257,7 @@ txLimitBtn.addEventListener("click", async () => {
showHint("Limit missing", 1500);
return;
}
txLimitBtn.disabled = true;
setControlPending(txLimitBtn, true);
showHint("Setting TX limit…");
try {
await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`);
@@ -4244,22 +4266,22 @@ txLimitBtn.addEventListener("click", async () => {
showHint("TX limit failed", 2000);
console.error(err);
} finally {
txLimitBtn.disabled = false;
setControlPending(txLimitBtn, false);
}
});
lockBtn.addEventListener("click", async () => {
lockBtn.disabled = true;
setControlPending(lockBtn, true);
showHint("Toggling lock…");
try {
const nextLock = lockBtn.textContent === "Lock";
const nextLock = !lastLocked;
await postPath(nextLock ? "/lock" : "/unlock");
showHint("Lock toggled", 1500);
} catch (err) {
showHint("Lock toggle failed", 2000);
console.error(err);
} finally {
lockBtn.disabled = false;
setControlPending(lockBtn, false);
}
});
@@ -4326,7 +4348,8 @@ async function applyBwDefaultForMode(mode, sendToServer) {
scheduleSpectrumDraw();
}
if (sendToServer) {
try { await postPath(`/set_bandwidth?hz=${def}`); } catch (_) {}
try { await postPath(`/set_bandwidth?hz=${def}`); }
catch (error) { window.trxUi?.notify("Default bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: () => applyBwDefaultForMode(mode, true) } }); }
}
}
@@ -4353,7 +4376,9 @@ async function applyBandwidthFromInput() {
if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz);
}
} catch (_) {}
} catch (error) {
window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } });
}
}
function estimateOccupiedBandwidth(data, centerHz, interference = {}) {
@@ -4471,13 +4496,24 @@ async function applyAutoBandwidth() {
if (lastSpectrumData) {
scheduleSpectrumDraw();
}
const mode = (modeEl?.value || "").toUpperCase();
let reason = "measured occupied spectrum";
if (mode === "WFM") {
if (estimated === 60_000 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`;
else if (estimated === 60_000) reason = "weak-signal noise rejection";
else if (lastWfmAci >= lastWfmCci && lastWfmAci >= 10) reason = `${Math.round(lastWfmAci)}% ACI cap`;
else if (lastWfmCci >= 10) reason = `${Math.round(lastWfmCci)}% CCI confidence cap`;
}
window.trxUi?.notify(`Auto BW: ${formatBwLabel(estimated)}${reason}`, { kind: "success", duration: 5000 });
try {
if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return;
await postPath(`/set_bandwidth?hz=${estimated}`);
if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz);
}
} catch (_) {}
} catch (error) {
window.trxUi?.notify("Automatic bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: applyAutoBandwidth } });
}
}
if (spectrumBwInput) {
@@ -4576,6 +4612,7 @@ function navigateToTab(name, options = {}) {
_activeTab = name;
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`);
panel.style.display = "";
@@ -4604,6 +4641,7 @@ function navigateToTab(name, options = {}) {
refreshRecorderStatus();
}
}
window.navigateToTab = navigateToTab;
document.querySelector(".tab-bar").addEventListener("click", (e) => {
const btn = e.target.closest(".tab[data-tab]");
@@ -4762,7 +4800,7 @@ if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => {
if (authRole) {
// Logged in - show logout confirmation
if (confirm("Are you sure you want to logout?")) {
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
await authLogout();
}
} else {
@@ -4963,11 +5001,15 @@ function latLonToMaidenhead(lat, lon) {
function _wireSubTabBar(bar) {
if (bar._subtabWired) return;
bar._subtabWired = true;
window.trxUi?.prepareTabList(bar, "secondary");
bar.addEventListener("click", (e) => {
const btn = e.target.closest(".sub-tab[data-subtab]");
if (!btn) return;
bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
window.trxUi?.syncSelectedTab(bar, btn);
const decoderPicker = document.getElementById("decoder-tab-select");
if (decoderPicker && btn.closest("#tab-digital-modes")) decoderPicker.value = btn.dataset.subtab;
const parent = bar.parentElement;
parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`);
@@ -5458,6 +5500,7 @@ function configureRxStream(nextInfo) {
ensureRxAudioContext(nextSampleRate);
rxGainNode.gain.value = rxVolSlider.value / 100;
rxActive = true;
window.trxUi?.setButtonState(rxAudioBtn, { active: true, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
setAudioLevel(0);
rxAudioBtn.style.borderColor = "#00d17f";
rxAudioBtn.style.color = "#00d17f";
@@ -5659,6 +5702,7 @@ function startRxAudio() {
// If TX was active when WS closed, release PTT
if (txActive) { stopTxAudio(); }
rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null;
updateWfmControls();
rxAudioBtn.style.borderColor = "";
@@ -5685,6 +5729,7 @@ function startRxAudio() {
function stopRxAudio() {
rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null;
if (audioWs) { audioWs.close(); audioWs = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
@@ -5723,6 +5768,7 @@ function startTxAudio() {
}).then(async (stream) => {
txStream = stream;
txActive = true;
window.trxUi?.setButtonState(txAudioBtn, { active: true, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
txAudioBtn.style.borderColor = "#e55353";
txAudioBtn.style.color = "#e55353";
audioStatus.textContent = "RX+TX";
@@ -5800,6 +5846,7 @@ function startTxAudio() {
async function stopTxAudio() {
if (!txActive) return;
txActive = false;
window.trxUi?.setButtonState(txAudioBtn, { active: false, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
clearTxTimeout();
// Release PTT automatically
@@ -6045,7 +6092,7 @@ function renderRecorderFiles() {
el.querySelectorAll(".rec-delete-btn").forEach(function (btn) {
btn.addEventListener("click", async function () {
const name = btn.dataset.name;
if (!confirm("Delete recording " + name + "?")) return;
if (!await window.trxUi.confirm({ title: "Delete recording?", message: `${name} will be permanently removed.`, confirmLabel: "Delete" })) return;
try {
const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" });
if (!resp.ok) throw new Error("HTTP " + resp.status);
@@ -6053,6 +6100,7 @@ function renderRecorderFiles() {
renderRecorderFiles();
} catch (e) {
console.error("Delete failed", e);
window.trxUi?.notify("Recording could not be deleted", { kind: "error" });
}
});
});
@@ -6989,6 +7037,13 @@ function startSpectrumStreaming() {
const rds = lastSpectrumData?.rds;
lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds };
window.lastSpectrumData = lastSpectrumData;
const spectrumSummary = document.getElementById("spectrum-text-summary");
if (spectrumSummary && bins.length) {
let peakIndex = 0;
for (let i = 1; i < bins.length; i += 1) if (bins[i] > bins[peakIndex]) peakIndex = i;
const peakHz = centerHz - sampleRate / 2 + (peakIndex / Math.max(1, bins.length - 1)) * sampleRate;
spectrumSummary.textContent = `Spectrum centered at ${formatFreqForHumans(centerHz)}, spanning ${formatFreqForHumans(sampleRate)}. Strongest visible bin near ${formatFreqForHumans(peakHz)} at ${bins[peakIndex]} dB.`;
}
// Server confirmed a new center — clear optimistic pending value.
if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1000) {
spectrumCenterPendingHz = null;
@@ -8451,7 +8506,12 @@ if (spectrumCanvas || overviewCanvas) {
await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz);
}
}
} catch (_) {}
} catch (error) {
window.trxUi?.notify("Bandwidth could not be changed", {
kind: "error",
action: { label: "Retry", run: () => postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`) },
});
}
_bwDragEdge = null;
_bwDragCanvas = null;
return;