[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 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-08 10:32:23 +01:00
parent a3b1702710
commit be497b78cb
6 changed files with 106 additions and 17 deletions
@@ -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) {
@@ -12,9 +12,9 @@
<div class="header" style="position:relative; z-index:2;">
<div>
<div class="title"><span id="rig-title">Rig status</span></div>
<div class="subtitle" id="server-subtitle"></div>
<div class="subtitle">{pkg} v{ver}</div>
</div>
<div id="callsign" style="color:#9aa4b5; font-weight:600; display:none;">{callsign_opt}</div>
</div>
<div id="loading" style="text-align:center; padding:2rem 0;">
<div id="loading-title" style="margin-bottom:0.4rem; font-size:1.1rem; font-weight:600;">Initializing (rig)…</div>
@@ -84,12 +84,11 @@
</div>
</div>
<div id="tx-limit-row" style="display:none;">
<div class="label">TX Limit</div>
<div class="label">TX Limit — units depend on rig (percentage/watts)</div>
<div class="inline">
<input class="status-input" id="tx-limit" type="number" min="0" max="255" step="1" value="" placeholder="--" />
<button id="tx-limit-btn" type="button">Set</button>
</div>
<small>Units depend on rig (percent/watts).</small>
</div>
</div>
<div class="full-row" id="audio-row">
@@ -97,6 +96,8 @@
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
<button id="rx-audio-btn" type="button">RX Audio</button>
<button id="tx-audio-btn" type="button">TX Audio</button>
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
<div id="audio-level" style="flex: 1 1 auto; height: 12px; border-radius: 999px; background: #1f2937; border: 1px solid #2d3748; overflow: hidden; min-width: 80px;">
<div id="audio-level-fill" style="height: 100%; width: 0%; background: linear-gradient(90deg, #00d17f, #f0ad4e); transition: width 100ms ease;"></div>
</div>
@@ -104,6 +105,7 @@
</div>
</div>
<div class="footer">
<div class="copyright">Built by Stan Grams SP2SJG from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a><span id="copyright-year"></span></div>
<div class="hint" id="power-hint">Connecting…</div>
</div>
</div>
@@ -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);
@@ -247,10 +247,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
}
#[get("/")]
async fn index(callsign: web::Data<Option<String>>) -> 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<RigState>) -> Result<RigSnapshot,
band: None,
enabled: state.control.enabled,
initialized: state.initialized,
server_callsign: state.server_callsign,
server_version: state.server_version,
})
}
@@ -64,18 +64,16 @@ fn build_server(
addr: SocketAddr,
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
callsign: Option<String>,
_callsign: Option<String>,
) -> Result<Server, actix_web::Error> {
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)
})
@@ -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(""))
}