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 7114fd45..30d841ad 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 @@ -23,6 +23,7 @@ window.onDecoderRegistryReady = function (fn) { for (const fn of _decoderRegistryReadyCallbacks) fn(); _decoderRegistryReadyCallbacks.length = 0; hideUnsupportedDecoderTabs(); + refreshOperatorLayoutCapabilities(); } } catch (e) { console.error("Failed to fetch decoder registry:", e); @@ -333,8 +334,14 @@ function applyCapabilities(caps) { const txAudioBtn = document.getElementById("tx-audio-btn"); const txVolSlider = document.getElementById("tx-vol"); const txVolControl = txVolSlider ? txVolSlider.closest(".vol-label") : null; - if (txPowerCol) txPowerCol.style.display = caps.tx ? "" : "none"; + if (txPowerCol) { + txPowerCol.style.display = ""; + const label = txPowerCol.querySelector(".label span"); + if (label) label.textContent = caps.tx ? "Transmit / Power" : "Power / Tuning"; + } if (pttBtn) pttBtn.style.display = caps.tx ? "" : "none"; + if (powerBtn) powerBtn.style.display = ""; + if (lockBtn) lockBtn.style.display = caps.lockable ? "" : "none"; if (txMetersRow) txMetersRow.style.display = caps.tx ? "" : "none"; if (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none"; if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none"; @@ -344,7 +351,7 @@ function applyCapabilities(caps) { // TX limit row const txLimitRow = document.getElementById("tx-limit-row"); - if (txLimitRow && !caps.tx_limit) txLimitRow.style.display = "none"; + if (txLimitRow) txLimitRow.style.display = caps.tx_limit ? "" : "none"; // VFO row const vfoRow = document.getElementById("vfo-row"); @@ -436,6 +443,7 @@ const signalSplitValueEl = document.getElementById("signal-split-value"); const overviewPeakHoldEl = document.getElementById("overview-peak-hold"); const themeToggleBtn = document.getElementById("theme-toggle"); const headerRigSwitchSelect = document.getElementById("header-rig-switch-select"); +const headerRigSummary = document.getElementById("header-rig-summary"); const headerStylePickSelect = document.getElementById("header-style-pick-select"); const rdsPsOverlay = document.getElementById("rds-ps-overlay"); const tabMainEl = document.getElementById("tab-main"); @@ -895,6 +903,7 @@ async function restorePreviousTuneState() { let lastRigIds = []; let lastRigDisplayNames = {}; let lastActiveRigId = null; +let rigSwitchInProgress = false; let lastCityLabel = ""; let sseSessionId = null; const originalTitle = document.title; @@ -1239,6 +1248,20 @@ function populateRigPicker(selectEl, rigIds, activeRigId, disabled) { selectEl.disabled = disabled; } +function updateRigIdentitySummary(rigId, pending = false) { + if (!headerRigSummary) return; + const rig = serverRigs.find((entry) => entry?.remote === rigId); + if (!rig) { + headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available"; + return; + } + const hardware = [rig.manufacturer, rig.model].map(value => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote; + const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : []; + const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)]; + if (modes.length > 5) features.push(`+${modes.length - 5} modes`); + headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`; +} + function updateRigSubtitle(activeRigId) { if (!rigSubtitle) return; const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--"; @@ -1275,6 +1298,8 @@ function applyRigList(activeRigId, rigIds, displayNames) { const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); updateRigSubtitle(lastActiveRigId); + updateRigIdentitySummary(lastActiveRigId); + window.trxUi?.setActiveRig(lastActiveRigId); if (rigListChanged) { if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); @@ -1305,10 +1330,7 @@ async function refreshRigList() { } }); serverRigs = rigs; - window.trxUi?.setBroadcastLayoutAvailable(rigs.some((rig) => - Array.isArray(rig?.supported_modes) - && rig.supported_modes.map(normalizeMode).includes("WFM") - )); + refreshOperatorLayoutCapabilities(); serverActiveRigId = data.active_remote || null; applyRigList(data.active_remote, rigIds, displayNames); window.trx.modules.map?.syncAprsReceiverMarker(); @@ -1317,6 +1339,19 @@ async function refreshRigList() { } } +function refreshOperatorLayoutCapabilities() { + const rigModes = serverRigs.map((rig) => + Array.isArray(rig?.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : [] + ); + const decoderModes = new Set(decoderRegistry.flatMap((decoder) => + Array.isArray(decoder?.active_modes) ? decoder.active_modes.map(normalizeMode).filter(Boolean) : [] + )); + window.trxUi?.setLayoutCapabilities({ + broadcast: rigModes.some((modes) => modes.includes("WFM")), + digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))), + }); +} + function showHint(msg, duration) { powerHint.textContent = msg; if (hintTimer) clearTimeout(hintTimer); @@ -3863,12 +3898,16 @@ function scheduleUiFrameJob(key, job) { window.trxScheduleUiFrameJob = scheduleUiFrameJob; -async function postPath(path) { +async function postPath(path, options = {}) { + if (rigSwitchInProgress && !options.allowDuringRigSwitch) { + throw new Error("Wait for the rig switch to finish"); + } + const targetRigId = options.remote === undefined ? lastActiveRigId : options.remote; // Auto-append remote so each tab targets its own rig. // Skip when the caller already included remote (e.g. /select_rig). - if (lastActiveRigId && !path.includes("remote=")) { + if (targetRigId && !path.includes("remote=")) { const sep = path.includes("?") ? "&" : "?"; - path = `${path}${sep}remote=${encodeURIComponent(lastActiveRigId)}`; + path = `${path}${sep}remote=${encodeURIComponent(targetRigId)}`; } const resp = await fetch(path, { method: "POST" }); if (authEnabled && resp.status === 401) { @@ -3913,36 +3952,45 @@ async function switchRigFromSelect(selectEl) { return; } const prevRig = lastActiveRigId; - lastActiveRigId = selectEl.value; - if (prevRig && prevRig !== lastActiveRigId) { - resetDecoderStateOnRigSwitch(); - } - updateRigSubtitle(lastActiveRigId); - if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); - if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); - if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || ""); - window.trx.modules.map?.syncAprsReceiverMarker(); - // Switch this session's rig and reconnect SSE to the new rig's - // state channel. + const nextRig = selectEl.value; + if (nextRig === prevRig || rigSwitchInProgress) return; + rigSwitchInProgress = true; + setControlPending(selectEl, true); + selectEl.closest(".header-rig-switch")?.classList.add("is-switching"); + updateRigIdentitySummary(nextRig, true); + showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}…`); try { const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : ""; - await postPath(`/select_rig?remote=${encodeURIComponent(selectEl.value)}${sidParam}`); + await postPath(`/select_rig?remote=${encodeURIComponent(nextRig)}${sidParam}`, { allowDuringRigSwitch: true, remote: null }); + lastActiveRigId = nextRig; + resetDecoderStateOnRigSwitch(); + updateRigSubtitle(lastActiveRigId); + updateRigIdentitySummary(lastActiveRigId); + window.trxUi?.setActiveRig(lastActiveRigId); + if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); + if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); + if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || ""); + window.trx.modules.map?.syncAprsReceiverMarker(); connect(); + stopSpectrumStreaming(); + startSpectrumStreaming(); + stopMeterStreaming(); + startMeterStreaming(); + if (rxActive) { + stopRxAudio(); + startRxAudio(); + } + showHint(`Rig: ${lastRigDisplayNames[lastActiveRigId] || lastActiveRigId}`, 1500); } catch (err) { console.error("select_rig failed:", err); + selectEl.value = prevRig || ""; + updateRigIdentitySummary(prevRig); + window.trxUi?.notify("Rig could not be switched", { kind: "error" }); + } finally { + rigSwitchInProgress = false; + setControlPending(selectEl, false); + selectEl.closest(".header-rig-switch")?.classList.remove("is-switching"); } - // Reconnect spectrum SSE to the new rig's spectrum channel. - stopSpectrumStreaming(); - startSpectrumStreaming(); - // Reconnect meter SSE to the new rig's meter channel. - stopMeterStreaming(); - startMeterStreaming(); - // Reconnect audio to the new rig if audio is active. - if (rxActive) { - stopRxAudio(); - startRxAudio(); - } - showHint(`Rig: ${lastActiveRigId}`, 1500); } if (headerRigSwitchSelect) { @@ -4606,6 +4654,7 @@ function _initMapWhenReady() { } function navigateToTab(name, options = {}) { + window.trxUi?.closeMobileOverlays?.(); const { updateHistory = true, replaceHistory = false } = options; if (authEnabled && !authRole && name !== "main") { showAuthGate(false); 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 c349d3fe..05f18654 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 @@ -85,6 +85,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
'; + label.innerHTML = 'Operator layout'; const select = label.querySelector("select"); - const savedLayout = localStorage.getItem("trxOperatorLayout") || "compact"; + const savedLayout = savedLayoutName(); actions.insertBefore(label, actions.firstChild); select.value = savedLayout; refreshLayoutOptions(); @@ -196,7 +236,7 @@ if (element) body.appendChild(element); }); tray.appendChild(details); - api.applyLayout(localStorage.getItem("trxOperatorLayout") || "compact"); + api.applyLayout(savedLayoutName(), { persist: false }); } } @@ -214,6 +254,14 @@ menu.id = "mobile-more-menu"; menu.className = "mobile-more-menu"; menu.setAttribute("role", "menu"); + more.setAttribute("aria-controls", menu.id); + const closeMore = (restoreFocus = false) => { + if (!menu.classList.contains("is-open")) return; + menu.classList.remove("is-open"); + more.setAttribute("aria-expanded", "false"); + if (restoreFocus) more.focus(); + }; + api.closeMobileOverlays = closeMore; ["statistics", "recorder", "settings", "about"].forEach((tabName) => { const source = nav.querySelector(`[data-tab="${tabName}"]`); if (!source) return; @@ -224,15 +272,23 @@ item.textContent = source.textContent.trim(); item.addEventListener("click", () => { if (typeof window.navigateToTab === "function") window.navigateToTab(tabName); - menu.classList.remove("is-open"); - more.setAttribute("aria-expanded", "false"); + closeMore(); }); menu.appendChild(item); }); more.addEventListener("click", () => { const open = menu.classList.toggle("is-open"); more.setAttribute("aria-expanded", String(open)); + if (open) menu.querySelector('[role="menuitem"]')?.focus(); }); + document.addEventListener("click", (event) => { + if (!menu.contains(event.target) && !more.contains(event.target)) closeMore(); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") closeMore(true); + }); + window.addEventListener("resize", () => closeMore()); + window.addEventListener("popstate", () => closeMore()); nav.append(more, menu); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs index 7504926d..f2834458 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs @@ -389,6 +389,8 @@ struct RigListItem { manufacturer: String, model: String, supported_modes: Vec, + tx: bool, + filter_controls: bool, initialized: bool, #[serde(skip_serializing_if = "Option::is_none")] latitude: Option, @@ -424,6 +426,8 @@ fn map_rig_entry(entry: &RemoteRigEntry) -> RigListItem { manufacturer: entry.state.info.manufacturer.clone(), model: entry.state.info.model.clone(), supported_modes: entry.state.info.capabilities.supported_modes.clone(), + tx: entry.state.info.capabilities.tx, + filter_controls: entry.state.info.capabilities.filter_controls, initialized: entry.state.initialized, latitude: entry.state.server_latitude, longitude: entry.state.server_longitude,