From a0bdaa2c4b547daef96e77cf236c534a54df355d Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 1 Aug 2026 02:24:47 +0200 Subject: [PATCH] feat(ui): improve radio operator experience --- .../trx-frontend-http/assets/web/app.js | 110 +++++-- .../trx-frontend-http/assets/web/index.html | 27 +- .../assets/web/plugins/ais.js | 2 +- .../assets/web/plugins/aprs.js | 2 +- .../assets/web/plugins/background-decode.js | 4 +- .../assets/web/plugins/bookmarks.js | 31 +- .../assets/web/plugins/cw.js | 2 +- .../assets/web/plugins/ft2.js | 2 +- .../assets/web/plugins/ft4.js | 2 +- .../assets/web/plugins/ft8.js | 2 +- .../assets/web/plugins/hf-aprs.js | 2 +- .../assets/web/plugins/sat-scheduler.js | 6 +- .../assets/web/plugins/sat.js | 2 +- .../assets/web/plugins/scheduler.js | 8 +- .../assets/web/plugins/vdes.js | 2 +- .../assets/web/plugins/wspr.js | 2 +- .../trx-frontend-http/assets/web/style.css | 84 +++++- .../trx-frontend-http/assets/web/ui-core.js | 276 ++++++++++++++++++ .../trx-frontend-http/src/api/assets.rs | 7 + .../trx-frontend-http/src/api/mod.rs | 1 + .../trx-frontend-http/src/status.rs | 19 ++ 21 files changed, 528 insertions(+), 65 deletions(-) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/ui-core.js diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index b6a0b257..173fa189 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -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; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index a358b025..c349d3fe 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -43,7 +43,7 @@ SPDX-License-Identifier: GPL-2.0-or-later -
+
@@ -204,12 +205,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
Signal strength
- -
Frequency
+ +
Frequency
@@ -235,7 +236,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Mode
- +
@@ -307,9 +308,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
Transmit / Power
- - - + + +
@@ -495,6 +496,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
@@ -530,7 +532,7 @@ SPDX-License-Identifier: GPL-2.0-or-later