fix(ui): make rig switching atomic and visible

This commit is contained in:
sjg
2026-08-01 11:15:39 +02:00
parent d07dbdc645
commit af74430551
4 changed files with 89 additions and 27 deletions
@@ -443,6 +443,7 @@ const signalSplitValueEl = document.getElementById("signal-split-value");
const overviewPeakHoldEl = document.getElementById("overview-peak-hold"); const overviewPeakHoldEl = document.getElementById("overview-peak-hold");
const themeToggleBtn = document.getElementById("theme-toggle"); const themeToggleBtn = document.getElementById("theme-toggle");
const headerRigSwitchSelect = document.getElementById("header-rig-switch-select"); const headerRigSwitchSelect = document.getElementById("header-rig-switch-select");
const headerRigSummary = document.getElementById("header-rig-summary");
const headerStylePickSelect = document.getElementById("header-style-pick-select"); const headerStylePickSelect = document.getElementById("header-style-pick-select");
const rdsPsOverlay = document.getElementById("rds-ps-overlay"); const rdsPsOverlay = document.getElementById("rds-ps-overlay");
const tabMainEl = document.getElementById("tab-main"); const tabMainEl = document.getElementById("tab-main");
@@ -902,6 +903,7 @@ async function restorePreviousTuneState() {
let lastRigIds = []; let lastRigIds = [];
let lastRigDisplayNames = {}; let lastRigDisplayNames = {};
let lastActiveRigId = null; let lastActiveRigId = null;
let rigSwitchInProgress = false;
let lastCityLabel = ""; let lastCityLabel = "";
let sseSessionId = null; let sseSessionId = null;
const originalTitle = document.title; const originalTitle = document.title;
@@ -1246,6 +1248,20 @@ function populateRigPicker(selectEl, rigIds, activeRigId, disabled) {
selectEl.disabled = 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) { function updateRigSubtitle(activeRigId) {
if (!rigSubtitle) return; if (!rigSubtitle) return;
const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--"; const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--";
@@ -1282,6 +1298,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
if (rigListChanged) { if (rigListChanged) {
if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId);
@@ -3881,12 +3898,16 @@ function scheduleUiFrameJob(key, job) {
window.trxScheduleUiFrameJob = scheduleUiFrameJob; 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. // Auto-append remote so each tab targets its own rig.
// Skip when the caller already included remote (e.g. /select_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("?") ? "&" : "?"; const sep = path.includes("?") ? "&" : "?";
path = `${path}${sep}remote=${encodeURIComponent(lastActiveRigId)}`; path = `${path}${sep}remote=${encodeURIComponent(targetRigId)}`;
} }
const resp = await fetch(path, { method: "POST" }); const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) { if (authEnabled && resp.status === 401) {
@@ -3931,36 +3952,45 @@ async function switchRigFromSelect(selectEl) {
return; return;
} }
const prevRig = lastActiveRigId; const prevRig = lastActiveRigId;
lastActiveRigId = selectEl.value; const nextRig = selectEl.value;
if (prevRig && prevRig !== lastActiveRigId) { if (nextRig === prevRig || rigSwitchInProgress) return;
resetDecoderStateOnRigSwitch(); rigSwitchInProgress = true;
} setControlPending(selectEl, true);
updateRigSubtitle(lastActiveRigId); selectEl.closest(".header-rig-switch")?.classList.add("is-switching");
if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); updateRigIdentitySummary(nextRig, true);
if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
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.
try { try {
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : ""; 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(); connect();
stopSpectrumStreaming();
startSpectrumStreaming();
stopMeterStreaming();
startMeterStreaming();
if (rxActive) {
stopRxAudio();
startRxAudio();
}
showHint(`Rig: ${lastRigDisplayNames[lastActiveRigId] || lastActiveRigId}`, 1500);
} catch (err) { } catch (err) {
console.error("select_rig failed:", 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) { if (headerRigSwitchSelect) {
@@ -85,6 +85,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="header-rec-btn" class="header-bar-btn header-rec-btn" type="button" aria-label="Toggle recording" title="Toggle recording">REC</button> <button id="header-rec-btn" class="header-bar-btn header-rec-btn" type="button" aria-label="Toggle recording" title="Toggle recording">REC</button>
<div class="header-rig-switch"> <div class="header-rig-switch">
<select id="header-rig-switch-select" aria-label="Select active rig"></select> <select id="header-rig-switch-select" aria-label="Select active rig"></select>
<span id="header-rig-summary" class="header-rig-summary" aria-live="polite"></span>
</div> </div>
<div class="header-style-pick"> <div class="header-style-pick">
<select id="header-style-pick-select" aria-label="Select UI style"> <select id="header-style-pick-select" aria-label="Select UI style">
@@ -1407,6 +1407,33 @@ small { color: var(--text-muted); }
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
position: relative;
}
.header-rig-summary {
position: absolute;
top: calc(100% + 0.3rem);
left: 0;
z-index: 20;
width: max-content;
max-width: min(28rem, calc(100vw - 2rem));
padding: 0.25rem 0.45rem;
border: 1px solid color-mix(in srgb, var(--border-light) 75%, transparent);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--card-bg) 96%, transparent);
color: var(--text-muted);
box-shadow: 0 7px 20px color-mix(in srgb, #000 24%, transparent);
font-size: var(--fs-xs);
line-height: 1.35;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity var(--dur-fast) var(--ease-out), visibility var(--dur-fast);
}
.header-rig-switch:hover .header-rig-summary,
.header-rig-switch:focus-within .header-rig-summary,
.header-rig-switch.is-switching .header-rig-summary {
opacity: 1;
visibility: visible;
} }
.header-rig-switch select { .header-rig-switch select {
min-width: 8rem; min-width: 8rem;
@@ -389,6 +389,8 @@ struct RigListItem {
manufacturer: String, manufacturer: String,
model: String, model: String,
supported_modes: Vec<trx_core::RigMode>, supported_modes: Vec<trx_core::RigMode>,
tx: bool,
filter_controls: bool,
initialized: bool, initialized: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
latitude: Option<f64>, latitude: Option<f64>,
@@ -424,6 +426,8 @@ fn map_rig_entry(entry: &RemoteRigEntry) -> RigListItem {
manufacturer: entry.state.info.manufacturer.clone(), manufacturer: entry.state.info.manufacturer.clone(),
model: entry.state.info.model.clone(), model: entry.state.info.model.clone(),
supported_modes: entry.state.info.capabilities.supported_modes.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, initialized: entry.state.initialized,
latitude: entry.state.server_latitude, latitude: entry.state.server_latitude,
longitude: entry.state.server_longitude, longitude: entry.state.server_longitude,