[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:
@@ -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(""))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user