Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53736ef750 | ||
|
|
6566eae2c7 | ||
|
|
f3eeddff7c | ||
|
|
1540ca5b4e | ||
|
|
e449704fd2 | ||
|
|
af74430551 | ||
|
|
d07dbdc645 | ||
|
|
d07a627508 | ||
|
|
9fbf90ee91 | ||
|
|
d393b3cefc | ||
|
|
35a492ec95 | ||
|
|
b20da5c541 | ||
|
|
a0bdaa2c4b | ||
|
|
6676b66993 | ||
|
|
412651d612 | ||
|
|
d347b493d9 | ||
|
|
39e59dca96 | ||
|
|
5654520901 | ||
|
|
ff75fcc692 | ||
|
|
4728b578ae | ||
|
|
7db7fad9b0 | ||
|
|
830f7299fe |
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) <year> <copyright holders>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -142,6 +142,6 @@ a unified set of frontends.
|
||||
## License
|
||||
|
||||
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
|
||||
bundled third-party license files. Bundled third-party components (Leaflet and
|
||||
the Leaflet AIS tracksymbol plugin under `assets/web/vendor/`) retain their
|
||||
original BSD-2-Clause license.
|
||||
bundled third-party license files. Bundled third-party components retain their
|
||||
original licenses: Leaflet is BSD-2-Clause, DSEG is OFL-1.1, and opus-decoder
|
||||
is MIT.
|
||||
|
||||
@@ -42,3 +42,11 @@ SPDX-License-Identifier = "BSD-2-Clause"
|
||||
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/dseg14-classic-latin-400-normal.woff2"]
|
||||
SPDX-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
|
||||
SPDX-License-Identifier = "OFL-1.1"
|
||||
|
||||
# Vendored opus-decoder 0.7.11 browser build
|
||||
# (https://github.com/eshaz/wasm-audio-decoders), MIT.
|
||||
# SHA-256: fd73ee0a9c8a5e233c0b88234df14f46003ad89bb4ba27435bc8714db2a6dc62
|
||||
[[annotations]]
|
||||
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/opus-decoder-0.7.11.min.js"]
|
||||
SPDX-FileCopyrightText = "2021-2025 Ethan Halsall"
|
||||
SPDX-License-Identifier = "MIT"
|
||||
|
||||
@@ -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,19 @@ 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";
|
||||
const hasPowerControl = !caps.filter_controls;
|
||||
if (txPowerCol) {
|
||||
txPowerCol.style.display = (caps.tx || hasPowerControl || caps.lockable) ? "" : "none";
|
||||
const label = txPowerCol.querySelector(".label span");
|
||||
if (label) {
|
||||
label.textContent = caps.tx && hasPowerControl ? "Transmit / Power"
|
||||
: caps.tx ? "Transmit / Tuning"
|
||||
: hasPowerControl ? "Power / Tuning" : "Tuning";
|
||||
}
|
||||
}
|
||||
if (pttBtn) pttBtn.style.display = caps.tx ? "" : "none";
|
||||
if (powerBtn) powerBtn.style.display = hasPowerControl ? "" : "none";
|
||||
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 +356,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 +448,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");
|
||||
@@ -645,7 +658,7 @@ function flushDeferredDecodeMapSync() {
|
||||
if (!decodeMapSyncPending || decodeHistoryReplayActive || !window.trx?.map?.aprsMap) return;
|
||||
decodeMapSyncPending = false;
|
||||
scheduleUiFrameJob("decode-map-maintenance", () => {
|
||||
window.trx.map?.pruneMapHistory();
|
||||
window.trx.modules.map?.pruneMapHistory();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -840,6 +853,8 @@ let jogMult = loadSetting("jogMult", 1); // divisor: 1, 10, 100
|
||||
let jogStep = Math.max(Math.round(jogUnit / jogMult), 1);
|
||||
let minFreqStepHz = 1;
|
||||
let lastModeName = "";
|
||||
let lastWfmCci = 0;
|
||||
let lastWfmAci = 0;
|
||||
const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"];
|
||||
function vfoColor(idx) {
|
||||
if (idx < VFO_COLORS.length) return VFO_COLORS[idx];
|
||||
@@ -893,6 +908,7 @@ async function restorePreviousTuneState() {
|
||||
let lastRigIds = [];
|
||||
let lastRigDisplayNames = {};
|
||||
let lastActiveRigId = null;
|
||||
let rigSwitchInProgress = false;
|
||||
let lastCityLabel = "";
|
||||
let sseSessionId = null;
|
||||
const originalTitle = document.title;
|
||||
@@ -1237,6 +1253,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 || "--";
|
||||
@@ -1273,13 +1303,15 @@ 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);
|
||||
if (typeof bmPopulateScopePicker === "function") bmPopulateScopePicker();
|
||||
if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || "");
|
||||
}
|
||||
window.trx.map?.updateMapRigFilter();
|
||||
window.trx.modules.map?.updateMapRigFilter();
|
||||
}
|
||||
|
||||
|
||||
@@ -1303,18 +1335,35 @@ async function refreshRigList() {
|
||||
}
|
||||
});
|
||||
serverRigs = rigs;
|
||||
refreshOperatorLayoutCapabilities();
|
||||
serverActiveRigId = data.active_remote || null;
|
||||
applyRigList(data.active_remote, rigIds, displayNames);
|
||||
window.trx.map?.syncAprsReceiverMarker();
|
||||
window.trx.modules.map?.syncAprsReceiverMarker();
|
||||
} catch (e) {
|
||||
// Non-fatal: SSE/status path still drives main UI.
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration);
|
||||
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
|
||||
window.trxUi?.notify(msg, { kind: "error" });
|
||||
}
|
||||
}
|
||||
let supportedModes = [];
|
||||
let supportedBands = [];
|
||||
@@ -2829,7 +2878,7 @@ function showUnsupportedFreqPopup(hz) {
|
||||
const now = Date.now();
|
||||
if (now - lastUnsupportedFreqPopupAt < 1200) return;
|
||||
lastUnsupportedFreqPopupAt = now;
|
||||
window.alert(message);
|
||||
window.trxUi?.notify(message.replaceAll("\n", " "), { kind: "error", duration: 7000 });
|
||||
}
|
||||
|
||||
// Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit).
|
||||
@@ -3143,9 +3192,9 @@ function render(update) {
|
||||
const grid = latLonToMaidenhead(serverLat, serverLon);
|
||||
locationSubtitle.textContent = `Location: ${grid}`;
|
||||
locationSubtitle.style.display = "";
|
||||
window.trx.map?.reverseGeocodeLocation(serverLat, serverLon, grid);
|
||||
window.trx.modules.map?.reverseGeocodeLocation(serverLat, serverLon, grid);
|
||||
}
|
||||
window.trx.map?.syncAprsReceiverMarker();
|
||||
window.trx.modules.map?.syncAprsReceiverMarker();
|
||||
if (typeof update.initial_map_zoom === "number" && Number.isFinite(update.initial_map_zoom)) {
|
||||
initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom));
|
||||
}
|
||||
@@ -3293,8 +3342,14 @@ function render(update) {
|
||||
wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected);
|
||||
wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected);
|
||||
}
|
||||
if (typeof update.filter.wfm_cci === "number") updateIntfBar(wfmCciFillEl, wfmCciValEl, update.filter.wfm_cci);
|
||||
if (typeof update.filter.wfm_aci === "number") updateIntfBar(wfmAciFillEl, wfmAciValEl, update.filter.wfm_aci);
|
||||
if (typeof update.filter.wfm_cci === "number") {
|
||||
lastWfmCci = Math.max(0, Math.min(100, update.filter.wfm_cci));
|
||||
updateIntfBar(wfmCciFillEl, wfmCciValEl, lastWfmCci);
|
||||
}
|
||||
if (typeof update.filter.wfm_aci === "number") {
|
||||
lastWfmAci = Math.max(0, Math.min(100, update.filter.wfm_aci));
|
||||
updateIntfBar(wfmAciFillEl, wfmAciValEl, lastWfmAci);
|
||||
}
|
||||
if (samStereoWidthEl && typeof update.filter.sam_stereo_width === "number") {
|
||||
samStereoWidthEl.value = String(Math.round(update.filter.sam_stereo_width * 100));
|
||||
}
|
||||
@@ -3421,7 +3476,11 @@ function render(update) {
|
||||
if (update.status && typeof update.status.tx_en === "boolean" && update.status.tx_en !== prevRenderData.txEn) {
|
||||
prevRenderData.txEn = update.status.tx_en;
|
||||
lastTxEn = update.status.tx_en;
|
||||
pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off";
|
||||
window.trxUi?.setButtonState(pttBtn, {
|
||||
active: update.status.tx_en,
|
||||
activeLabel: "Stop TX",
|
||||
inactiveLabel: "Start TX",
|
||||
});
|
||||
if (update.status.tx_en) {
|
||||
pttBtn.style.background = "var(--accent-red)";
|
||||
pttBtn.style.borderColor = "var(--accent-red)";
|
||||
@@ -3530,11 +3589,15 @@ function render(update) {
|
||||
bandLabel.textContent = typeof update.band === "string" ? update.band : "--";
|
||||
}
|
||||
if (typeof update.enabled === "boolean") {
|
||||
powerBtn.disabled = false;
|
||||
powerBtn.textContent = update.enabled ? "Power Off" : "Power On";
|
||||
window.trxUi?.setButtonState(powerBtn, {
|
||||
active: update.enabled,
|
||||
activeLabel: "Power Off",
|
||||
inactiveLabel: "Power On",
|
||||
});
|
||||
} else {
|
||||
powerBtn.disabled = true;
|
||||
powerBtn.textContent = "Toggle Power";
|
||||
powerBtn.textContent = "Power unavailable";
|
||||
powerBtn.setAttribute("aria-pressed", "false");
|
||||
powerHint.textContent = "State unknown";
|
||||
}
|
||||
lastControl = update.enabled;
|
||||
@@ -3649,7 +3712,11 @@ function render(update) {
|
||||
}
|
||||
powerHint.textContent = readyText();
|
||||
lastLocked = update.status && update.status.lock === true;
|
||||
lockBtn.textContent = lastLocked ? "Unlock" : "Lock";
|
||||
window.trxUi?.setButtonState(lockBtn, {
|
||||
active: lastLocked,
|
||||
activeLabel: "Unlock Tuning",
|
||||
inactiveLabel: "Lock Tuning",
|
||||
});
|
||||
|
||||
const tx = update.status && update.status.tx ? update.status.tx : null;
|
||||
txMeters.style.display = lastHasTx ? "" : "none";
|
||||
@@ -3836,12 +3903,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) {
|
||||
@@ -3886,44 +3957,60 @@ 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.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) {
|
||||
headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); });
|
||||
}
|
||||
|
||||
function setControlPending(control, pending) {
|
||||
if (!control) return;
|
||||
control.disabled = pending;
|
||||
control.classList.toggle("is-busy", pending);
|
||||
control.setAttribute("aria-busy", String(pending));
|
||||
}
|
||||
|
||||
powerBtn.addEventListener("click", async () => {
|
||||
powerBtn.disabled = true;
|
||||
setControlPending(powerBtn, true);
|
||||
showHint("Sending...");
|
||||
try {
|
||||
await postPath("/toggle_power");
|
||||
@@ -3932,12 +4019,12 @@ powerBtn.addEventListener("click", async () => {
|
||||
showHint("Toggle failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
powerBtn.disabled = false;
|
||||
setControlPending(powerBtn, false);
|
||||
}
|
||||
});
|
||||
|
||||
pttBtn.addEventListener("click", async () => {
|
||||
pttBtn.disabled = true;
|
||||
setControlPending(pttBtn, true);
|
||||
showHint("Toggling PTT…");
|
||||
try {
|
||||
const desired = lastTxEn ? "false" : "true";
|
||||
@@ -3947,7 +4034,7 @@ pttBtn.addEventListener("click", async () => {
|
||||
showHint("PTT toggle failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
pttBtn.disabled = false;
|
||||
setControlPending(pttBtn, false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3980,7 +4067,7 @@ async function applyCenterFreqFromInput() {
|
||||
return;
|
||||
}
|
||||
centerFreqDirty = false;
|
||||
centerFreqEl.disabled = true;
|
||||
setControlPending(centerFreqEl, true);
|
||||
showHint("Setting central frequency…");
|
||||
try {
|
||||
await postPath(`/set_center_freq?hz=${parsed}`);
|
||||
@@ -3989,7 +4076,7 @@ async function applyCenterFreqFromInput() {
|
||||
showHint("Set central freq failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
centerFreqEl.disabled = false;
|
||||
setControlPending(centerFreqEl, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4190,7 +4277,7 @@ async function applyModeFromPicker() {
|
||||
return;
|
||||
}
|
||||
updateWfmControls();
|
||||
modeEl.disabled = true;
|
||||
setControlPending(modeEl, true);
|
||||
showHint("Setting mode…");
|
||||
try {
|
||||
if (typeof vchanInterceptMode === "function" && await vchanInterceptMode(mode)) {
|
||||
@@ -4208,7 +4295,7 @@ async function applyModeFromPicker() {
|
||||
showHint("Set mode failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
modeEl.disabled = false;
|
||||
setControlPending(modeEl, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4227,7 +4314,7 @@ txLimitBtn.addEventListener("click", async () => {
|
||||
showHint("Limit missing", 1500);
|
||||
return;
|
||||
}
|
||||
txLimitBtn.disabled = true;
|
||||
setControlPending(txLimitBtn, true);
|
||||
showHint("Setting TX limit…");
|
||||
try {
|
||||
await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`);
|
||||
@@ -4236,22 +4323,22 @@ txLimitBtn.addEventListener("click", async () => {
|
||||
showHint("TX limit failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
txLimitBtn.disabled = false;
|
||||
setControlPending(txLimitBtn, false);
|
||||
}
|
||||
});
|
||||
|
||||
lockBtn.addEventListener("click", async () => {
|
||||
lockBtn.disabled = true;
|
||||
setControlPending(lockBtn, true);
|
||||
showHint("Toggling lock…");
|
||||
try {
|
||||
const nextLock = lockBtn.textContent === "Lock";
|
||||
const nextLock = !lastLocked;
|
||||
await postPath(nextLock ? "/lock" : "/unlock");
|
||||
showHint("Lock toggled", 1500);
|
||||
} catch (err) {
|
||||
showHint("Lock toggle failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
lockBtn.disabled = false;
|
||||
setControlPending(lockBtn, false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4268,7 +4355,7 @@ const MODE_BW_DEFAULTS = {
|
||||
FM: [12_500, 2_500, 25_000, 500],
|
||||
AIS: [25_000, 12_500, 50_000, 500],
|
||||
VDES: [100_000, 25_000, 200_000, 1_000],
|
||||
WFM: [180_000, 50_000,300_000,5_000],
|
||||
WFM: [180_000, 60_000,300_000,5_000],
|
||||
DIG: [3_000, 300, 6_000, 100],
|
||||
PKT: [25_000, 300, 50_000, 500],
|
||||
};
|
||||
@@ -4318,7 +4405,8 @@ async function applyBwDefaultForMode(mode, sendToServer) {
|
||||
scheduleSpectrumDraw();
|
||||
}
|
||||
if (sendToServer) {
|
||||
try { await postPath(`/set_bandwidth?hz=${def}`); } catch (_) {}
|
||||
try { await postPath(`/set_bandwidth?hz=${def}`); }
|
||||
catch (error) { window.trxUi?.notify("Default bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: () => applyBwDefaultForMode(mode, true) } }); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4345,67 +4433,115 @@ async function applyBandwidthFromInput() {
|
||||
if (Number.isFinite(lastFreqHz)) {
|
||||
await ensureTunedBandwidthCoverage(lastFreqHz);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (error) {
|
||||
window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } });
|
||||
}
|
||||
}
|
||||
|
||||
function estimateBandwidthAroundPeak(data, centerHz) {
|
||||
function estimateOccupiedBandwidth(data, centerHz, interference = {}) {
|
||||
if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bins = data.bins;
|
||||
const maxIdx = bins.length - 1;
|
||||
const hzPerBin = data.sample_rate / maxIdx;
|
||||
const fullLoHz = data.center_hz - data.sample_rate / 2;
|
||||
const centerIdx = Math.max(
|
||||
1,
|
||||
Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)),
|
||||
);
|
||||
const searchRadius = Math.max(6, Math.min(120, Math.round(maxIdx * 0.03)));
|
||||
const searchLo = Math.max(1, centerIdx - searchRadius);
|
||||
const searchHi = Math.min(maxIdx - 1, centerIdx + searchRadius);
|
||||
|
||||
let peakIdx = centerIdx;
|
||||
for (let i = searchLo; i <= searchHi; i++) {
|
||||
if (bins[i] > bins[peakIdx]) peakIdx = i;
|
||||
}
|
||||
const mode = (modeEl ? modeEl.value : "USB").toUpperCase();
|
||||
const [defaultBw, minBw, maxBw, stepBw] = mwDefaultsForMode(mode);
|
||||
const oneSided = mode === "USB" || mode === "DIG" || mode === "CW"
|
||||
? 1
|
||||
: mode === "LSB" || mode === "CWR" ? -1 : 0;
|
||||
const isWfm = mode === "WFM";
|
||||
|
||||
// Reduce single-bin peaks and holes before finding occupied-channel edges.
|
||||
// WFM needs a wider smoothing window because its energy is noise-like and
|
||||
// spread across the entire channel rather than concentrated at a carrier.
|
||||
const smoothRadius = isWfm ? 3 : 1;
|
||||
const smoothed = bins.map((_, i) => {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let j = Math.max(0, i - smoothRadius); j <= Math.min(maxIdx, i + smoothRadius); j++) {
|
||||
sum += bins[j];
|
||||
count += 1;
|
||||
}
|
||||
return sum / count;
|
||||
});
|
||||
const sorted = [...bins].sort((a, b) => a - b);
|
||||
const noise = sorted[Math.floor(sorted.length * 0.2)];
|
||||
const peak = bins[peakIdx];
|
||||
const threshold = Math.max(noise + 4, peak - Math.max(8, (peak - noise) * 0.35));
|
||||
const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin));
|
||||
const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins;
|
||||
const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins));
|
||||
const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins));
|
||||
let peak = -Infinity;
|
||||
for (let i = searchLo; i <= searchHi; i++) peak = Math.max(peak, smoothed[i]);
|
||||
const snr = peak - noise;
|
||||
if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw;
|
||||
|
||||
let left = peakIdx;
|
||||
let right = peakIdx;
|
||||
let belowCount = 0;
|
||||
for (let i = peakIdx; i > 1; i--) {
|
||||
if (bins[i] < threshold) belowCount += 1;
|
||||
else belowCount = 0;
|
||||
if (belowCount >= 2) break;
|
||||
left = i;
|
||||
// A threshold relative to the noise floor finds occupied bandwidth much
|
||||
// more reliably than one relative to the peak. The latter fails for WFM,
|
||||
// whose multiplex spectrum has peaks, notches, and no narrow centre carrier.
|
||||
const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28)));
|
||||
const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12_000 : stepBw) / hzPerBin));
|
||||
|
||||
function occupiedExtent(direction, limitBins) {
|
||||
let lastOccupied = centerIdx;
|
||||
let gap = 0;
|
||||
for (let n = 0; n <= limitBins; n++) {
|
||||
const i = centerIdx + direction * n;
|
||||
if (i <= 0 || i >= maxIdx) break;
|
||||
if (smoothed[i] >= threshold) {
|
||||
lastOccupied = i;
|
||||
gap = 0;
|
||||
} else if (++gap > allowedGap) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Math.abs(lastOccupied - centerIdx) * hzPerBin;
|
||||
}
|
||||
|
||||
belowCount = 0;
|
||||
for (let i = peakIdx; i < maxIdx - 1; i++) {
|
||||
if (bins[i] < threshold) belowCount += 1;
|
||||
else belowCount = 0;
|
||||
if (belowCount >= 2) break;
|
||||
right = i;
|
||||
let rawBw;
|
||||
if (oneSided !== 0) {
|
||||
rawBw = occupiedExtent(oneSided, maxSpanBins);
|
||||
} else {
|
||||
const leftHz = occupiedExtent(-1, searchHalfBins);
|
||||
const rightHz = occupiedExtent(1, searchHalfBins);
|
||||
// A symmetric RF filter must contain the larger of the two sidebands.
|
||||
rawBw = 2 * Math.max(leftHz, rightHz);
|
||||
}
|
||||
|
||||
const shoulderPad = Math.max(1, Math.round((right - left) * 0.08));
|
||||
left = Math.max(0, left - shoulderPad);
|
||||
right = Math.min(maxIdx, right + shoulderPad);
|
||||
|
||||
const hzPerBin = data.sample_rate / maxIdx;
|
||||
const rawBw = Math.max(hzPerBin, (right - left) * hzPerBin);
|
||||
const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
|
||||
// Add a transition-band margin. Weak WFM deliberately falls back to the
|
||||
// 60 kHz mode floor above: a narrower filter trades stereo/RDS content for
|
||||
// a useful improvement in intelligibility when the signal is very poor.
|
||||
rawBw *= isWfm ? 1.08 : 1.12;
|
||||
if (isWfm) {
|
||||
const aci = Math.max(0, Math.min(100, Number(interference.aci) || 0)) / 100;
|
||||
const cci = Math.max(0, Math.min(100, Number(interference.cci) || 0)) / 100;
|
||||
// Adjacent-channel energy is outside the wanted modulation, so ACI can
|
||||
// safely drive the cap all the way from the 300 kHz ceiling to 60 kHz.
|
||||
const aciCap = maxBw - (maxBw - minBw) * aci;
|
||||
// CCI overlaps the wanted station and cannot be removed by an RF filter.
|
||||
// Only distrust the widest edge estimates, retaining at least 65% of the
|
||||
// useful range between the weak-signal floor and nominal WFM bandwidth.
|
||||
const cciFloor = minBw + (defaultBw - minBw) * 0.65;
|
||||
const cciCap = maxBw - (maxBw - cciFloor) * cci;
|
||||
rawBw = Math.min(rawBw, aciCap, cciCap);
|
||||
}
|
||||
const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
|
||||
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
|
||||
}
|
||||
|
||||
async function applyAutoBandwidth() {
|
||||
if (!lastSpectrumData || lastFreqHz == null) return;
|
||||
const estimated = estimateBandwidthAroundPeak(lastSpectrumData, lastFreqHz);
|
||||
// WFM interference telemetry belongs to the primary DSP channel. Do not
|
||||
// apply it to a virtual channel, where it would describe the wrong signal.
|
||||
const onVirtual = typeof vchanIsOnVirtual === "function" && vchanIsOnVirtual();
|
||||
const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci };
|
||||
const estimated = estimateOccupiedBandwidth(lastSpectrumData, lastFreqHz, interference);
|
||||
if (!Number.isFinite(estimated) || estimated <= 0) {
|
||||
syncBandwidthInput(currentBandwidthHz);
|
||||
return;
|
||||
@@ -4417,13 +4553,24 @@ async function applyAutoBandwidth() {
|
||||
if (lastSpectrumData) {
|
||||
scheduleSpectrumDraw();
|
||||
}
|
||||
const mode = (modeEl?.value || "").toUpperCase();
|
||||
let reason = "measured occupied spectrum";
|
||||
if (mode === "WFM") {
|
||||
if (estimated === 60_000 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`;
|
||||
else if (estimated === 60_000) reason = "weak-signal noise rejection";
|
||||
else if (lastWfmAci >= lastWfmCci && lastWfmAci >= 10) reason = `${Math.round(lastWfmAci)}% ACI cap`;
|
||||
else if (lastWfmCci >= 10) reason = `${Math.round(lastWfmCci)}% CCI confidence cap`;
|
||||
}
|
||||
window.trxUi?.notify(`Auto BW: ${formatBwLabel(estimated)} — ${reason}`, { kind: "success", duration: 5000 });
|
||||
try {
|
||||
if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return;
|
||||
await postPath(`/set_bandwidth?hz=${estimated}`);
|
||||
if (Number.isFinite(lastFreqHz)) {
|
||||
await ensureTunedBandwidthCoverage(lastFreqHz);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (error) {
|
||||
window.trxUi?.notify("Automatic bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: applyAutoBandwidth } });
|
||||
}
|
||||
}
|
||||
|
||||
if (spectrumBwInput) {
|
||||
@@ -4480,23 +4627,23 @@ function updateTabHistory(name, replaceHistory = false) {
|
||||
}
|
||||
|
||||
// Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js
|
||||
// (window.trx.map) if they haven't loaded yet.
|
||||
// (window.trx.modules.map) if they haven't loaded yet.
|
||||
let _mapInitTimer = null;
|
||||
function _initMapWhenReady() {
|
||||
const loadingEl = document.getElementById("map-loading");
|
||||
if (window.trx.map && typeof L !== "undefined") {
|
||||
if (window.trx.modules.map && typeof L !== "undefined") {
|
||||
if (_mapInitTimer) { clearInterval(_mapInitTimer); _mapInitTimer = null; }
|
||||
if (loadingEl) loadingEl.classList.add("is-hidden");
|
||||
window.trx.map.initAprsMap();
|
||||
window.trx.map.sizeAprsMapToViewport();
|
||||
window.trx.modules.map.initAprsMap();
|
||||
window.trx.modules.map.sizeAprsMapToViewport();
|
||||
// The map panel was just made visible (display:none → ""); the browser
|
||||
// may not have laid it out yet, so getBoundingClientRect() can return
|
||||
// stale/zero dimensions. Double-rAF ensures a full layout pass has
|
||||
// completed before we re-measure and tell Leaflet about its real size.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
window.trx.map.sizeAprsMapToViewport();
|
||||
if (window.trx.map.aprsMap) window.trx.map.aprsMap.invalidateSize();
|
||||
window.trx.modules.map.sizeAprsMapToViewport();
|
||||
if (window.trx.modules.map.aprsMap) window.trx.modules.map.aprsMap.invalidateSize();
|
||||
});
|
||||
});
|
||||
return;
|
||||
@@ -4512,6 +4659,7 @@ function _initMapWhenReady() {
|
||||
}
|
||||
|
||||
function navigateToTab(name, options = {}) {
|
||||
window.trxUi?.closeMobileOverlays?.();
|
||||
const { updateHistory = true, replaceHistory = false } = options;
|
||||
if (authEnabled && !authRole && name !== "main") {
|
||||
showAuthGate(false);
|
||||
@@ -4522,6 +4670,7 @@ function navigateToTab(name, options = {}) {
|
||||
_activeTab = name;
|
||||
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
|
||||
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
|
||||
const panel = document.getElementById(`tab-${name}`);
|
||||
panel.style.display = "";
|
||||
@@ -4544,12 +4693,13 @@ function navigateToTab(name, options = {}) {
|
||||
_initMapWhenReady();
|
||||
}
|
||||
if (name === "statistics") {
|
||||
window.trx.map?.scheduleStatsRender();
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
if (name === "recorder") {
|
||||
refreshRecorderStatus();
|
||||
}
|
||||
}
|
||||
window.navigateToTab = navigateToTab;
|
||||
|
||||
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".tab[data-tab]");
|
||||
@@ -4708,7 +4858,7 @@ if (headerAuthBtn) {
|
||||
headerAuthBtn.addEventListener("click", async () => {
|
||||
if (authRole) {
|
||||
// Logged in - show logout confirmation
|
||||
if (confirm("Are you sure you want to logout?")) {
|
||||
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
|
||||
await authLogout();
|
||||
}
|
||||
} else {
|
||||
@@ -4721,10 +4871,11 @@ if (headerAuthBtn) {
|
||||
// ── Shared namespace for lazy-loaded modules ────────────────────────────────
|
||||
// Modules (map-core.js, screenshot.js) access core state and utilities via
|
||||
// window.trx. Modules register their own APIs as sub-namespaces
|
||||
// (e.g. window.trx.map, window.trx.screenshot).
|
||||
window.trx = Object.create(null);
|
||||
// (e.g. window.trx.modules.map, window.trx.modules.screenshot).
|
||||
const trxState = Object.create(null);
|
||||
const trxModules = Object.create(null);
|
||||
// -- State getters (backed by core-scoped variables) --
|
||||
Object.defineProperties(window.trx, {
|
||||
Object.defineProperties(trxState, {
|
||||
serverLat: { get() { return serverLat; }, set(v) { serverLat = v; } },
|
||||
serverLon: { get() { return serverLon; }, set(v) { serverLon = v; } },
|
||||
lastFreqHz: { get() { return lastFreqHz; } },
|
||||
@@ -4762,7 +4913,7 @@ Object.defineProperties(window.trx, {
|
||||
signalOverlayGl: { get() { return signalOverlayGl; } },
|
||||
});
|
||||
// -- Shared utility functions --
|
||||
Object.assign(window.trx, {
|
||||
const trxCore = Object.freeze({
|
||||
saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
|
||||
formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal,
|
||||
postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
|
||||
@@ -4772,18 +4923,19 @@ Object.assign(window.trx, {
|
||||
currentTheme, canvasPalette, currentStyle,
|
||||
cssColorToRgba, rgbaWithAlpha, isBinsArray, estimateNoiseFloorDb,
|
||||
spectrumVisibleRange, drawSpectrum,
|
||||
bandForHz: function(hz) { return window.trx.map?.bandForHz?.(hz); },
|
||||
bandForHz: function(hz) { return trxModules.map?.bandForHz?.(hz); },
|
||||
markDecodeMapSyncPending,
|
||||
decodeHistoryMapRenderingDeferred,
|
||||
updateDocumentTitle,
|
||||
activeChannelRds,
|
||||
});
|
||||
Object.defineProperties(window.trx, {
|
||||
Object.defineProperties(trxState, {
|
||||
decodeHistoryReplayActive: { get() { return decodeHistoryReplayActive; } },
|
||||
decodeMapSyncPending: { get() { return decodeMapSyncPending; } },
|
||||
_activeTab: { get() { return _activeTab; } },
|
||||
locationSubtitle: { get() { return locationSubtitle; } },
|
||||
});
|
||||
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
|
||||
|
||||
// Load plugin scripts now that window.trx is populated. Dynamic scripts are
|
||||
// async so they must not be created before the namespace they depend on exists.
|
||||
@@ -4797,7 +4949,7 @@ window.addEventListener("resize", resizeHeaderSignalCanvas);
|
||||
// ── Map module (extracted to map-core.js, lazy-loaded) ──────────────────────
|
||||
// The map, statistics, and geolocation code (~3,450 lines) has been moved to
|
||||
// map-core.js and is loaded on demand when the Map tab is first activated.
|
||||
// Core communicates with the map module via window.trx.map.* namespace.
|
||||
// Core communicates with the map module via window.trx.modules.map.* namespace.
|
||||
|
||||
// ── Geo utilities (shared with map-core.js via window.trx) ─────────────────
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
@@ -4907,11 +5059,15 @@ function latLonToMaidenhead(lat, lon) {
|
||||
function _wireSubTabBar(bar) {
|
||||
if (bar._subtabWired) return;
|
||||
bar._subtabWired = true;
|
||||
window.trxUi?.prepareTabList(bar, "secondary");
|
||||
bar.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".sub-tab[data-subtab]");
|
||||
if (!btn) return;
|
||||
bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
window.trxUi?.syncSelectedTab(bar, btn);
|
||||
const decoderPicker = document.getElementById("decoder-tab-select");
|
||||
if (decoderPicker && btn.closest("#tab-digital-modes")) decoderPicker.value = btn.dataset.subtab;
|
||||
const parent = bar.parentElement;
|
||||
parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
|
||||
const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`);
|
||||
@@ -4932,7 +5088,7 @@ document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||
window.addEventListener("resize", () => {
|
||||
const mapTab = document.getElementById("tab-map");
|
||||
if (!mapTab || mapTab.style.display === "none") return;
|
||||
window.trx.map?.sizeAprsMapToViewport();
|
||||
window.trx.modules.map?.sizeAprsMapToViewport();
|
||||
});
|
||||
|
||||
// --- Signal measurement ---
|
||||
@@ -5402,6 +5558,7 @@ function configureRxStream(nextInfo) {
|
||||
ensureRxAudioContext(nextSampleRate);
|
||||
rxGainNode.gain.value = rxVolSlider.value / 100;
|
||||
rxActive = true;
|
||||
window.trxUi?.setButtonState(rxAudioBtn, { active: true, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
|
||||
setAudioLevel(0);
|
||||
rxAudioBtn.style.borderColor = "#00d17f";
|
||||
rxAudioBtn.style.color = "#00d17f";
|
||||
@@ -5603,6 +5760,7 @@ function startRxAudio() {
|
||||
// If TX was active when WS closed, release PTT
|
||||
if (txActive) { stopTxAudio(); }
|
||||
rxActive = false;
|
||||
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
|
||||
streamInfo = null;
|
||||
updateWfmControls();
|
||||
rxAudioBtn.style.borderColor = "";
|
||||
@@ -5629,6 +5787,7 @@ function startRxAudio() {
|
||||
|
||||
function stopRxAudio() {
|
||||
rxActive = false;
|
||||
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
|
||||
streamInfo = null;
|
||||
if (audioWs) { audioWs.close(); audioWs = null; }
|
||||
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
||||
@@ -5667,6 +5826,7 @@ function startTxAudio() {
|
||||
}).then(async (stream) => {
|
||||
txStream = stream;
|
||||
txActive = true;
|
||||
window.trxUi?.setButtonState(txAudioBtn, { active: true, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
|
||||
txAudioBtn.style.borderColor = "#e55353";
|
||||
txAudioBtn.style.color = "#e55353";
|
||||
audioStatus.textContent = "RX+TX";
|
||||
@@ -5744,6 +5904,7 @@ function startTxAudio() {
|
||||
async function stopTxAudio() {
|
||||
if (!txActive) return;
|
||||
txActive = false;
|
||||
window.trxUi?.setButtonState(txAudioBtn, { active: false, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
|
||||
clearTxTimeout();
|
||||
|
||||
// Release PTT automatically
|
||||
@@ -5989,7 +6150,7 @@ function renderRecorderFiles() {
|
||||
el.querySelectorAll(".rec-delete-btn").forEach(function (btn) {
|
||||
btn.addEventListener("click", async function () {
|
||||
const name = btn.dataset.name;
|
||||
if (!confirm("Delete recording " + name + "?")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Delete recording?", message: `${name} will be permanently removed.`, confirmLabel: "Delete" })) return;
|
||||
try {
|
||||
const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" });
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
@@ -5997,6 +6158,7 @@ function renderRecorderFiles() {
|
||||
renderRecorderFiles();
|
||||
} catch (e) {
|
||||
console.error("Delete failed", e);
|
||||
window.trxUi?.notify("Recording could not be deleted", { kind: "error" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6102,8 +6264,8 @@ function dispatchDecodeMessage(msg, skipStats) {
|
||||
if (msg.type === "wefax" && window.onServerWefax) window.onServerWefax(msg);
|
||||
if (msg.type === "wefax_progress" && window.onServerWefaxProgress) window.onServerWefaxProgress(msg);
|
||||
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
|
||||
window.trx.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
window.trx.map?.scheduleStatsRender();
|
||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6112,10 +6274,10 @@ function dispatchDecodeBatch(batch) {
|
||||
// Record statistics for every message in the batch regardless of dispatch path.
|
||||
for (const msg of batch) {
|
||||
if (msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
|
||||
window.trx.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
}
|
||||
}
|
||||
window.trx.map?.scheduleStatsRender();
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
const type = String(batch[0]?.type || "");
|
||||
const uniformType = batch.every((msg) => String(msg?.type || "") === type);
|
||||
if (uniformType) {
|
||||
@@ -6200,9 +6362,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
|
||||
// Record statistics for restored history messages.
|
||||
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
|
||||
for (const msg of messages) {
|
||||
window.trx.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
||||
}
|
||||
window.trx.map?.scheduleStatsRender();
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
if (kind === "ais") {
|
||||
if (window.restoreAisHistory) { window.restoreAisHistory(messages); }
|
||||
@@ -6933,6 +7095,13 @@ function startSpectrumStreaming() {
|
||||
const rds = lastSpectrumData?.rds;
|
||||
lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds };
|
||||
window.lastSpectrumData = lastSpectrumData;
|
||||
const spectrumSummary = document.getElementById("spectrum-text-summary");
|
||||
if (spectrumSummary && bins.length) {
|
||||
let peakIndex = 0;
|
||||
for (let i = 1; i < bins.length; i += 1) if (bins[i] > bins[peakIndex]) peakIndex = i;
|
||||
const peakHz = centerHz - sampleRate / 2 + (peakIndex / Math.max(1, bins.length - 1)) * sampleRate;
|
||||
spectrumSummary.textContent = `Spectrum centered at ${formatFreqForHumans(centerHz)}, spanning ${formatFreqForHumans(sampleRate)}. Strongest visible bin near ${formatFreqForHumans(peakHz)} at ${bins[peakIndex]} dB.`;
|
||||
}
|
||||
// Server confirmed a new center — clear optimistic pending value.
|
||||
if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1000) {
|
||||
spectrumCenterPendingHz = null;
|
||||
@@ -8000,12 +8169,12 @@ window.addEventListener("keydown", (event) => {
|
||||
// S — spectrum screenshot (lazy-loads screenshot.js on first use)
|
||||
if (key === "s") {
|
||||
event.preventDefault();
|
||||
if (window.trx.screenshot) {
|
||||
void window.trx.screenshot.captureSpectrumScreenshot();
|
||||
if (window.trx.modules.screenshot) {
|
||||
void window.trx.modules.screenshot.captureSpectrumScreenshot();
|
||||
} else {
|
||||
const s = document.createElement("script");
|
||||
s.src = "/screenshot.js";
|
||||
s.onload = () => { void window.trx.screenshot?.captureSpectrumScreenshot(); };
|
||||
s.onload = () => { void window.trx.modules.screenshot?.captureSpectrumScreenshot(); };
|
||||
document.body.appendChild(s);
|
||||
}
|
||||
return;
|
||||
@@ -8395,7 +8564,12 @@ if (spectrumCanvas || overviewCanvas) {
|
||||
await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (error) {
|
||||
window.trxUi?.notify("Bandwidth could not be changed", {
|
||||
kind: "error",
|
||||
action: { label: "Retry", run: () => postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`) },
|
||||
});
|
||||
}
|
||||
_bwDragEdge = null;
|
||||
_bwDragCanvas = null;
|
||||
return;
|
||||
|
||||
@@ -43,7 +43,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="subtitle" id="location-subtitle" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-bar-nav">
|
||||
<div class="tab-bar-nav" aria-label="Primary navigation">
|
||||
<button class="tab active" data-tab="main">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg>
|
||||
<span class="tab-label">Main</span>
|
||||
@@ -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>
|
||||
<div class="header-rig-switch">
|
||||
<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 class="header-style-pick">
|
||||
<select id="header-style-pick-select" aria-label="Select UI style">
|
||||
@@ -140,13 +141,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="spectrum-wrap">
|
||||
<div id="spectrum-bookmark-axis"></div>
|
||||
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
|
||||
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display"></canvas>
|
||||
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display" aria-describedby="spectrum-text-summary"></canvas>
|
||||
<p id="spectrum-text-summary" class="visually-hidden">Spectrum data is waiting for the receiver.</p>
|
||||
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
|
||||
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
|
||||
<div id="spectrum-db-axis" aria-hidden="true"></div>
|
||||
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
|
||||
<div id="spectrum-tooltip"></div>
|
||||
<canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" style="display:none;"></canvas>
|
||||
<canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" aria-describedby="spectrum-text-summary" style="display:none;"></canvas>
|
||||
<div id="spectrum-freq-axis">
|
||||
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">‹</button>
|
||||
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">›</button>
|
||||
@@ -204,12 +206,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="label"><span>Signal strength</span></div>
|
||||
</div>
|
||||
<div class="freq-field frequency-col">
|
||||
<input class="status-input" id="freq" type="text" value="--" />
|
||||
<div class="label"><span>Frequency</span></div>
|
||||
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" />
|
||||
<div class="label" id="freq-label"><span>Frequency</span></div>
|
||||
</div>
|
||||
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
|
||||
<input class="status-input" id="center-freq" type="text" value="--" />
|
||||
<div class="label"><span>Center Frequency</span></div>
|
||||
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" />
|
||||
<div class="label" id="center-freq-label"><span>Center Frequency</span></div>
|
||||
</div>
|
||||
<div class="freq-field unit-col">
|
||||
<div class="jog-step" id="jog-step">
|
||||
@@ -235,7 +237,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="controls-col label-below-col">
|
||||
<div class="label"><span>Mode</span></div>
|
||||
<div class="inline">
|
||||
<select class="status-input" id="mode"></select>
|
||||
<select class="status-input" id="mode" aria-label="Demodulation mode"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls-col controls-col-center">
|
||||
@@ -307,9 +309,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
|
||||
<div class="label"><span>Transmit / Power</span></div>
|
||||
<div class="btn-grid">
|
||||
<button id="ptt-btn" type="button">Toggle PTT</button>
|
||||
<button id="power-btn" type="button">Toggle Power</button>
|
||||
<button id="lock-btn" type="button">Lock</button>
|
||||
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
|
||||
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
|
||||
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -495,6 +497,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</label>
|
||||
</div>
|
||||
<div class="bm-form-actions">
|
||||
<div id="bm-form-error" class="form-error" role="alert" aria-live="polite"></div>
|
||||
<button type="submit" class="bm-save-btn">Save</button>
|
||||
<button type="button" id="bm-form-cancel">Cancel</button>
|
||||
</div>
|
||||
@@ -530,7 +533,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-digital-modes" class="tab-panel" style="display:none;">
|
||||
<div class="sub-tab-bar">
|
||||
<div class="sub-tab-bar" aria-label="Decoder views">
|
||||
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
||||
<button class="sub-tab" data-subtab="ais">AIS</button>
|
||||
<button class="sub-tab" data-subtab="vdes">VDES</button>
|
||||
@@ -1623,10 +1626,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
||||
</div>
|
||||
</div>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.11/dist/opus-decoder.min.js" charset="UTF-8"></script>
|
||||
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
|
||||
<script defer src="/vendor/leaflet.js"></script>
|
||||
<script defer src="/leaflet-ais-tracksymbol.js"></script>
|
||||
<script defer src="/webgl-renderer.js"></script>
|
||||
<script defer src="/ui-core.js"></script>
|
||||
<script defer src="/app.js"></script>
|
||||
<script>
|
||||
// Lazy plugin loader: loads plugin scripts when their tab/feature is first activated
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// Map, statistics, and geolocation module (lazy-loaded on map tab activation).
|
||||
// Communicates with app.js core via window.trx namespace.
|
||||
// Communicates with app.js through explicit state/core/module services.
|
||||
(function () {
|
||||
"use strict";
|
||||
const T = window.trx;
|
||||
const { state: T, core: C, modules } = window.trx;
|
||||
|
||||
// Destructure shared utility functions for convenience
|
||||
const { saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
|
||||
@@ -14,7 +14,7 @@
|
||||
formatUptime, latLonToMaidenhead, locatorToLatLon, haversineKm,
|
||||
formatDistanceKm, formatTimeAgo, currentDecodeHistoryRetentionMs,
|
||||
formatWavelength, bookmarkDistanceText, buildBookmarkTooltipText,
|
||||
nearestBookmarkForHz } = T;
|
||||
nearestBookmarkForHz } = C;
|
||||
|
||||
function updateMapRigFilter() {
|
||||
const el = document.getElementById("map-rig-filter");
|
||||
@@ -261,7 +261,7 @@
|
||||
if (canRenderMap) {
|
||||
refreshAprsTrack(call, entry);
|
||||
} else {
|
||||
T.markDecodeMapSyncPending();
|
||||
C.markDecodeMapSyncPending();
|
||||
}
|
||||
if (!visible) {
|
||||
if (canRenderMap && selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) {
|
||||
@@ -294,7 +294,7 @@
|
||||
if (canRenderMap) {
|
||||
refreshAisTrack(key, entry);
|
||||
} else {
|
||||
T.markDecodeMapSyncPending();
|
||||
C.markDecodeMapSyncPending();
|
||||
}
|
||||
if (!visible) {
|
||||
if (canRenderMap && selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) {
|
||||
@@ -337,7 +337,7 @@
|
||||
entry.stations = new Set();
|
||||
entry.bandMeta = new Map();
|
||||
if (canRenderMap) setRetainedMapMarkerVisible(entry.marker, false);
|
||||
else T.markDecodeMapSyncPending();
|
||||
else C.markDecodeMapSyncPending();
|
||||
return false;
|
||||
}
|
||||
const nextStations = new Set();
|
||||
@@ -352,7 +352,7 @@
|
||||
);
|
||||
const count = Math.max(nextDetails.size, nextStations.size || 0, 1);
|
||||
if (!canRenderMap) {
|
||||
T.markDecodeMapSyncPending();
|
||||
C.markDecodeMapSyncPending();
|
||||
return true;
|
||||
}
|
||||
ensureDecodeLocatorMarker(entry);
|
||||
@@ -392,7 +392,7 @@
|
||||
pruneLocatorEntry(key, entry, cutoffMs);
|
||||
}
|
||||
if (!aprsMap || T.decodeHistoryReplayActive) {
|
||||
T.markDecodeMapSyncPending();
|
||||
C.markDecodeMapSyncPending();
|
||||
return;
|
||||
}
|
||||
rebuildDecodeContactPaths();
|
||||
@@ -415,7 +415,7 @@
|
||||
|
||||
function locatorFilterColor(type) {
|
||||
const hues = locatorThemeHues();
|
||||
const lightTheme = T.currentTheme() === "light";
|
||||
const lightTheme = C.currentTheme() === "light";
|
||||
const sat = lightTheme ? 66 : 76;
|
||||
const light = lightTheme ? 42 : 56;
|
||||
const hue = type === "bookmark"
|
||||
@@ -539,7 +539,7 @@
|
||||
}
|
||||
|
||||
function locatorThemeHues() {
|
||||
const pal = T.canvasPalette();
|
||||
const pal = C.canvasPalette();
|
||||
const baseHue = paletteHue(pal?.spectrumLine, 145);
|
||||
const waveHue = paletteHue(pal?.waveformLine, baseHue + 34);
|
||||
const peakHue = paletteHue(pal?.waveformPeak, baseHue - 42);
|
||||
@@ -560,7 +560,7 @@
|
||||
|
||||
function locatorBandChipColor(label) {
|
||||
const hues = locatorThemeHues();
|
||||
const lightTheme = T.currentTheme() === "light";
|
||||
const lightTheme = C.currentTheme() === "light";
|
||||
const hue = wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508);
|
||||
const sat = lightTheme ? 68 : 78;
|
||||
const light = lightTheme ? 44 : 58;
|
||||
@@ -606,7 +606,7 @@
|
||||
const safeCount = Math.max(1, Number.isFinite(count) ? count : 1);
|
||||
const intensity = Math.min(1, Math.log2(safeCount + 1) / 5);
|
||||
const hue = locatorHueForEntry(entry);
|
||||
const lightTheme = T.currentTheme() === "light";
|
||||
const lightTheme = C.currentTheme() === "light";
|
||||
const strokeSat = lightTheme ? 62 : 74;
|
||||
const fillSat = lightTheme ? 68 : 78;
|
||||
const strokeLight = lightTheme ? 40 : 56;
|
||||
@@ -1573,7 +1573,7 @@
|
||||
stageResizeObserver = new ResizeObserver(() => sizeAprsMapToViewport());
|
||||
stageResizeObserver.observe(stage);
|
||||
}
|
||||
updateMapBaseLayerForTheme(T.currentTheme());
|
||||
updateMapBaseLayerForTheme(C.currentTheme());
|
||||
syncAprsReceiverMarker();
|
||||
|
||||
// Rebuild popup content on open (keeps age/distance/rig list fresh)
|
||||
@@ -2307,18 +2307,18 @@
|
||||
existing.rigIds.add(msgRigId);
|
||||
}
|
||||
if (!visible) {
|
||||
if (!T.decodeHistoryMapRenderingDeferred()) {
|
||||
if (!C.decodeHistoryMapRenderingDeferred()) {
|
||||
setRetainedMapMarkerVisible(existing.marker, false);
|
||||
} else {
|
||||
T.markDecodeMapSyncPending();
|
||||
C.markDecodeMapSyncPending();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!T.decodeHistoryMapRenderingDeferred()) {
|
||||
if (!C.decodeHistoryMapRenderingDeferred()) {
|
||||
ensureVdesMarker(key, existing);
|
||||
setRetainedMapMarkerVisible(existing.marker, true);
|
||||
} else {
|
||||
T.markDecodeMapSyncPending();
|
||||
C.markDecodeMapSyncPending();
|
||||
}
|
||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||
existing.marker.setLatLng([msg.lat, msg.lon]);
|
||||
@@ -2334,11 +2334,11 @@
|
||||
};
|
||||
vdesMarkers.set(key, entry);
|
||||
if (!visible) return;
|
||||
if (!T.decodeHistoryMapRenderingDeferred()) {
|
||||
if (!C.decodeHistoryMapRenderingDeferred()) {
|
||||
ensureVdesMarker(key, entry);
|
||||
setRetainedMapMarkerVisible(entry.marker, true);
|
||||
} else {
|
||||
T.markDecodeMapSyncPending();
|
||||
C.markDecodeMapSyncPending();
|
||||
}
|
||||
if (aprsMap && entry.marker && !T.decodeHistoryReplayActive) {
|
||||
entry.marker.setPopupContent(popupHtml);
|
||||
@@ -2365,7 +2365,7 @@
|
||||
if (T.locationSubtitle) {
|
||||
T.locationSubtitle.textContent = `Location: ${grid} · ${label}`;
|
||||
}
|
||||
T.updateDocumentTitle(T.activeChannelRds());
|
||||
C.updateDocumentTitle(C.activeChannelRds());
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -2443,8 +2443,8 @@
|
||||
}
|
||||
|
||||
function scheduleDecodeMapMaintenance() {
|
||||
if (T.decodeHistoryMapRenderingDeferred()) {
|
||||
T.markDecodeMapSyncPending();
|
||||
if (C.decodeHistoryMapRenderingDeferred()) {
|
||||
C.markDecodeMapSyncPending();
|
||||
return;
|
||||
}
|
||||
scheduleUiFrameJob("decode-map-maintenance", () => {
|
||||
@@ -3461,7 +3461,7 @@
|
||||
}
|
||||
|
||||
// Register module API for core to call
|
||||
window.trx.map = {
|
||||
modules.map = {
|
||||
initAprsMap,
|
||||
sizeAprsMapToViewport,
|
||||
syncAprsReceiverMarker,
|
||||
|
||||
@@ -382,7 +382,7 @@ window.pruneAisHistoryView = function() {
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all AIS decode history? This cannot be undone.")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await postPath("/clear_ais_decode");
|
||||
window.resetAisHistoryView();
|
||||
|
||||
@@ -442,7 +442,7 @@ window.restoreAprsHistory = function(packets) {
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all APRS decode history? This cannot be undone.")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await postPath("/clear_aprs_decode");
|
||||
window.resetAprsHistoryView();
|
||||
|
||||
+2
-2
@@ -215,10 +215,10 @@
|
||||
});
|
||||
}
|
||||
|
||||
function resetBackgroundDecode() {
|
||||
async function resetBackgroundDecode() {
|
||||
const rigId = currentRigId;
|
||||
if (!rigId) return;
|
||||
if (!confirm("Reset background decode configuration? This cannot be undone.")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return;
|
||||
apiResetConfig(rigId)
|
||||
.then(function (saved) {
|
||||
currentConfig = saved;
|
||||
|
||||
@@ -331,8 +331,13 @@ async function bmSave(e) {
|
||||
const comment = document.getElementById("bm-comment").value.trim();
|
||||
const decoders = bmReadDecoders();
|
||||
|
||||
const formError = document.getElementById("bm-form-error");
|
||||
if (formError) formError.textContent = "";
|
||||
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
||||
alert("Name, Frequency, and Mode are required.");
|
||||
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
|
||||
const invalid = !name ? document.getElementById("bm-name")
|
||||
: !Number.isFinite(freq_hz) ? document.getElementById("bm-freq") : document.getElementById("bm-mode");
|
||||
invalid?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -373,12 +378,13 @@ async function bmSave(e) {
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to save bookmark:", err);
|
||||
alert("Failed to save bookmark: " + err.message);
|
||||
if (formError) formError.textContent = "Failed to save bookmark: " + err.message;
|
||||
window.trxUi?.notify("Bookmark could not be saved", { kind: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
async function bmDelete(id) {
|
||||
if (!confirm("Delete this bookmark?")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
|
||||
const bm = bmList.find((b) => b.id === id);
|
||||
const scope = bm ? bm.scope : undefined;
|
||||
try {
|
||||
@@ -389,7 +395,7 @@ async function bmDelete(id) {
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete bookmark:", err);
|
||||
alert("Failed to delete bookmark: " + err.message);
|
||||
window.trxUi?.notify("Failed to delete bookmark: " + err.message, { kind: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,7 +561,12 @@ async function bmMoveSelected() {
|
||||
const target = document.getElementById("bm-move-target")?.value;
|
||||
if (!target) return;
|
||||
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
|
||||
if (!confirm(`Move ${ids.length} bookmark${ids.length > 1 ? "s" : ""} to "${targetLabel}"?`)) return;
|
||||
if (!await window.trxUi.confirm({
|
||||
title: "Move selected bookmarks?",
|
||||
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
|
||||
confirmLabel: "Move",
|
||||
danger: false,
|
||||
})) return;
|
||||
try {
|
||||
// Group selected IDs by their owning scope (skip if already in target).
|
||||
const byScope = {};
|
||||
@@ -577,7 +588,7 @@ async function bmMoveSelected() {
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to move bookmarks:", err);
|
||||
alert("Failed to move bookmarks: " + err.message);
|
||||
window.trxUi?.notify("Failed to move bookmarks: " + err.message, { kind: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,7 +609,11 @@ function bmSyncSelectAllCheckbox() {
|
||||
async function bmDeleteSelected() {
|
||||
const ids = Array.from(bmSelected);
|
||||
if (ids.length === 0) return;
|
||||
if (!confirm(`Delete ${ids.length} selected bookmark${ids.length > 1 ? "s" : ""}?`)) return;
|
||||
if (!await window.trxUi.confirm({
|
||||
title: "Delete selected bookmarks?",
|
||||
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
|
||||
confirmLabel: "Delete",
|
||||
})) return;
|
||||
try {
|
||||
// Group selected IDs by their owning scope.
|
||||
const byScope = {};
|
||||
@@ -619,7 +634,7 @@ async function bmDeleteSelected() {
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete bookmarks:", err);
|
||||
alert("Failed to delete bookmarks: " + err.message);
|
||||
window.trxUi?.notify("Failed to delete bookmarks: " + err.message, { kind: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -364,7 +364,7 @@ window.resetCwHistoryView = function() {
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all CW decode history? This cannot be undone.")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await postPath("/clear_cw_decode");
|
||||
window.resetCwHistoryView();
|
||||
|
||||
@@ -190,7 +190,7 @@ ft2DecodeToggleBtn?.addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all FT2 decode history? This cannot be undone.")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Clear FT2 history?", message: "All stored FT2 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await postPath("/clear_ft2_decode");
|
||||
window.resetFt2HistoryView();
|
||||
|
||||
@@ -190,7 +190,7 @@ ft4DecodeToggleBtn?.addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all FT4 decode history? This cannot be undone.")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Clear FT4 history?", message: "All stored FT4 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await postPath("/clear_ft4_decode");
|
||||
window.resetFt4HistoryView();
|
||||
|
||||
@@ -462,7 +462,7 @@ ft8DecodeToggleBtn?.addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all FT8 decode history? This cannot be undone.")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Clear FT8 history?", message: "All stored FT8 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await postPath("/clear_ft8_decode");
|
||||
window.resetFt8HistoryView();
|
||||
|
||||
@@ -388,7 +388,7 @@ hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all HF APRS decode history? This cannot be undone.")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await postPath("/clear_hf_aprs_decode");
|
||||
window.resetHfAprsHistoryView();
|
||||
|
||||
@@ -250,9 +250,9 @@
|
||||
var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
|
||||
var bmId = dom.bookmark ? dom.bookmark.value : "";
|
||||
|
||||
if (!satellite) { alert("Please enter a satellite name."); return; }
|
||||
if (isNaN(noradId) || noradId <= 0) { alert("Please enter a valid NORAD catalog number."); return; }
|
||||
if (!bmId) { alert("Please select a bookmark."); return; }
|
||||
if (!satellite) { window.trxUi?.notify("Enter a satellite name.", { kind: "error" }); document.getElementById("scheduler-sat-name")?.focus(); return; }
|
||||
if (isNaN(noradId) || noradId <= 0) { window.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" }); document.getElementById("scheduler-sat-norad")?.focus(); return; }
|
||||
if (!bmId) { window.trxUi?.notify("Select a bookmark.", { kind: "error" }); document.getElementById("scheduler-sat-bookmark")?.focus(); return; }
|
||||
|
||||
var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
|
||||
var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
|
||||
|
||||
@@ -295,7 +295,7 @@ satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
|
||||
document
|
||||
.getElementById("settings-clear-sat-history")
|
||||
?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all satellite decode history? This cannot be undone.")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await postPath("/clear_lrpt_decode");
|
||||
window.resetSatHistoryView();
|
||||
|
||||
@@ -599,7 +599,7 @@
|
||||
|
||||
const bmId = bmEl.value;
|
||||
if (!bmId) {
|
||||
alert("Please select a primary bookmark.");
|
||||
window.trxUi?.notify("Select a primary bookmark before saving.", { kind: "error" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -887,7 +887,7 @@
|
||||
var recEl = tr.querySelector('[data-field="record"]');
|
||||
var exEl = tr.querySelector('[data-field="exclusive"]');
|
||||
|
||||
if (bmEl && !bmEl.value) { alert('Please select a bookmark.'); return; }
|
||||
if (bmEl && !bmEl.value) { window.trxUi?.notify("Select a bookmark before saving.", { kind: "error" }); bmEl.focus(); return; }
|
||||
|
||||
entry.start_min = hhmmToMin(startEl.value);
|
||||
entry.end_min = hhmmToMin(endEl.value);
|
||||
@@ -1236,10 +1236,10 @@
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
function resetScheduler() {
|
||||
async function resetScheduler() {
|
||||
const rig = currentRigId;
|
||||
if (!rig) return;
|
||||
if (!confirm("Reset scheduler for this rig to Disabled?")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return;
|
||||
|
||||
apiDeleteScheduler(rig)
|
||||
.then(function () {
|
||||
|
||||
@@ -317,7 +317,7 @@ window.restoreVdesHistory = function(messages) {
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all VDES decode history? This cannot be undone.")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await postPath("/clear_vdes_decode");
|
||||
window.resetVdesHistoryView();
|
||||
|
||||
@@ -270,7 +270,7 @@ wsprDecodeToggleBtn?.addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all WSPR decode history? This cannot be undone.")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await postPath("/clear_wspr_decode");
|
||||
window.resetWsprHistoryView();
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
}
|
||||
|
||||
// Register module API
|
||||
window.trx.screenshot = {
|
||||
window.trx.modules.screenshot = {
|
||||
captureSpectrumScreenshot,
|
||||
buildSpectrumSnapshotCanvas,
|
||||
saveCanvasAsPng,
|
||||
|
||||
@@ -1231,7 +1231,7 @@ small { color: var(--text-muted); }
|
||||
}
|
||||
.top-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem 0.6rem;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
@@ -1404,10 +1404,21 @@ small { color: var(--text-muted); }
|
||||
padding: 0.9rem 0.2rem 0;
|
||||
}
|
||||
.header-rig-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.header-rig-summary {
|
||||
display: block;
|
||||
max-width: 20rem;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.header-rig-switch select {
|
||||
min-width: 8rem;
|
||||
height: 2rem;
|
||||
@@ -1417,6 +1428,7 @@ small { color: var(--text-muted); }
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
align-self: start;
|
||||
}
|
||||
.header-rig-switch button {
|
||||
height: 2rem;
|
||||
@@ -2839,6 +2851,268 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Shared interaction primitives ───────────────────────────────────── */
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
.toast-region {
|
||||
position: fixed;
|
||||
z-index: 120;
|
||||
right: var(--space-4);
|
||||
bottom: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
width: min(28rem, calc(100vw - 2rem));
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
--toast-accent: var(--accent-yellow);
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1.25rem minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 3rem;
|
||||
padding: 0.7rem 0.75rem;
|
||||
border: 1px solid color-mix(in srgb, var(--toast-accent) 35%, var(--border-light));
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--card-bg) 96%, var(--toast-accent) 4%);
|
||||
color: var(--text);
|
||||
box-shadow: 0 14px 38px color-mix(in srgb, #000 38%, transparent), inset 3px 0 0 var(--toast-accent);
|
||||
opacity: 0;
|
||||
transform: translateY(0.65rem) scale(0.98);
|
||||
transition: opacity var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.toast::before {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--toast-accent) 18%, transparent);
|
||||
color: var(--toast-accent);
|
||||
content: "i";
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
.toast > span { min-width: 0; line-height: 1.35; }
|
||||
.toast-visible { opacity: 1; transform: translateY(0); }
|
||||
.toast-success { --toast-accent: var(--accent-green); }
|
||||
.toast-success::before { content: "✓"; }
|
||||
.toast-error { --toast-accent: var(--accent-red); }
|
||||
.toast-error::before { content: "!"; }
|
||||
.toast button {
|
||||
width: auto;
|
||||
min-height: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 var(--space-3);
|
||||
border-color: color-mix(in srgb, var(--toast-accent) 55%, var(--btn-border));
|
||||
color: var(--toast-accent);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
.ui-dialog {
|
||||
max-width: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
box-shadow: 0 24px 70px color-mix(in srgb, #000 55%, transparent);
|
||||
}
|
||||
.ui-dialog::backdrop { background: rgba(3, 8, 18, 0.72); backdrop-filter: blur(5px); }
|
||||
.ui-dialog-card {
|
||||
width: min(29rem, calc(100vw - 2rem));
|
||||
margin: 0;
|
||||
padding: var(--space-6);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(145deg, color-mix(in srgb, var(--card-bg) 94%, var(--text) 6%), var(--card-bg));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ui-dialog-card h2 { margin: 0 0 var(--space-2); color: var(--text-heading); font-size: var(--fs-md); line-height: 1.3; }
|
||||
.ui-dialog-card p { margin: 0; color: var(--text-muted); line-height: 1.55; }
|
||||
.ui-dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-6); }
|
||||
.ui-dialog-actions button { min-width: 6rem; }
|
||||
.ui-dialog-actions .danger { background: var(--accent-red); border-color: var(--accent-red); color: #fff; font-weight: 700; }
|
||||
.ui-dialog-actions .danger:hover:not(:disabled) { background: color-mix(in srgb, var(--accent-red) 82%, #fff); border-color: color-mix(in srgb, var(--accent-red) 82%, #fff); }
|
||||
.form-error { flex: 1 1 100%; min-height: 1.2em; color: var(--accent-red); font-size: var(--fs-sm); font-weight: 600; }
|
||||
button.is-active {
|
||||
border-color: var(--accent-green);
|
||||
background: color-mix(in srgb, var(--btn-bg) 82%, var(--accent-green) 18%);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-green) 32%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent-green) 12%, transparent);
|
||||
}
|
||||
#ptt-btn.is-active { background: var(--accent-red) !important; border-color: var(--accent-red) !important; color: white !important; }
|
||||
.is-busy { cursor: progress !important; opacity: 0.68; }
|
||||
.operator-layout-picker {
|
||||
display: inline-grid;
|
||||
grid-template-columns: auto auto;
|
||||
align-items: center;
|
||||
min-height: 2rem;
|
||||
padding-left: 0.6rem;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--input-bg) 88%, transparent);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
overflow: visible;
|
||||
}
|
||||
.operator-layout-picker::before { content: "Layout"; }
|
||||
.operator-layout-picker select {
|
||||
width: auto;
|
||||
min-height: 2rem;
|
||||
max-width: 9rem;
|
||||
padding: 0 1.75rem 0 0.45rem;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
color: var(--text);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.operator-layout-picker:focus-within { border-color: var(--accent-green); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-green) 20%, transparent); }
|
||||
.operator-layout-picker select:focus-visible { outline: 0; }
|
||||
.operator-layout-description {
|
||||
grid-column: 1 / -1;
|
||||
max-width: 22rem;
|
||||
padding: 0 0.55rem 0.35rem 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.35;
|
||||
text-transform: none;
|
||||
}
|
||||
.advanced-radio-controls {
|
||||
margin-top: var(--space-3);
|
||||
border: 1px solid color-mix(in srgb, var(--border-light) 75%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--input-bg) 68%, transparent);
|
||||
overflow: clip;
|
||||
transition: border-color var(--dur-fast) var(--ease-standard), background-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.advanced-radio-controls:hover { border-color: var(--border-light); }
|
||||
.advanced-radio-controls[open] { background: color-mix(in srgb, var(--surface) 86%, transparent); }
|
||||
.advanced-radio-controls summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--text-heading);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.advanced-radio-controls summary::-webkit-details-marker { display: none; }
|
||||
.advanced-radio-controls summary::before {
|
||||
content: "›";
|
||||
color: var(--accent-text);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
transform: rotate(0deg);
|
||||
transition: transform var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
.advanced-radio-controls[open] summary::before { transform: rotate(90deg); }
|
||||
.advanced-radio-controls[open] summary { border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); }
|
||||
.advanced-radio-body { display: grid; gap: var(--space-3); padding: var(--space-3); }
|
||||
.advanced-radio-body > * { margin: 0; }
|
||||
body[data-operator-layout="digital"] .controls-col-wfm { display: none !important; }
|
||||
body[data-operator-layout="broadcast"] #tx-power-col,
|
||||
body[data-operator-layout="broadcast"] #tx-meters,
|
||||
body[data-operator-layout="broadcast"] #tx-limit-row,
|
||||
body[data-operator-layout="broadcast"] #vfo-row,
|
||||
body[data-operator-layout="broadcast"] #sam-controls-col,
|
||||
body[data-operator-layout="broadcast"] .advanced-radio-controls {
|
||||
display: none !important;
|
||||
}
|
||||
body[data-operator-layout="broadcast"] .controls-row {
|
||||
grid-template-columns: minmax(8rem, 0.65fr) auto minmax(20rem, 2fr);
|
||||
}
|
||||
body[data-operator-layout="broadcast"] #wfm-controls-col,
|
||||
body[data-operator-layout="broadcast"] #audio-row,
|
||||
body[data-operator-layout="broadcast"] #spectrum-bw-row {
|
||||
border-color: color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
|
||||
background: color-mix(in srgb, var(--accent-green) 7%, transparent);
|
||||
}
|
||||
body[data-operator-layout="broadcast"] #wfm-controls-col {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
body[data-operator-layout="broadcast"] #audio-row {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
body[data-operator-layout="broadcast"] #spectrum-bw-row {
|
||||
padding: var(--space-2);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
body[data-operator-layout="broadcast"] #ais-bar-overlay,
|
||||
body[data-operator-layout="broadcast"] #vdes-bar-overlay,
|
||||
body[data-operator-layout="broadcast"] #ft8-bar-overlay,
|
||||
body[data-operator-layout="broadcast"] #aprs-bar-overlay,
|
||||
body[data-operator-layout="broadcast"] #hf-aprs-bar-overlay,
|
||||
body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
display: none !important;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
body[data-operator-layout="broadcast"] .controls-row { grid-template-columns: 1fr auto; }
|
||||
body[data-operator-layout="broadcast"] #wfm-controls-col { grid-column: 1 / -1; }
|
||||
}
|
||||
.mobile-more-btn, .mobile-more-menu, .decoder-tab-select { display: none; }
|
||||
.mobile-more-menu {
|
||||
position: fixed;
|
||||
right: max(0.75rem, env(safe-area-inset-right));
|
||||
bottom: calc(5.4rem + env(safe-area-inset-bottom));
|
||||
z-index: 80;
|
||||
min-width: 12rem;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--card-bg) 97%, transparent);
|
||||
box-shadow: 0 18px 45px color-mix(in srgb, #000 42%, transparent);
|
||||
}
|
||||
.mobile-more-menu.is-open { display: grid; gap: 0.2rem; animation: trx-menu-in var(--dur-base) var(--ease-out); }
|
||||
.mobile-more-menu button {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
height: 2.65rem;
|
||||
padding: 0 var(--space-3);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.mobile-more-menu button:hover { background: var(--btn-hover-bg); border-color: transparent; }
|
||||
.decoder-state-dot {
|
||||
display: inline-block;
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
margin-left: 0.4rem;
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 70%, transparent);
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--text-muted) 60%, transparent);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.decoder-state-dot[data-state="active"] { background: var(--accent-green); box-shadow: 0 0 0.35rem color-mix(in srgb, var(--accent-green) 65%, transparent); }
|
||||
.decoder-state-dot[data-state="error"] { background: var(--accent-red); }
|
||||
@keyframes trx-menu-in { from { opacity: 0; transform: translateY(0.4rem) scale(0.98); } to { opacity: 1; transform: none; } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
/* ── Scrollbars ───────────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
@@ -2953,7 +3227,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
||||
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.25rem;
|
||||
padding: 0.38rem;
|
||||
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
|
||||
@@ -2993,12 +3267,38 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
||||
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
|
||||
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
|
||||
.tab[data-tab="statistics"] .tab-label { font-size: 0.6rem; }
|
||||
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
|
||||
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
|
||||
.mobile-more-btn { display: flex; }
|
||||
.mobile-more-btn[aria-expanded="true"] {
|
||||
color: var(--accent-text);
|
||||
background: color-mix(in srgb, var(--accent-green) 10%, transparent);
|
||||
}
|
||||
.tab .tab-label, .tab[data-tab] .tab-label { font-size: 0.75rem; }
|
||||
.decoder-tab-select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 2.8rem;
|
||||
margin-bottom: var(--space-3);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--input-bg) 90%, var(--card-bg));
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 5%, transparent);
|
||||
}
|
||||
#tab-digital-modes > .sub-tab-bar { display: none; }
|
||||
.toast-region { bottom: calc(5.7rem + env(safe-area-inset-bottom)); }
|
||||
.top-bar-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.operator-layout-picker { max-width: 100%; }
|
||||
.operator-layout-picker select { max-width: 10rem; }
|
||||
.header-rig-switch,
|
||||
.header-style-pick {
|
||||
flex: 1 1 12rem;
|
||||
@@ -3910,6 +4210,27 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.toast-region {
|
||||
right: max(0.65rem, env(safe-area-inset-right));
|
||||
width: calc(100vw - max(1.3rem, env(safe-area-inset-left) + env(safe-area-inset-right)));
|
||||
}
|
||||
.toast { grid-template-columns: 1.25rem minmax(0, 1fr); }
|
||||
.toast button { grid-column: 2; justify-self: start; }
|
||||
.ui-dialog-card { padding: var(--space-5); }
|
||||
.ui-dialog-actions { flex-direction: column-reverse; }
|
||||
.ui-dialog-actions button { width: 100%; }
|
||||
}
|
||||
|
||||
/* Preserve legibility after the component-specific compact-mobile rules. */
|
||||
@media (max-width: 640px) {
|
||||
button, input, select, textarea, .tab-label, .sub-tab,
|
||||
.label, .hint, .subtitle, .wfm-control-label, .wfm-intf-val,
|
||||
.ft8-header, .ft8-message, .aprs-packet, .ais-message, .vdes-message {
|
||||
font-size: max(0.75rem, 12px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme styles have been moved to themes.css */
|
||||
|
||||
|
||||
@@ -5061,3 +5382,11 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
button, input, select, textarea, .tab-label, .sub-tab,
|
||||
.label, .hint, .subtitle, .wfm-control-label, .wfm-intf-val,
|
||||
.ft8-header, .ft8-message, .aprs-packet, .ais-message, .vdes-message {
|
||||
font-size: max(0.75rem, 12px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
|
||||
class ClassList {
|
||||
constructor() { this.values = new Set(); }
|
||||
add(...names) { names.forEach(name => this.values.add(name)); }
|
||||
remove(...names) { names.forEach(name => this.values.delete(name)); }
|
||||
contains(name) { return this.values.has(name); }
|
||||
toggle(name, force) {
|
||||
const enabled = force === undefined ? !this.contains(name) : force;
|
||||
if (enabled) this.add(name); else this.remove(name);
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
|
||||
class Element {
|
||||
constructor(tagName, document) {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.ownerDocument = document;
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.attributes = new Map();
|
||||
this.classList = new ClassList();
|
||||
this.listeners = new Map();
|
||||
this.style = {};
|
||||
this.options = [];
|
||||
this.value = "";
|
||||
this.textContent = "";
|
||||
}
|
||||
set id(value) { this._id = value; if (value) this.ownerDocument.elements.set(value, this); }
|
||||
get id() { return this._id || ""; }
|
||||
set className(value) { this.classList = new ClassList(); value.split(/\s+/).filter(Boolean).forEach(name => this.classList.add(name)); }
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = value;
|
||||
if (value.includes('value="confirm"')) {
|
||||
const title = new Element("h2", this.ownerDocument); title.id = "ui-confirm-title";
|
||||
const message = new Element("p", this.ownerDocument); message.id = "ui-confirm-message";
|
||||
const confirm = new Element("button", this.ownerDocument); confirm.value = "confirm";
|
||||
this.append(title, message, confirm);
|
||||
this._confirmButton = confirm;
|
||||
}
|
||||
}
|
||||
get innerHTML() { return this._innerHTML || ""; }
|
||||
appendChild(child) { this.children.push(child); child.parentElement = this; return child; }
|
||||
append(...children) { children.forEach(child => this.appendChild(child)); }
|
||||
insertBefore(child) { return this.appendChild(child); }
|
||||
remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter(child => child !== this); }
|
||||
replaceChildren(...children) { this.children = []; this.options = []; this.append(...children); }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
getAttribute(name) { return this.attributes.get(name); }
|
||||
addEventListener(type, listener) { this.listeners.set(type, listener); }
|
||||
dispatch(type, event = {}) { this.listeners.get(type)?.({ target: this, preventDefault() {}, ...event }); }
|
||||
querySelector(selector) {
|
||||
if (selector === '[value="confirm"]') return this._confirmButton || null;
|
||||
return null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
if (selector === ".tab[data-tab]") return this.children.filter(child => child.dataset.tab);
|
||||
if (selector === ".sub-tab[data-subtab]") return this.children.filter(child => child.dataset.subtab);
|
||||
if (selector === '[role="tab"]') return this.children.filter(child => child.getAttribute("role") === "tab");
|
||||
return [];
|
||||
}
|
||||
add(option) { this.options.push(option); if (!this.value) this.value = option.value; }
|
||||
showModal() { this.open = true; }
|
||||
close(value) { this.returnValue = value; this.open = false; this.dispatch("close"); }
|
||||
focus() { this.focused = true; }
|
||||
click() { this.clicked = true; }
|
||||
}
|
||||
|
||||
class DocumentFixture {
|
||||
constructor() {
|
||||
this.readyState = "loading";
|
||||
this.elements = new Map();
|
||||
this.body = new Element("body", this);
|
||||
}
|
||||
createElement(tagName) { return new Element(tagName, this); }
|
||||
getElementById(id) { return this.elements.get(id) || null; }
|
||||
querySelector() { return null; }
|
||||
querySelectorAll() { return []; }
|
||||
addEventListener() {}
|
||||
}
|
||||
|
||||
const document = new DocumentFixture();
|
||||
const storage = new Map();
|
||||
const localStorage = {
|
||||
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
|
||||
setItem(key, value) { storage.set(key, String(value)); },
|
||||
};
|
||||
const window = { document, localStorage, addEventListener() {} };
|
||||
const context = vm.createContext({
|
||||
window, document, localStorage,
|
||||
Option: class Option { constructor(label, value) { this.label = label; this.value = value; } },
|
||||
MutationObserver: class MutationObserver { observe() {} },
|
||||
requestAnimationFrame(callback) { callback(); },
|
||||
setTimeout() { return 1; },
|
||||
clearTimeout() {},
|
||||
console,
|
||||
});
|
||||
|
||||
const source = await readFile(new URL("../ui-core.js", import.meta.url), "utf8");
|
||||
new vm.Script(source, { filename: "ui-core.js" }).runInContext(context);
|
||||
const ui = window.trxUi;
|
||||
|
||||
const toast = ui.notify("Saved", { kind: "success" });
|
||||
assert.equal(toast.getAttribute("role"), "status");
|
||||
assert.equal(toast.classList.contains("toast-success"), true);
|
||||
assert.equal(document.getElementById("toast-region").children.length, 1);
|
||||
|
||||
const confirmation = ui.confirm({ title: "Delete?", message: "Permanent", confirmLabel: "Delete" });
|
||||
const dialog = document.getElementById("ui-confirm-dialog");
|
||||
assert.equal(dialog.open, true);
|
||||
assert.equal(document.getElementById("ui-confirm-title").textContent, "Delete?");
|
||||
dialog.close("confirm");
|
||||
assert.equal(await confirmation, true);
|
||||
|
||||
const tabBar = new Element("div", document);
|
||||
const firstTab = new Element("button", document); firstTab.dataset.tab = "main"; firstTab.classList.add("active");
|
||||
const secondTab = new Element("button", document); secondTab.dataset.tab = "map";
|
||||
tabBar.append(firstTab, secondTab);
|
||||
const mainPanel = new Element("section", document); mainPanel.id = "tab-main";
|
||||
const mapPanel = new Element("section", document); mapPanel.id = "tab-map";
|
||||
ui.prepareTabList(tabBar, "primary");
|
||||
assert.equal(firstTab.getAttribute("aria-selected"), "true");
|
||||
tabBar.dispatch("keydown", { target: firstTab, key: "ArrowRight" });
|
||||
assert.equal(secondTab.focused, true);
|
||||
assert.equal(secondTab.clicked, true);
|
||||
|
||||
localStorage.setItem("trxOperatorLayout:rig-a", "broadcast");
|
||||
ui.setActiveRig("rig-a");
|
||||
assert.equal(document.body.dataset.operatorLayout, "compact");
|
||||
ui.setLayoutCapabilities({ broadcast: true });
|
||||
ui.setActiveRig("rig-a");
|
||||
assert.equal(document.body.dataset.operatorLayout, "broadcast");
|
||||
ui.setActiveRig("rig-b");
|
||||
ui.applyLayout("digital");
|
||||
assert.equal(document.body.dataset.operatorLayout, "compact");
|
||||
assert.equal(localStorage.getItem("trxOperatorLayout:rig-b"), "compact");
|
||||
|
||||
console.log("ui-core component tests passed");
|
||||
@@ -0,0 +1,359 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"use strict";
|
||||
|
||||
// Shared UI primitives. Keeping these outside app.js prevents navigation,
|
||||
// feedback, dialogs, and layout preferences from growing separate state models.
|
||||
(function initUiCore() {
|
||||
const api = window.trxUi = window.trxUi || {};
|
||||
|
||||
function ensureLiveRegions() {
|
||||
if (!document.getElementById("toast-region")) {
|
||||
const region = document.createElement("div");
|
||||
region.id = "toast-region";
|
||||
region.className = "toast-region";
|
||||
region.setAttribute("aria-live", "polite");
|
||||
region.setAttribute("aria-atomic", "false");
|
||||
document.body.appendChild(region);
|
||||
}
|
||||
if (!document.getElementById("ui-confirm-dialog")) {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.id = "ui-confirm-dialog";
|
||||
dialog.className = "ui-dialog";
|
||||
dialog.innerHTML = `
|
||||
<form method="dialog" class="ui-dialog-card">
|
||||
<h2 id="ui-confirm-title">Confirm action</h2>
|
||||
<p id="ui-confirm-message"></p>
|
||||
<div class="ui-dialog-actions">
|
||||
<button value="cancel" type="submit">Cancel</button>
|
||||
<button value="confirm" type="submit" class="danger">Confirm</button>
|
||||
</div>
|
||||
</form>`;
|
||||
document.body.appendChild(dialog);
|
||||
}
|
||||
}
|
||||
|
||||
api.notify = function notify(message, options = {}) {
|
||||
ensureLiveRegions();
|
||||
const { kind = "info", duration = kind === "error" ? 7000 : 3200, action = null } = options;
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast toast-${kind}`;
|
||||
toast.setAttribute("role", kind === "error" ? "alert" : "status");
|
||||
const text = document.createElement("span");
|
||||
text.textContent = message;
|
||||
toast.appendChild(text);
|
||||
if (action && typeof action.run === "function") {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = action.label || "Retry";
|
||||
button.addEventListener("click", () => { action.run(); toast.remove(); });
|
||||
toast.appendChild(button);
|
||||
}
|
||||
document.getElementById("toast-region").appendChild(toast);
|
||||
requestAnimationFrame(() => toast.classList.add("toast-visible"));
|
||||
if (duration > 0) setTimeout(() => toast.remove(), duration);
|
||||
return toast;
|
||||
};
|
||||
|
||||
api.confirm = function confirmAction(options = {}) {
|
||||
ensureLiveRegions();
|
||||
const dialog = document.getElementById("ui-confirm-dialog");
|
||||
document.getElementById("ui-confirm-title").textContent = options.title || "Confirm action";
|
||||
document.getElementById("ui-confirm-message").textContent = options.message || "Continue?";
|
||||
const confirmButton = dialog.querySelector('[value="confirm"]');
|
||||
confirmButton.textContent = options.confirmLabel || "Confirm";
|
||||
confirmButton.classList.toggle("danger", options.danger !== false);
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => resolve(dialog.returnValue === "confirm");
|
||||
dialog.addEventListener("close", finish, { once: true });
|
||||
dialog.showModal();
|
||||
});
|
||||
};
|
||||
|
||||
api.setButtonState = function setButtonState(button, options = {}) {
|
||||
if (!button) return;
|
||||
const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options;
|
||||
button.classList.toggle("is-active", active);
|
||||
button.classList.toggle("is-busy", busy);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
button.setAttribute("aria-busy", String(busy));
|
||||
button.disabled = disabled || busy;
|
||||
const label = active ? activeLabel : inactiveLabel;
|
||||
if (label) button.textContent = label;
|
||||
};
|
||||
|
||||
api.prepareTabList = function prepareTabList(bar, kind = "primary") {
|
||||
if (!bar) return;
|
||||
if (bar._accessibleTabsPrepared) return;
|
||||
bar._accessibleTabsPrepared = true;
|
||||
const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]";
|
||||
const buttons = Array.from(bar.querySelectorAll(selector));
|
||||
bar.setAttribute("role", "tablist");
|
||||
buttons.forEach((button, index) => {
|
||||
button.setAttribute("role", "tab");
|
||||
button.setAttribute("aria-selected", String(button.classList.contains("active")));
|
||||
button.tabIndex = button.classList.contains("active") || (!buttons.some(b => b.classList.contains("active")) && index === 0) ? 0 : -1;
|
||||
const key = button.dataset.tab || button.dataset.subtab;
|
||||
button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
||||
const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
||||
if (panel) {
|
||||
if (!button.id) button.id = `${kind}-tab-${key}`;
|
||||
panel.setAttribute("role", "tabpanel");
|
||||
panel.setAttribute("aria-labelledby", button.id);
|
||||
}
|
||||
});
|
||||
bar.addEventListener("keydown", (event) => {
|
||||
if (!buttons.includes(event.target)) return;
|
||||
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1
|
||||
: event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
|
||||
if (!direction) return;
|
||||
event.preventDefault();
|
||||
const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
|
||||
next.focus();
|
||||
next.click();
|
||||
});
|
||||
};
|
||||
|
||||
api.syncSelectedTab = function syncSelectedTab(bar, selected) {
|
||||
if (!bar) return;
|
||||
bar.querySelectorAll('[role="tab"]').forEach((tab) => {
|
||||
const active = tab === selected;
|
||||
tab.setAttribute("aria-selected", String(active));
|
||||
tab.tabIndex = active ? 0 : -1;
|
||||
});
|
||||
};
|
||||
|
||||
const layouts = {
|
||||
compact: { label: "Compact", description: "Essential tuning and audio controls", advanced: false, preferredTab: "main" },
|
||||
broadcast: { label: "Broadcast", description: "WFM, RDS, stereo and interference monitoring", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, preferredTab: "main", capability: "broadcast" },
|
||||
digital: { label: "Digital", description: "Decoder status, messages and background decoding", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, preferredTab: "digital-modes", capability: "digital" },
|
||||
full: { label: "Full controls", description: "Every control supported by the selected rig", advanced: true, preferredTab: "main" },
|
||||
};
|
||||
const layoutCapabilities = { broadcast: false, digital: false };
|
||||
let activeRigId = null;
|
||||
|
||||
function layoutStorageKey() {
|
||||
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
|
||||
}
|
||||
|
||||
function savedLayoutName() {
|
||||
return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
|
||||
}
|
||||
|
||||
function layoutAvailable(layout) {
|
||||
return !layout.capability || layoutCapabilities[layout.capability] === true;
|
||||
}
|
||||
|
||||
function unavailableLayoutMessage() {
|
||||
const unavailable = Object.values(layouts).filter(layout => !layoutAvailable(layout) && layout.unavailable);
|
||||
return unavailable.length ? `Unavailable: ${unavailable.map(layout => layout.unavailable).join("; ")}.` : "";
|
||||
}
|
||||
|
||||
function updateLayoutDescription(layout) {
|
||||
const description = document.getElementById("operator-layout-description");
|
||||
if (!description) return;
|
||||
description.textContent = [layout.description, unavailableLayoutMessage()].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function refreshLayoutOptions() {
|
||||
const select = document.getElementById("operator-layout-select");
|
||||
if (!select) return;
|
||||
const previous = select.value || document.body.dataset.operatorLayout || "compact";
|
||||
select.replaceChildren();
|
||||
Object.entries(layouts).forEach(([value, layout]) => {
|
||||
if (!layoutAvailable(layout)) return;
|
||||
select.add(new Option(layout.label, value));
|
||||
});
|
||||
const available = Array.from(select.options).some(option => option.value === previous);
|
||||
select.value = available ? previous : "compact";
|
||||
if (!available && previous !== "compact") api.applyLayout("compact", { persist: false });
|
||||
select.title = unavailableLayoutMessage();
|
||||
updateLayoutDescription(layouts[select.value] || layouts.compact);
|
||||
}
|
||||
|
||||
api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) {
|
||||
Object.keys(layoutCapabilities).forEach((name) => {
|
||||
if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]);
|
||||
});
|
||||
refreshLayoutOptions();
|
||||
const select = document.getElementById("operator-layout-select");
|
||||
const saved = savedLayoutName();
|
||||
if (select && Array.from(select.options).some(option => option.value === saved)) {
|
||||
select.value = saved;
|
||||
api.applyLayout(saved, { persist: false });
|
||||
}
|
||||
};
|
||||
|
||||
api.setActiveRig = function setActiveRig(rigId) {
|
||||
activeRigId = typeof rigId === "string" && rigId ? rigId : null;
|
||||
const saved = savedLayoutName();
|
||||
const select = document.getElementById("operator-layout-select");
|
||||
if (select) select.value = Array.from(select.options).some(option => option.value === saved) ? saved : "compact";
|
||||
api.applyLayout(select?.value || saved, { persist: false });
|
||||
};
|
||||
|
||||
api.applyLayout = function applyLayout(name, options = {}) {
|
||||
const requestedLayout = layouts[name];
|
||||
const permittedName = requestedLayout && layoutAvailable(requestedLayout) ? name : "compact";
|
||||
const layout = layouts[permittedName];
|
||||
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
|
||||
if (options.persist !== false) localStorage.setItem(layoutStorageKey(), document.body.dataset.operatorLayout);
|
||||
const details = document.getElementById("advanced-radio-controls");
|
||||
if (details) details.open = layout.advanced;
|
||||
updateLayoutDescription(layout);
|
||||
if (options.navigate && typeof window.navigateToTab === "function") {
|
||||
window.navigateToTab(layout.preferredTab);
|
||||
}
|
||||
};
|
||||
|
||||
function installLayoutControls() {
|
||||
const actions = document.querySelector(".top-bar-actions");
|
||||
if (actions && !document.getElementById("operator-layout-select")) {
|
||||
const label = document.createElement("label");
|
||||
label.className = "operator-layout-picker";
|
||||
label.innerHTML = '<span class="visually-hidden">Operator layout</span><select id="operator-layout-select" aria-label="Operator layout" aria-describedby="operator-layout-description"></select><span id="operator-layout-description" class="operator-layout-description"></span>';
|
||||
const select = label.querySelector("select");
|
||||
const savedLayout = savedLayoutName();
|
||||
actions.insertBefore(label, actions.firstChild);
|
||||
select.value = savedLayout;
|
||||
refreshLayoutOptions();
|
||||
if (savedLayout !== "broadcast" && layouts[savedLayout]) select.value = savedLayout;
|
||||
select.addEventListener("change", () => api.applyLayout(select.value, { navigate: true }));
|
||||
api.applyLayout(select.value);
|
||||
}
|
||||
|
||||
const tray = document.querySelector(".controls-tray");
|
||||
if (tray && !document.getElementById("advanced-radio-controls")) {
|
||||
const details = document.createElement("details");
|
||||
details.id = "advanced-radio-controls";
|
||||
details.className = "advanced-radio-controls";
|
||||
details.innerHTML = '<summary>Advanced radio controls</summary><div class="advanced-radio-body"></div>';
|
||||
const body = details.querySelector(".advanced-radio-body");
|
||||
["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) body.appendChild(element);
|
||||
});
|
||||
tray.appendChild(details);
|
||||
api.applyLayout(savedLayoutName(), { persist: false });
|
||||
}
|
||||
}
|
||||
|
||||
function installMobileMore() {
|
||||
const nav = document.querySelector(".tab-bar-nav");
|
||||
if (!nav || document.getElementById("mobile-more-btn")) return;
|
||||
const more = document.createElement("button");
|
||||
more.id = "mobile-more-btn";
|
||||
more.className = "tab mobile-more-btn";
|
||||
more.type = "button";
|
||||
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
|
||||
more.setAttribute("aria-haspopup", "menu");
|
||||
more.setAttribute("aria-expanded", "false");
|
||||
const menu = document.createElement("div");
|
||||
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;
|
||||
const item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.setAttribute("role", "menuitem");
|
||||
item.dataset.navigateTab = tabName;
|
||||
item.textContent = source.textContent.trim();
|
||||
item.addEventListener("click", () => {
|
||||
if (typeof window.navigateToTab === "function") window.navigateToTab(tabName);
|
||||
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);
|
||||
}
|
||||
|
||||
function installDecoderPicker() {
|
||||
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
|
||||
if (!bar || document.getElementById("decoder-tab-select")) return;
|
||||
const select = document.createElement("select");
|
||||
select.id = "decoder-tab-select";
|
||||
select.className = "decoder-tab-select";
|
||||
select.setAttribute("aria-label", "Decoder view");
|
||||
const groups = [
|
||||
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
|
||||
];
|
||||
groups.forEach(([label, ids]) => {
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = label;
|
||||
ids.forEach((id) => {
|
||||
const button = bar.querySelector(`[data-subtab="${id}"]`);
|
||||
if (button) group.appendChild(new Option(button.textContent.trim(), id));
|
||||
});
|
||||
select.appendChild(group);
|
||||
});
|
||||
select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click());
|
||||
bar.insertAdjacentElement("afterend", select);
|
||||
}
|
||||
|
||||
function installDecoderBadges() {
|
||||
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
|
||||
if (!bar) return;
|
||||
bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => {
|
||||
const id = button.dataset.subtab;
|
||||
if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "decoder-state-dot";
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
button.appendChild(dot);
|
||||
const status = document.getElementById(`${id}-status`);
|
||||
if (!status) return;
|
||||
const sync = () => {
|
||||
const value = status.textContent.toLowerCase();
|
||||
const state = /receiv|decod|connected|listening/.test(value) ? "active"
|
||||
: /error|fail|disconnected/.test(value) ? "error" : "idle";
|
||||
dot.dataset.state = state;
|
||||
button.title = `${button.childNodes[0]?.textContent?.trim() || id}: ${status.textContent.trim()}`;
|
||||
};
|
||||
new MutationObserver(sync).observe(status, { childList: true, characterData: true, subtree: true });
|
||||
sync();
|
||||
});
|
||||
}
|
||||
|
||||
api.init = function init() {
|
||||
ensureLiveRegions();
|
||||
installLayoutControls();
|
||||
installMobileMore();
|
||||
installDecoderPicker();
|
||||
installDecoderBadges();
|
||||
api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary");
|
||||
document.querySelectorAll(".sub-tab-bar").forEach(bar => api.prepareTabList(bar, "secondary"));
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const message = event.reason?.message || "An operation failed unexpectedly";
|
||||
api.notify(message, { kind: "error" });
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", api.init, { once: true });
|
||||
else api.init();
|
||||
})();
|
||||
+232
File diff suppressed because one or more lines are too long
@@ -29,6 +29,7 @@ define_gz_cache!(gz_index_html, status::index_html(), "index.html");
|
||||
define_gz_cache!(gz_style_css, status::STYLE_CSS, "style.css");
|
||||
define_gz_cache!(gz_themes_css, status::THEMES_CSS, "themes.css");
|
||||
define_gz_cache!(gz_app_js, status::APP_JS, "app.js");
|
||||
define_gz_cache!(gz_ui_core_js, status::UI_CORE_JS, "ui-core.js");
|
||||
define_gz_cache!(gz_map_core_js, status::MAP_CORE_JS, "map-core.js");
|
||||
define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js");
|
||||
define_gz_cache!(
|
||||
@@ -75,6 +76,13 @@ define_gz_cache!(gz_bandplan_json, status::BANDPLAN_JSON, "bandplan.json");
|
||||
// Vendored DSEG14 Classic font
|
||||
// (binary woff2 — served directly, not through gz_cache)
|
||||
|
||||
// Vendored opus-decoder 0.7.11
|
||||
define_gz_cache!(
|
||||
gz_opus_decoder_js,
|
||||
status::OPUS_DECODER_JS,
|
||||
"opus-decoder-0.7.11.min.js"
|
||||
);
|
||||
|
||||
// Vendored Leaflet 1.9.4
|
||||
define_gz_cache!(gz_leaflet_js, status::LEAFLET_JS, "leaflet.js");
|
||||
define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");
|
||||
@@ -173,6 +181,12 @@ pub(crate) async fn app_js(req: HttpRequest) -> impl Responder {
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/ui-core.js")]
|
||||
pub(crate) async fn ui_core_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_ui_core_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/map-core.js")]
|
||||
pub(crate) async fn map_core_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_map_core_js();
|
||||
@@ -341,6 +355,12 @@ pub(crate) async fn dseg14_classic_woff2() -> impl Responder {
|
||||
.body(status::DSEG14_CLASSIC_WOFF2)
|
||||
}
|
||||
|
||||
#[get("/vendor/opus-decoder-0.7.11.min.js")]
|
||||
pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_opus_decoder_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vendored Leaflet 1.9.4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -643,6 +643,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(assets::style_css)
|
||||
.service(assets::themes_css)
|
||||
.service(assets::app_js)
|
||||
.service(assets::ui_core_js)
|
||||
.service(assets::map_core_js)
|
||||
.service(assets::screenshot_js)
|
||||
.service(assets::decode_history_worker_js)
|
||||
@@ -668,6 +669,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(assets::bandplan_json)
|
||||
// Vendored DSEG14 Classic font
|
||||
.service(assets::dseg14_classic_woff2)
|
||||
// Vendored opus-decoder 0.7.11
|
||||
.service(assets::opus_decoder_js)
|
||||
// Vendored Leaflet 1.9.4
|
||||
.service(assets::leaflet_js)
|
||||
.service(assets::leaflet_css)
|
||||
|
||||
@@ -388,6 +388,9 @@ struct RigListItem {
|
||||
display_name: Option<String>,
|
||||
manufacturer: String,
|
||||
model: String,
|
||||
supported_modes: Vec<trx_core::RigMode>,
|
||||
tx: bool,
|
||||
filter_controls: bool,
|
||||
initialized: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
latitude: Option<f64>,
|
||||
@@ -422,6 +425,9 @@ fn map_rig_entry(entry: &RemoteRigEntry) -> RigListItem {
|
||||
display_name: entry.display_name.clone(),
|
||||
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,
|
||||
|
||||
@@ -12,6 +12,7 @@ const INDEX_HTML: &str = include_str!("../assets/web/index.html");
|
||||
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
|
||||
pub const THEMES_CSS: &str = include_str!("../assets/web/themes.css");
|
||||
pub const APP_JS: &str = include_str!("../assets/web/app.js");
|
||||
pub const UI_CORE_JS: &str = include_str!("../assets/web/ui-core.js");
|
||||
pub const MAP_CORE_JS: &str = include_str!("../assets/web/map-core.js");
|
||||
pub const SCREENSHOT_JS: &str = include_str!("../assets/web/screenshot.js");
|
||||
pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/decode-history-worker.js");
|
||||
@@ -40,6 +41,9 @@ pub const BANDPLAN_JSON: &str = include_str!("../assets/web/bandplan.json");
|
||||
pub const DSEG14_CLASSIC_WOFF2: &[u8] =
|
||||
include_bytes!("../assets/web/vendor/dseg14-classic-latin-400-normal.woff2");
|
||||
|
||||
// Vendored opus-decoder 0.7.11 browser build (WebAssembly embedded in JS)
|
||||
pub const OPUS_DECODER_JS: &str = include_str!("../assets/web/vendor/opus-decoder-0.7.11.min.js");
|
||||
|
||||
// Vendored Leaflet 1.9.4
|
||||
pub const LEAFLET_JS: &str = include_str!("../assets/web/vendor/leaflet.js");
|
||||
pub const LEAFLET_CSS: &str = include_str!("../assets/web/vendor/leaflet.css");
|
||||
@@ -68,3 +72,21 @@ pub fn index_html() -> &'static str {
|
||||
.replace("{client_build_date}", CLIENT_BUILD_DATE)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_ui_core_is_embedded_and_loaded_before_app() {
|
||||
let html = index_html();
|
||||
let ui_core = html.find("/ui-core.js").expect("ui-core script is loaded");
|
||||
let app = html.find("/app.js").expect("application script is loaded");
|
||||
|
||||
assert!(
|
||||
ui_core < app,
|
||||
"UI primitives must load before application code"
|
||||
);
|
||||
assert!(UI_CORE_JS.contains("window.trxUi"));
|
||||
}
|
||||
}
|
||||
|
||||
+11
-530
@@ -6,13 +6,14 @@
|
||||
|
||||
#[cfg(feature = "ft2")]
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::collections::HashSet;
|
||||
#[cfg(test)]
|
||||
use std::collections::VecDeque;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::Engine as _;
|
||||
use bytes::Bytes;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
@@ -34,10 +35,9 @@ use trx_core::audio::{
|
||||
AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB,
|
||||
AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
||||
};
|
||||
use trx_core::decode::{
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, LrptImage, LrptProgress,
|
||||
VdesMessage, WefaxMessage, WsprMessage,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use trx_core::decode::{AisMessage, AprsPacket, CwEvent};
|
||||
use trx_core::decode::{DecodedMessage, Ft8Message, LrptImage, LrptProgress, WsprMessage};
|
||||
use trx_core::rig::state::{RigMode, RigState};
|
||||
use trx_core::vchan::SharedVChanManager;
|
||||
use trx_cw::CwDecoder;
|
||||
@@ -47,21 +47,11 @@ use trx_wspr::WsprDecoder;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::AudioConfig;
|
||||
use crate::history_policy::{current_timestamp_ms, lock_or_recover};
|
||||
#[cfg(test)]
|
||||
use crate::history_policy::{enforce_capacity, prune_by_age, MAX_HISTORY_ENTRIES};
|
||||
use trx_decode_log::DecoderLoggers;
|
||||
|
||||
const APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const HF_APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const AIS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const VDES_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const CW_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const LRPT_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const WEFAX_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
/// Maximum entries per decoder history queue. Prevents unbounded memory growth
|
||||
/// on busy channels (e.g. AIS near a port). Oldest entries are evicted when
|
||||
/// the limit is reached, independent of the time-based pruning.
|
||||
const MAX_HISTORY_ENTRIES: usize = 10_000;
|
||||
/// Silence timeout before auto-finalising an LRPT pass (30 s without new MCUs).
|
||||
const LRPT_PASS_SILENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const FT8_SAMPLE_RATE: u32 = 12_000;
|
||||
@@ -75,13 +65,6 @@ const DECODE_AUDIO_GATE_RMS: f32 = 2.5e-4;
|
||||
const AUDIO_STREAM_ERROR_LOG_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const AUDIO_STREAM_RECOVERY_DELAY: Duration = Duration::from_secs(1);
|
||||
|
||||
fn current_timestamp_ms() -> i64 {
|
||||
match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||
Ok(dur) => dur.as_millis() as i64,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ft2")]
|
||||
fn retain_ft2_window(buf: &mut Vec<f32>) {
|
||||
if buf.len() > FT2_ASYNC_BUFFER_SAMPLES {
|
||||
@@ -360,509 +343,7 @@ fn classify_stream_error(err: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-rig decoder history store.
|
||||
///
|
||||
/// Replaces the previous process-wide `OnceLock` statics so that each rig
|
||||
/// instance can maintain its own independent history. Pass an
|
||||
/// `Arc<DecoderHistories>` into every decoder task and into the audio listener.
|
||||
pub struct DecoderHistories {
|
||||
pub ais: Mutex<VecDeque<(Instant, AisMessage)>>,
|
||||
pub vdes: Mutex<VecDeque<(Instant, VdesMessage)>>,
|
||||
pub aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
|
||||
pub hf_aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
|
||||
pub cw: Mutex<VecDeque<(Instant, CwEvent)>>,
|
||||
pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||
pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||
pub ft2: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
||||
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
|
||||
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
|
||||
/// Approximate total entry count across all decoders, maintained
|
||||
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
|
||||
total_count: AtomicUsize,
|
||||
}
|
||||
|
||||
/// Acquire a mutex, recovering from poisoning with a warning log.
|
||||
fn lock_or_recover<'a, T>(mutex: &'a Mutex<T>, label: &str) -> std::sync::MutexGuard<'a, T> {
|
||||
mutex.lock().unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
"Mutex for {} was poisoned (prior panic); recovering with potentially inconsistent data",
|
||||
label
|
||||
);
|
||||
e.into_inner()
|
||||
})
|
||||
}
|
||||
|
||||
/// Enforce capacity limit on a history deque by evicting oldest entries.
|
||||
fn enforce_capacity<T>(deque: &mut VecDeque<T>, max: usize) {
|
||||
while deque.len() > max {
|
||||
deque.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop entries older than `retention` from the front of a time-tagged deque.
|
||||
/// Uses `checked_sub` so an early `now` (before `retention` elapses since the
|
||||
/// monotonic clock origin) is treated as "no entries are old enough to prune"
|
||||
/// rather than panicking.
|
||||
fn prune_by_age<T>(deque: &mut VecDeque<(Instant, T)>, retention: Duration, now: Instant) {
|
||||
let Some(cutoff) = now.checked_sub(retention) else {
|
||||
return;
|
||||
};
|
||||
while let Some((ts, _)) = deque.front() {
|
||||
if *ts < cutoff {
|
||||
deque.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DecoderHistories {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
ais: Mutex::new(VecDeque::new()),
|
||||
vdes: Mutex::new(VecDeque::new()),
|
||||
aprs: Mutex::new(VecDeque::new()),
|
||||
hf_aprs: Mutex::new(VecDeque::new()),
|
||||
cw: Mutex::new(VecDeque::new()),
|
||||
ft8: Mutex::new(VecDeque::new()),
|
||||
ft4: Mutex::new(VecDeque::new()),
|
||||
ft2: Mutex::new(VecDeque::new()),
|
||||
wspr: Mutex::new(VecDeque::new()),
|
||||
lrpt: Mutex::new(VecDeque::new()),
|
||||
wefax: Mutex::new(VecDeque::new()),
|
||||
total_count: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Adjust the atomic total count after a record/prune/clear operation.
|
||||
///
|
||||
/// Uses a CAS loop for decrements to prevent underflow wrapping the
|
||||
/// counter to `usize::MAX` (which would cause a capacity-overflow panic
|
||||
/// when pre-allocating the history replay blob).
|
||||
fn adjust_total_count(&self, old_len: usize, new_len: usize) {
|
||||
if new_len > old_len {
|
||||
self.total_count
|
||||
.fetch_add(new_len - old_len, Ordering::Relaxed);
|
||||
} else if old_len > new_len {
|
||||
let delta = old_len - new_len;
|
||||
let mut current = self.total_count.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let next = current.saturating_sub(delta);
|
||||
match self.total_count.compare_exchange_weak(
|
||||
current,
|
||||
next,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => break,
|
||||
Err(actual) => current = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- AIS ---
|
||||
|
||||
fn prune_ais(history: &mut VecDeque<(Instant, AisMessage)>) {
|
||||
prune_by_age(history, AIS_HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_ais_message(&self, mut msg: AisMessage) {
|
||||
if msg.ts_ms.is_none() {
|
||||
msg.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let mut h = lock_or_recover(&self.ais, "ais_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_ais(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_ais_history(&self) -> Vec<AisMessage> {
|
||||
let mut h = lock_or_recover(&self.ais, "ais_history");
|
||||
let before = h.len();
|
||||
Self::prune_ais(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter().map(|(_, msg)| msg.clone()).collect()
|
||||
}
|
||||
|
||||
// --- VDES ---
|
||||
|
||||
fn prune_vdes(history: &mut VecDeque<(Instant, VdesMessage)>) {
|
||||
prune_by_age(history, VDES_HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_vdes_message(&self, mut msg: VdesMessage) {
|
||||
if msg.ts_ms.is_none() {
|
||||
msg.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let mut h = lock_or_recover(&self.vdes, "vdes_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_vdes(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_vdes_history(&self) -> Vec<VdesMessage> {
|
||||
let mut h = lock_or_recover(&self.vdes, "vdes_history");
|
||||
let before = h.len();
|
||||
Self::prune_vdes(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter().map(|(_, msg)| msg.clone()).collect()
|
||||
}
|
||||
|
||||
// --- APRS ---
|
||||
|
||||
fn prune_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
|
||||
prune_by_age(history, APRS_HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_aprs_packet(&self, mut pkt: AprsPacket) {
|
||||
if !pkt.crc_ok {
|
||||
return;
|
||||
}
|
||||
if pkt.ts_ms.is_none() {
|
||||
pkt.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let mut h = lock_or_recover(&self.aprs, "aprs_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), pkt));
|
||||
Self::prune_aprs(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_aprs_history(&self) -> Vec<AprsPacket> {
|
||||
let mut h = lock_or_recover(&self.aprs, "aprs_history");
|
||||
let before = h.len();
|
||||
Self::prune_aprs(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_aprs_history(&self) {
|
||||
let mut h = lock_or_recover(&self.aprs, "aprs_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- HF APRS ---
|
||||
|
||||
fn prune_hf_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
|
||||
prune_by_age(history, HF_APRS_HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_hf_aprs_packet(&self, mut pkt: AprsPacket) {
|
||||
if !pkt.crc_ok {
|
||||
return;
|
||||
}
|
||||
if pkt.ts_ms.is_none() {
|
||||
pkt.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), pkt));
|
||||
Self::prune_hf_aprs(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_hf_aprs_history(&self) -> Vec<AprsPacket> {
|
||||
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
|
||||
let before = h.len();
|
||||
Self::prune_hf_aprs(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_hf_aprs_history(&self) {
|
||||
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- CW ---
|
||||
|
||||
fn prune_cw(history: &mut VecDeque<(Instant, CwEvent)>) {
|
||||
prune_by_age(history, CW_HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_cw_event(&self, evt: CwEvent) {
|
||||
let mut h = lock_or_recover(&self.cw, "cw_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), evt));
|
||||
Self::prune_cw(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_cw_history(&self) -> Vec<CwEvent> {
|
||||
let mut h = lock_or_recover(&self.cw, "cw_history");
|
||||
let before = h.len();
|
||||
Self::prune_cw(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, evt): &(Instant, CwEvent)| evt.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_cw_history(&self) {
|
||||
let mut h = lock_or_recover(&self.cw, "cw_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- FT8 ---
|
||||
|
||||
fn prune_ft8(history: &mut VecDeque<(Instant, Ft8Message)>) {
|
||||
prune_by_age(history, FT8_HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_ft8_message(&self, msg: Ft8Message) {
|
||||
let mut h = lock_or_recover(&self.ft8, "ft8_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_ft8(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_ft8_history(&self) -> Vec<Ft8Message> {
|
||||
let mut h = lock_or_recover(&self.ft8, "ft8_history");
|
||||
let before = h.len();
|
||||
Self::prune_ft8(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_ft8_history(&self) {
|
||||
let mut h = lock_or_recover(&self.ft8, "ft8_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- FT4 ---
|
||||
|
||||
fn prune_ft4(history: &mut VecDeque<(Instant, Ft8Message)>) {
|
||||
prune_by_age(history, FT8_HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_ft4_message(&self, msg: Ft8Message) {
|
||||
let mut h = lock_or_recover(&self.ft4, "ft4_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_ft4(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_ft4_history(&self) -> Vec<Ft8Message> {
|
||||
let mut h = lock_or_recover(&self.ft4, "ft4_history");
|
||||
let before = h.len();
|
||||
Self::prune_ft4(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_ft4_history(&self) {
|
||||
let mut h = lock_or_recover(&self.ft4, "ft4_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- FT2 ---
|
||||
|
||||
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
|
||||
fn prune_ft2(history: &mut VecDeque<(Instant, Ft8Message)>) {
|
||||
prune_by_age(history, FT8_HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
|
||||
pub fn record_ft2_message(&self, msg: Ft8Message) {
|
||||
let mut h = lock_or_recover(&self.ft2, "ft2_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_ft2(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
|
||||
pub fn snapshot_ft2_history(&self) -> Vec<Ft8Message> {
|
||||
let mut h = lock_or_recover(&self.ft2, "ft2_history");
|
||||
let before = h.len();
|
||||
Self::prune_ft2(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_ft2_history(&self) {
|
||||
let mut h = lock_or_recover(&self.ft2, "ft2_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- WSPR ---
|
||||
|
||||
fn prune_wspr(history: &mut VecDeque<(Instant, WsprMessage)>) {
|
||||
prune_by_age(history, WSPR_HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_wspr_message(&self, msg: WsprMessage) {
|
||||
let mut h = lock_or_recover(&self.wspr, "wspr_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_wspr(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_wspr_history(&self) -> Vec<WsprMessage> {
|
||||
let mut h = lock_or_recover(&self.wspr, "wspr_history");
|
||||
let before = h.len();
|
||||
Self::prune_wspr(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, msg): &(Instant, WsprMessage)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_wspr_history(&self) {
|
||||
let mut h = lock_or_recover(&self.wspr, "wspr_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- LRPT ---
|
||||
|
||||
fn prune_lrpt(history: &mut VecDeque<(Instant, LrptImage)>) {
|
||||
prune_by_age(history, LRPT_HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_lrpt_image(&self, mut img: LrptImage) {
|
||||
if img.ts_ms.is_none() {
|
||||
img.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), img));
|
||||
Self::prune_lrpt(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_lrpt_history(&self) -> Vec<LrptImage> {
|
||||
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
|
||||
let before = h.len();
|
||||
Self::prune_lrpt(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, img): &(Instant, LrptImage)| img.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_lrpt_history(&self) {
|
||||
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- WEFAX ---
|
||||
|
||||
fn prune_wefax(history: &mut VecDeque<(Instant, WefaxMessage)>) {
|
||||
prune_by_age(history, WEFAX_HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_wefax_message(&self, mut msg: WefaxMessage) {
|
||||
if msg.ts_ms.is_none() {
|
||||
msg.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
// Strip bulk PNG data before storing in memory/persistence.
|
||||
msg.png_data = None;
|
||||
let mut h = lock_or_recover(&self.wefax, "wefax_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_wefax(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_wefax_history(&self) -> Vec<WefaxMessage> {
|
||||
let mut h = lock_or_recover(&self.wefax, "wefax_history");
|
||||
let before = h.len();
|
||||
Self::prune_wefax(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, msg)| {
|
||||
let mut m = msg.clone();
|
||||
// Re-read PNG from disk so remote clients can save a local copy.
|
||||
if m.png_data.is_none() {
|
||||
if let Some(ref path) = m.path {
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
m.png_data =
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_wefax_history(&self) {
|
||||
let mut h = lock_or_recover(&self.wefax, "wefax_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
/// Returns a quick (non-pruning) estimate of the total number of history
|
||||
/// entries across all decoders, used for pre-allocating the replay blob.
|
||||
///
|
||||
/// Uses an `AtomicUsize` counter maintained by record/prune/clear methods,
|
||||
/// avoiding 9 separate mutex acquisitions.
|
||||
pub fn estimated_total_count(&self) -> usize {
|
||||
self.total_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Rebuild the aggregate count after bulk restoration bypasses the normal
|
||||
/// record methods.
|
||||
pub(crate) fn recalculate_total_count(&self) {
|
||||
let total = lock_or_recover(&self.ais, "ais_history").len()
|
||||
+ lock_or_recover(&self.vdes, "vdes_history").len()
|
||||
+ lock_or_recover(&self.aprs, "aprs_history").len()
|
||||
+ lock_or_recover(&self.hf_aprs, "hf_aprs_history").len()
|
||||
+ lock_or_recover(&self.cw, "cw_history").len()
|
||||
+ lock_or_recover(&self.ft8, "ft8_history").len()
|
||||
+ lock_or_recover(&self.ft4, "ft4_history").len()
|
||||
+ lock_or_recover(&self.ft2, "ft2_history").len()
|
||||
+ lock_or_recover(&self.wspr, "wspr_history").len()
|
||||
+ lock_or_recover(&self.lrpt, "lrpt_history").len()
|
||||
+ lock_or_recover(&self.wefax, "wefax_history").len();
|
||||
self.total_count.store(total, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub use crate::decoder_history::DecoderHistories;
|
||||
|
||||
/// Spawn the audio capture thread.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Per-rig storage and lifecycle operations for decoded-message histories.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use base64::Engine as _;
|
||||
use trx_core::decode::{
|
||||
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, VdesMessage, WefaxMessage, WsprMessage,
|
||||
};
|
||||
|
||||
use crate::history_policy::{
|
||||
current_timestamp_ms, enforce_capacity, lock_or_recover, prune_by_age, HISTORY_RETENTION,
|
||||
MAX_HISTORY_ENTRIES,
|
||||
};
|
||||
|
||||
/// Per-rig decoder history store.
|
||||
///
|
||||
/// Replaces the previous process-wide `OnceLock` statics so that each rig
|
||||
/// instance can maintain its own independent history. Pass an
|
||||
/// `Arc<DecoderHistories>` into every decoder task and into the audio listener.
|
||||
pub struct DecoderHistories {
|
||||
pub ais: Mutex<VecDeque<(Instant, AisMessage)>>,
|
||||
pub vdes: Mutex<VecDeque<(Instant, VdesMessage)>>,
|
||||
pub aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
|
||||
pub hf_aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
|
||||
pub cw: Mutex<VecDeque<(Instant, CwEvent)>>,
|
||||
pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||
pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||
pub ft2: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
||||
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
|
||||
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
|
||||
/// Approximate total entry count across all decoders, maintained
|
||||
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
|
||||
total_count: AtomicUsize,
|
||||
}
|
||||
|
||||
impl DecoderHistories {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
ais: Mutex::new(VecDeque::new()),
|
||||
vdes: Mutex::new(VecDeque::new()),
|
||||
aprs: Mutex::new(VecDeque::new()),
|
||||
hf_aprs: Mutex::new(VecDeque::new()),
|
||||
cw: Mutex::new(VecDeque::new()),
|
||||
ft8: Mutex::new(VecDeque::new()),
|
||||
ft4: Mutex::new(VecDeque::new()),
|
||||
ft2: Mutex::new(VecDeque::new()),
|
||||
wspr: Mutex::new(VecDeque::new()),
|
||||
lrpt: Mutex::new(VecDeque::new()),
|
||||
wefax: Mutex::new(VecDeque::new()),
|
||||
total_count: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Adjust the atomic total count after a record/prune/clear operation.
|
||||
///
|
||||
/// Uses a CAS loop for decrements to prevent underflow wrapping the
|
||||
/// counter to `usize::MAX` (which would cause a capacity-overflow panic
|
||||
/// when pre-allocating the history replay blob).
|
||||
pub(crate) fn adjust_total_count(&self, old_len: usize, new_len: usize) {
|
||||
if new_len > old_len {
|
||||
self.total_count
|
||||
.fetch_add(new_len - old_len, Ordering::Relaxed);
|
||||
} else if old_len > new_len {
|
||||
let delta = old_len - new_len;
|
||||
let mut current = self.total_count.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let next = current.saturating_sub(delta);
|
||||
match self.total_count.compare_exchange_weak(
|
||||
current,
|
||||
next,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => break,
|
||||
Err(actual) => current = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- AIS ---
|
||||
|
||||
fn prune_ais(history: &mut VecDeque<(Instant, AisMessage)>) {
|
||||
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_ais_message(&self, mut msg: AisMessage) {
|
||||
if msg.ts_ms.is_none() {
|
||||
msg.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let mut h = lock_or_recover(&self.ais, "ais_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_ais(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_ais_history(&self) -> Vec<AisMessage> {
|
||||
let mut h = lock_or_recover(&self.ais, "ais_history");
|
||||
let before = h.len();
|
||||
Self::prune_ais(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter().map(|(_, msg)| msg.clone()).collect()
|
||||
}
|
||||
|
||||
// --- VDES ---
|
||||
|
||||
fn prune_vdes(history: &mut VecDeque<(Instant, VdesMessage)>) {
|
||||
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_vdes_message(&self, mut msg: VdesMessage) {
|
||||
if msg.ts_ms.is_none() {
|
||||
msg.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let mut h = lock_or_recover(&self.vdes, "vdes_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_vdes(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_vdes_history(&self) -> Vec<VdesMessage> {
|
||||
let mut h = lock_or_recover(&self.vdes, "vdes_history");
|
||||
let before = h.len();
|
||||
Self::prune_vdes(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter().map(|(_, msg)| msg.clone()).collect()
|
||||
}
|
||||
|
||||
// --- APRS ---
|
||||
|
||||
fn prune_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
|
||||
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_aprs_packet(&self, mut pkt: AprsPacket) {
|
||||
if !pkt.crc_ok {
|
||||
return;
|
||||
}
|
||||
if pkt.ts_ms.is_none() {
|
||||
pkt.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let mut h = lock_or_recover(&self.aprs, "aprs_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), pkt));
|
||||
Self::prune_aprs(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_aprs_history(&self) -> Vec<AprsPacket> {
|
||||
let mut h = lock_or_recover(&self.aprs, "aprs_history");
|
||||
let before = h.len();
|
||||
Self::prune_aprs(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_aprs_history(&self) {
|
||||
let mut h = lock_or_recover(&self.aprs, "aprs_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- HF APRS ---
|
||||
|
||||
fn prune_hf_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
|
||||
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_hf_aprs_packet(&self, mut pkt: AprsPacket) {
|
||||
if !pkt.crc_ok {
|
||||
return;
|
||||
}
|
||||
if pkt.ts_ms.is_none() {
|
||||
pkt.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), pkt));
|
||||
Self::prune_hf_aprs(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_hf_aprs_history(&self) -> Vec<AprsPacket> {
|
||||
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
|
||||
let before = h.len();
|
||||
Self::prune_hf_aprs(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_hf_aprs_history(&self) {
|
||||
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- CW ---
|
||||
|
||||
fn prune_cw(history: &mut VecDeque<(Instant, CwEvent)>) {
|
||||
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_cw_event(&self, evt: CwEvent) {
|
||||
let mut h = lock_or_recover(&self.cw, "cw_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), evt));
|
||||
Self::prune_cw(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_cw_history(&self) -> Vec<CwEvent> {
|
||||
let mut h = lock_or_recover(&self.cw, "cw_history");
|
||||
let before = h.len();
|
||||
Self::prune_cw(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, evt): &(Instant, CwEvent)| evt.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_cw_history(&self) {
|
||||
let mut h = lock_or_recover(&self.cw, "cw_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- FT8 ---
|
||||
|
||||
fn prune_ft8(history: &mut VecDeque<(Instant, Ft8Message)>) {
|
||||
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_ft8_message(&self, msg: Ft8Message) {
|
||||
let mut h = lock_or_recover(&self.ft8, "ft8_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_ft8(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_ft8_history(&self) -> Vec<Ft8Message> {
|
||||
let mut h = lock_or_recover(&self.ft8, "ft8_history");
|
||||
let before = h.len();
|
||||
Self::prune_ft8(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_ft8_history(&self) {
|
||||
let mut h = lock_or_recover(&self.ft8, "ft8_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- FT4 ---
|
||||
|
||||
fn prune_ft4(history: &mut VecDeque<(Instant, Ft8Message)>) {
|
||||
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_ft4_message(&self, msg: Ft8Message) {
|
||||
let mut h = lock_or_recover(&self.ft4, "ft4_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_ft4(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_ft4_history(&self) -> Vec<Ft8Message> {
|
||||
let mut h = lock_or_recover(&self.ft4, "ft4_history");
|
||||
let before = h.len();
|
||||
Self::prune_ft4(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_ft4_history(&self) {
|
||||
let mut h = lock_or_recover(&self.ft4, "ft4_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- FT2 ---
|
||||
|
||||
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
|
||||
fn prune_ft2(history: &mut VecDeque<(Instant, Ft8Message)>) {
|
||||
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
|
||||
pub fn record_ft2_message(&self, msg: Ft8Message) {
|
||||
let mut h = lock_or_recover(&self.ft2, "ft2_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_ft2(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
|
||||
pub fn snapshot_ft2_history(&self) -> Vec<Ft8Message> {
|
||||
let mut h = lock_or_recover(&self.ft2, "ft2_history");
|
||||
let before = h.len();
|
||||
Self::prune_ft2(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_ft2_history(&self) {
|
||||
let mut h = lock_or_recover(&self.ft2, "ft2_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- WSPR ---
|
||||
|
||||
fn prune_wspr(history: &mut VecDeque<(Instant, WsprMessage)>) {
|
||||
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_wspr_message(&self, msg: WsprMessage) {
|
||||
let mut h = lock_or_recover(&self.wspr, "wspr_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_wspr(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_wspr_history(&self) -> Vec<WsprMessage> {
|
||||
let mut h = lock_or_recover(&self.wspr, "wspr_history");
|
||||
let before = h.len();
|
||||
Self::prune_wspr(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, msg): &(Instant, WsprMessage)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_wspr_history(&self) {
|
||||
let mut h = lock_or_recover(&self.wspr, "wspr_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- LRPT ---
|
||||
|
||||
fn prune_lrpt(history: &mut VecDeque<(Instant, LrptImage)>) {
|
||||
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_lrpt_image(&self, mut img: LrptImage) {
|
||||
if img.ts_ms.is_none() {
|
||||
img.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), img));
|
||||
Self::prune_lrpt(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_lrpt_history(&self) -> Vec<LrptImage> {
|
||||
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
|
||||
let before = h.len();
|
||||
Self::prune_lrpt(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, img): &(Instant, LrptImage)| img.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_lrpt_history(&self) {
|
||||
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
// --- WEFAX ---
|
||||
|
||||
fn prune_wefax(history: &mut VecDeque<(Instant, WefaxMessage)>) {
|
||||
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||
}
|
||||
|
||||
pub fn record_wefax_message(&self, mut msg: WefaxMessage) {
|
||||
if msg.ts_ms.is_none() {
|
||||
msg.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
// Strip bulk PNG data before storing in memory/persistence.
|
||||
msg.png_data = None;
|
||||
let mut h = lock_or_recover(&self.wefax, "wefax_history");
|
||||
let before = h.len();
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_wefax(&mut h);
|
||||
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||
self.adjust_total_count(before, h.len());
|
||||
}
|
||||
|
||||
pub fn snapshot_wefax_history(&self) -> Vec<WefaxMessage> {
|
||||
let mut h = lock_or_recover(&self.wefax, "wefax_history");
|
||||
let before = h.len();
|
||||
Self::prune_wefax(&mut h);
|
||||
self.adjust_total_count(before, h.len());
|
||||
h.iter()
|
||||
.map(|(_, msg)| {
|
||||
let mut m = msg.clone();
|
||||
// Re-read PNG from disk so remote clients can save a local copy.
|
||||
if m.png_data.is_none() {
|
||||
if let Some(ref path) = m.path {
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
m.png_data =
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_wefax_history(&self) {
|
||||
let mut h = lock_or_recover(&self.wefax, "wefax_history");
|
||||
let before = h.len();
|
||||
h.clear();
|
||||
self.adjust_total_count(before, 0);
|
||||
}
|
||||
|
||||
/// Returns a quick (non-pruning) estimate of the total number of history
|
||||
/// entries across all decoders, used for pre-allocating the replay blob.
|
||||
///
|
||||
/// Uses an `AtomicUsize` counter maintained by record/prune/clear methods,
|
||||
/// avoiding 11 separate mutex acquisitions.
|
||||
pub fn estimated_total_count(&self) -> usize {
|
||||
self.total_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Rebuild the aggregate count after bulk restoration bypasses the normal
|
||||
/// record methods.
|
||||
pub(crate) fn recalculate_total_count(&self) {
|
||||
let total = lock_or_recover(&self.ais, "ais_history").len()
|
||||
+ lock_or_recover(&self.vdes, "vdes_history").len()
|
||||
+ lock_or_recover(&self.aprs, "aprs_history").len()
|
||||
+ lock_or_recover(&self.hf_aprs, "hf_aprs_history").len()
|
||||
+ lock_or_recover(&self.cw, "cw_history").len()
|
||||
+ lock_or_recover(&self.ft8, "ft8_history").len()
|
||||
+ lock_or_recover(&self.ft4, "ft4_history").len()
|
||||
+ lock_or_recover(&self.ft2, "ft2_history").len()
|
||||
+ lock_or_recover(&self.wspr, "wspr_history").len()
|
||||
+ lock_or_recover(&self.lrpt, "lrpt_history").len()
|
||||
+ lock_or_recover(&self.wefax, "wefax_history").len();
|
||||
self.total_count.store(total, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Shared retention and synchronization policy for decoder histories.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(crate) const HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Maximum entries per decoder history queue. Oldest entries are evicted on
|
||||
/// busy channels independently of time-based pruning.
|
||||
pub(crate) const MAX_HISTORY_ENTRIES: usize = 10_000;
|
||||
|
||||
pub(crate) fn current_timestamp_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(crate) fn lock_or_recover<'a, T>(mutex: &'a Mutex<T>, label: &str) -> MutexGuard<'a, T> {
|
||||
mutex.lock().unwrap_or_else(|error| {
|
||||
tracing::warn!(
|
||||
"Mutex for {} was poisoned (prior panic); recovering with potentially inconsistent data",
|
||||
label
|
||||
);
|
||||
error.into_inner()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn enforce_capacity<T>(deque: &mut VecDeque<T>, max: usize) {
|
||||
while deque.len() > max {
|
||||
deque.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop entries older than `retention` from an ordered history queue.
|
||||
pub(crate) fn prune_by_age<T>(
|
||||
deque: &mut VecDeque<(Instant, T)>,
|
||||
retention: Duration,
|
||||
now: Instant,
|
||||
) {
|
||||
let Some(cutoff) = now.checked_sub(retention) else {
|
||||
return;
|
||||
};
|
||||
while let Some((timestamp, _)) = deque.front() {
|
||||
if *timestamp < cutoff {
|
||||
deque.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
mod audio;
|
||||
mod config;
|
||||
mod decoder_history;
|
||||
mod error;
|
||||
mod history_policy;
|
||||
mod history_store;
|
||||
mod listener;
|
||||
mod rig_handle;
|
||||
|
||||
Reference in New Issue
Block a user