From be497b78cbfdab4b96ba1c1b56e5c1d6959213de Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sun, 8 Feb 2026 10:32:23 +0100 Subject: [PATCH] [feat](trx-frontend-http): volume controls, server callsign, UI improvements - Add RX/TX volume sliders with GainNode audio chain integration, scroll wheel support, and percentage display - Display server callsign and version from SSE event data - Merge TX Limit hint into its label line - Add copyright footer with link to haxx.space - Uniform section spacing via flex gap on #content Co-Authored-By: Claude Opus 4.6 Signed-off-by: Stanislaw Grams --- .../trx-frontend-http/assets/web/app.js | 52 ++++++++++++++++--- .../trx-frontend-http/assets/web/index.html | 8 +-- .../trx-frontend-http/assets/web/style.css | 50 +++++++++++++++++- .../trx-frontend/trx-frontend-http/src/api.rs | 6 ++- .../trx-frontend-http/src/server.rs | 4 +- .../trx-frontend-http/src/status.rs | 3 +- 6 files changed, 106 insertions(+), 17 deletions(-) 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 798856d..13dcba7 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 @@ -21,7 +21,7 @@ const swrBar = document.getElementById("swr-bar"); const swrValue = document.getElementById("swr-value"); const loadingEl = document.getElementById("loading"); const contentEl = document.getElementById("content"); -const callsignEl = document.getElementById("callsign"); +const serverSubtitle = document.getElementById("server-subtitle"); const loadingTitle = document.getElementById("loading-title"); const loadingSub = document.getElementById("loading-sub"); @@ -153,9 +153,12 @@ function render(update) { loadingEl.style.display = "none"; if (contentEl) contentEl.style.display = ""; } - // Reveal callsign if provided and non-empty. - if (callsignEl && callsignEl.textContent.trim() !== "") { - callsignEl.style.display = ""; + // Server subtitle: "trx-server vX.Y.Z hosted by CALL" + if (update.server_version || update.server_callsign) { + let text = "trx-server"; + if (update.server_version) text += ` v${update.server_version}`; + if (update.server_callsign) text += ` hosted by ${update.server_callsign}`; + serverSubtitle.textContent = text; } setDisabled(false); if (update.info && update.info.capabilities && Array.isArray(update.info.capabilities.supported_modes)) { @@ -622,6 +625,10 @@ let opusDecoder = null; let txEncoder = null; let nextPlayTime = 0; let lastLevelUpdate = 0; +let rxGainNode = null; +let txGainNode = null; +const rxVolSlider = document.getElementById("rx-vol"); +const txVolSlider = document.getElementById("tx-vol"); const TX_TIMEOUT_SECS = 120; let txTimeoutTimer = null; let txTimeoutRemaining = 0; @@ -682,6 +689,9 @@ function startRxAudio() { try { streamInfo = JSON.parse(evt.data); audioCtx = new AudioContext({ sampleRate: streamInfo.sample_rate || 48000 }); + rxGainNode = audioCtx.createGain(); + rxGainNode.gain.value = rxVolSlider.value / 100; + rxGainNode.connect(audioCtx.destination); rxActive = true; rxAudioBtn.style.borderColor = "#00d17f"; rxAudioBtn.style.color = "#00d17f"; @@ -723,7 +733,7 @@ function startRxAudio() { } const src = audioCtx.createBufferSource(); src.buffer = ab; - src.connect(audioCtx.destination); + src.connect(rxGainNode); const now = audioCtx.currentTime; const schedTime = Math.max(now, (nextPlayTime || now)); src.start(schedTime); @@ -763,6 +773,7 @@ function startRxAudio() { rxAudioBtn.style.color = ""; audioStatus.textContent = "Off"; audioLevelFill.style.width = "0%"; + rxGainNode = null; if (opusDecoder) { try { opusDecoder.close(); } catch(e) {} opusDecoder = null; @@ -779,6 +790,7 @@ function stopRxAudio() { rxActive = false; if (audioWs) { audioWs.close(); audioWs = null; } if (audioCtx) { audioCtx.close(); audioCtx = null; } + rxGainNode = null; if (opusDecoder) { try { opusDecoder.close(); } catch(e) {} opusDecoder = null; @@ -869,7 +881,10 @@ function startTxAudio() { // Ignore } }; - source.connect(processor); + txGainNode = audioCtx.createGain(); + txGainNode.gain.value = txVolSlider.value / 100; + source.connect(txGainNode); + txGainNode.connect(processor); processor.connect(audioCtx.destination); txProcessor = { source, processor }; }).catch((err) => { @@ -899,6 +914,7 @@ async function stopTxAudio() { try { txEncoder.close(); } catch(e) {} txEncoder = null; } + txGainNode = null; txAudioBtn.style.borderColor = ""; txAudioBtn.style.color = ""; audioStatus.textContent = rxActive ? "RX" : "Off"; @@ -907,6 +923,30 @@ async function stopTxAudio() { rxAudioBtn.addEventListener("click", startRxAudio); txAudioBtn.addEventListener("click", startTxAudio); +const rxVolPct = document.getElementById("rx-vol-pct"); +const txVolPct = document.getElementById("tx-vol-pct"); + +function updateVolSlider(slider, pctEl, gainNode) { + pctEl.textContent = `${slider.value}%`; + if (gainNode) gainNode.gain.value = slider.value / 100; +} + +rxVolSlider.addEventListener("input", () => updateVolSlider(rxVolSlider, rxVolPct, rxGainNode)); +txVolSlider.addEventListener("input", () => updateVolSlider(txVolSlider, txVolPct, txGainNode)); + +function volWheel(slider, pctEl, getGain) { + slider.addEventListener("wheel", (e) => { + e.preventDefault(); + const step = e.deltaY < 0 ? 2 : -2; + slider.value = Math.max(0, Math.min(100, Number(slider.value) + step)); + updateVolSlider(slider, pctEl, getGain()); + }, { passive: false }); +} +volWheel(rxVolSlider, rxVolPct, () => rxGainNode); +volWheel(txVolSlider, txVolPct, () => txGainNode); + +document.getElementById("copyright-year").textContent = new Date().getFullYear(); + // Release PTT on page unload to prevent stuck transmit window.addEventListener("beforeunload", () => { if (txActive) { 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 e0d6db1..d1db466 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 @@ -12,9 +12,9 @@
Rig status
+
{pkg} v{ver}
-
Initializing (rig)…
@@ -84,12 +84,11 @@
@@ -97,6 +96,8 @@
+ +
@@ -104,6 +105,7 @@
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 808af61..d5d57b9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -148,8 +148,56 @@ small { color: var(--text-muted); } .meter-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: var(--btn-bg); border: 1px solid var(--border-light); overflow: hidden; } .meter-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent-green), var(--accent-yellow), var(--accent-red)); transition: width 150ms ease; } .meter-value { font-size: 0.95rem; color: var(--text-heading); min-width: 64px; text-align: right; } -.footer { margin-top: 0.6rem; display: flex; justify-content: flex-end; } +#content { display: flex; flex-direction: column; gap: 1.1rem; } +.footer { display: flex; justify-content: space-between; align-items: baseline; } .full-row { grid-column: 1 / -1; } +.copyright { color: var(--text-muted); font-size: 0.75rem; opacity: 0.7; } +.copyright a { color: var(--accent-green); text-decoration: none; } +.copyright a:hover { text-decoration: underline; } + +.vol-label { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + color: var(--text-muted); + font-size: 0.82rem; + white-space: nowrap; +} +.vol-pct { + font-size: 0.72rem; + color: var(--text-muted); + line-height: 1; +} +.vol-slider { + -webkit-appearance: none; + appearance: none; + width: 80px; + height: 6px; + border-radius: 3px; + background: var(--btn-bg); + border: 1px solid var(--border-light); + outline: none; + cursor: pointer; +} +.vol-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent-green); + border: none; + cursor: pointer; +} +.vol-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent-green); + border: none; + cursor: pointer; +} button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-green); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index e55da40..737b65a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -247,10 +247,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { } #[get("/")] -async fn index(callsign: web::Data>) -> impl Responder { +async fn index() -> impl Responder { HttpResponse::Ok() .insert_header((header::CONTENT_TYPE, "text/html; charset=utf-8")) - .body(status::index_html(callsign.get_ref().as_deref())) + .body(status::index_html()) } #[get("/favicon.ico")] @@ -339,6 +339,8 @@ async fn wait_for_view(mut rx: watch::Receiver) -> Result, rig_tx: mpsc::Sender, - callsign: Option, + _callsign: Option, ) -> Result { let state_data = web::Data::new(state_rx); let rig_tx = web::Data::new(rig_tx); - let callsign = web::Data::new(callsign); let clients = web::Data::new(Arc::new(AtomicUsize::new(0))); let server = HttpServer::new(move || { App::new() .app_data(state_data.clone()) .app_data(rig_tx.clone()) - .app_data(callsign.clone()) .app_data(clients.clone()) .configure(api::configure) }) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs index 1bb757a..83ca8f5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs @@ -9,9 +9,8 @@ const INDEX_HTML: &str = include_str!("../assets/web/index.html"); pub const STYLE_CSS: &str = include_str!("../assets/web/style.css"); pub const APP_JS: &str = include_str!("../assets/web/app.js"); -pub fn index_html(callsign: Option<&str>) -> String { +pub fn index_html() -> String { INDEX_HTML .replace("{pkg}", PKG_NAME) .replace("{ver}", PKG_VERSION) - .replace("{callsign_opt}", callsign.unwrap_or("")) }