+ Decodes saved bookmarks on hidden channels while you work another band.
+ Needs an SDR rig, and a bookmark is only decoded while it falls inside
+ the span the rig is tuned across.
+
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index 8d7afb5b..35560d40 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
@@ -5589,18 +5589,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
#sch-sat-form-cancel:hover {
background: var(--border);
}
-.bgd-toggle-wrap {
- min-width: 18rem;
- flex: 1 1 20rem;
-}
-.bgd-toggle-row {
- display: inline-flex;
- align-items: center;
- gap: 0.55rem;
- min-height: var(--control-height);
- color: var(--text);
- font-weight: 500;
-}
.bgd-add-row {
display: flex;
gap: 0.55rem;
@@ -5624,48 +5612,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--text-muted);
font-size: 0.78rem;
}
-.bgd-status-list {
- display: grid;
- gap: 0.65rem;
-}
-.bgd-status-row {
- display: flex;
- justify-content: space-between;
- gap: 1rem;
- padding: 0.65rem 0.75rem;
- border: 1px solid var(--border-light);
- border-radius: 0.55rem;
- background: color-mix(in srgb, var(--card-bg) 74%, transparent);
-}
-.bgd-status-name {
- font-weight: 600;
-}
-.bgd-status-meta {
- color: var(--text-muted);
- font-size: 0.82rem;
-}
-.bgd-status-state {
- align-self: center;
- white-space: nowrap;
- font-size: 0.78rem;
- font-weight: 700;
- letter-spacing: 0.05em;
- text-transform: uppercase;
- color: var(--accent-green);
-}
-.bgd-status-state[data-state="out_of_span"],
-.bgd-status-state[data-state="waiting_for_spectrum"],
-.bgd-status-state[data-state="waiting_for_user"],
-.bgd-status-state[data-state="scheduler_has_control"],
-.bgd-status-state[data-state="inactive"],
-.bgd-status-state[data-state="handled_by_scheduler"],
-.bgd-status-state[data-state="handled_by_virtual_channel"] {
- color: var(--accent-yellow);
-}
-.bgd-status-state[data-state="missing_bookmark"],
-.bgd-status-state[data-state="no_supported_decoders"] {
- color: var(--accent-red);
-}
/* ── "Now Playing" status card (top of scheduler & bgd panels) ──── */
.now-playing-card {
border-left: 3px solid var(--accent-green);
@@ -5881,18 +5827,65 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--text-muted);
opacity: 0.7;
}
+.bgd-intro {
+ margin: 0 0 0.9rem;
+ max-width: 62ch;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ line-height: 1.45;
+}
+/* The switch and what the rig can hear, on one line: the second is the reason
+ a selected bookmark may not be decoding, so it belongs beside the first. */
+.bgd-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 0.9rem;
+}
+.bgd-enable {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--text);
+ cursor: pointer;
+}
+.bgd-span {
+ color: var(--text-muted);
+ font-size: 0.82rem;
+}
+.bgd-span[data-tone="warn"] {
+ color: var(--text-warn, #b7791f);
+}
+/* The filter and the bulk buttons sit on the list they act on, and stay on it
+ when the panel narrows. */
+.bgd-list-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+.bgd-list-toolbar .bgd-checklist-filter {
+ flex: 1 1 auto;
+ min-width: 0;
+}
.bgd-checklist {
- max-height: 16rem;
+ max-height: 22rem;
overflow-y: auto;
border: 1px solid var(--border-light);
border-radius: 0.4rem;
background: var(--bg-secondary);
}
+/* One row per bookmark: pick it here, and read what it is doing here too. */
.bgd-checklist-row {
- display: flex;
+ display: grid;
+ grid-template-columns: auto minmax(6rem, 1fr) auto auto;
align-items: center;
gap: 0.55rem;
- padding: 0.45rem 0.65rem;
+ padding: 0.5rem 0.65rem;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
font-size: 0.85rem;
@@ -5904,28 +5897,83 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.bgd-checklist-row:hover {
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
}
+.bgd-checklist-row.is-selected {
+ background: color-mix(in srgb, var(--accent-green) 8%, transparent);
+}
.bgd-checklist-row input[type="checkbox"] {
flex-shrink: 0;
}
.bgd-checklist-name {
font-weight: 600;
color: var(--text);
-}
-.bgd-checklist-meta {
- color: var(--text-muted);
- font-size: 0.78rem;
- margin-left: auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
white-space: nowrap;
}
+.bgd-checklist-meta {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 0.4rem;
+ color: var(--text-muted);
+ font-size: 0.78rem;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+.bgd-checklist-mode {
+ color: var(--text-muted);
+ opacity: 0.85;
+}
+/* A dot and a word. The dot carries the state, so the word can stay a word. */
+.bgd-state {
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.35rem;
+ min-width: 9.5rem;
+ color: var(--text-muted);
+ font-size: 0.76rem;
+ white-space: nowrap;
+}
+.bgd-state-dot {
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 50%;
+ background: currentcolor;
+ opacity: 0.55;
+ flex-shrink: 0;
+}
+.bgd-state[data-state="active"] {
+ color: var(--accent-green);
+ font-weight: 600;
+}
+.bgd-state[data-state="active"] .bgd-state-dot {
+ opacity: 1;
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-green) 22%, transparent);
+}
+.bgd-state[data-state="missing_bookmark"],
+.bgd-state[data-state="no_supported_decoders"] {
+ color: var(--color-error, #c0392b);
+}
+.bgd-state[data-state="unselected"] {
+ min-width: 0;
+}
.bgd-checklist-empty {
- padding: 0.75rem;
+ padding: 1rem 0.75rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
+ line-height: 1.5;
}
-/* ── Select All / Deselect All buttons ────────────────────────────── */
+/* What the selection adds up to, under the list it describes. */
+.bgd-summary {
+ margin-top: 0.5rem;
+ color: var(--text-muted);
+ font-size: 0.8rem;
+}
+/* ── Select all / none buttons ─────────────────────────────────────── */
.bgd-select-actions {
display: flex;
+ flex-shrink: 0;
gap: 0.5rem;
}
.bgd-select-btn {
@@ -5943,15 +5991,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
background: var(--card-bg);
color: var(--text);
}
-/* ── SVG State Dot Badges ─────────────────────────────────────────── */
-.bgd-state-dot {
- width: 8px;
- height: 8px;
- display: inline-block;
- vertical-align: middle;
- margin-right: 4px;
- fill: currentColor;
-}
@media (max-width: 600px) {
.channel-scheduler-controls {
flex-direction: column;
@@ -5992,8 +6031,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.bm-page-summary {
text-align: center;
}
- .bgd-add-row,
- .bgd-status-row {
+ .bgd-add-row {
flex-direction: column;
align-items: stretch;
}
@@ -6003,8 +6041,17 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.interleave-ring-label {
max-width: 8rem;
}
- .bgd-checklist-meta {
- margin-left: 0;
+ /* Two lines rather than four columns: the name and its state stay legible
+ when there is no room for the frequency beside them. */
+ .bgd-checklist-row {
+ grid-template-columns: auto minmax(0, 1fr);
+ row-gap: 0.2rem;
+ }
+ .bgd-checklist-meta,
+ .bgd-state {
+ grid-column: 2;
+ justify-content: flex-start;
+ min-width: 0;
font-size: 0.75rem;
}
}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json
index edfdea2a..8a53fbc2 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
- "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs",
+ "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
index a921b48e..f20e4d78 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
@@ -1468,9 +1468,13 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
+ // Told every time, not only when the list changes: these modules load lazily
+ // and can miss the one telling there was, which left them holding no rig and
+ // showing an empty panel until the operator switched rigs. Both ignore a rig
+ // they already have.
+ window.trx.modules.scheduler?.setRig(lastActiveRigId);
+ window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
if (rigListChanged) {
- window.trx.modules.scheduler?.setRig(lastActiveRigId);
- window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.populateScopePicker();
void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
}
@@ -5036,7 +5040,12 @@ async function initializeApp() {
}
}
+// Whether the session got far enough to wire the settings panels up, so the
+// plugins can be wired again if they turn up after it did.
+let settingsUiReady = false;
+
function initSettingsUI() {
+ settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
@@ -5189,7 +5198,15 @@ 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.
-void loadEagerPlugins().catch((error: unknown) => { console.error(error); });
+//
+// Wire the settings panels again once they are here: the session can be up
+// before these modules finish importing, and the wiring at that point silently
+// skipped whichever had not arrived. A panel that missed it had no rig, no
+// data, and dead buttons — for the rest of the session, since nothing came
+// back to it. Both entry points are safe to run twice.
+void loadEagerPlugins()
+ .then(() => { if (settingsUiReady) initSettingsUI(); })
+ .catch((error: unknown) => { console.error(error); });
// Start the app
void initializeApp();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts
index 16e7dcc5..323d4833 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.ts
@@ -66,10 +66,17 @@ const bgdWindow = window as unknown as BackgroundBridge;
let bookmarkList: Bookmark[] = [];
let statusInterval: ReturnType
| null = null;
let bgdDirty = false;
+ /** Last polled state per bookmark, so a row can say what it is doing. */
+ let statusByBookmark = new Map();
+ let lastStatus: BackgroundDecodeStatus | null = null;
function initBackgroundDecode(rigId: string | null, role: string | null): void {
backgroundDecodeRole = role;
- currentRigId = rigId || null;
+ // The panel used to take whatever rig it was handed at load and wait to be
+ // told again. Loading before the rig list arrives handed it null, and the
+ // next telling only came when the operator switched rigs, so the panel sat
+ // empty and silent. The host knows the rig; ask it.
+ currentRigId = rigId || hostState.lastActiveRigId || null;
if (currentRigId) loadBackgroundDecode();
startStatusPolling();
}
@@ -171,7 +178,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist();
- const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
+ const isControl = isControlRole();
const panel = document.getElementById("background-decode-panel");
if (panel) {
panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) {
@@ -182,6 +189,11 @@ const bgdWindow = window as unknown as BackgroundBridge;
const resetBtn = document.getElementById("background-decode-reset-btn");
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
+ syncSaveButton();
+ }
+
+ function currentFilterText(): string {
+ return (document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value ?? "";
}
function renderBookmarkChecklist(filterText = ""): void {
@@ -203,26 +215,65 @@ const bgdWindow = window as unknown as BackgroundBridge;
: all;
if (filtered.length === 0) {
- container.innerHTML = '' +
- (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
- '
';
+ container.innerHTML = '' + escHtml(emptyListText(all.length)) + "
";
+ renderSelectionSummary();
return;
}
filtered.forEach(function (bookmark) {
const row = document.createElement("label");
row.className = "bgd-checklist-row";
+ row.dataset.bmId = bookmark.id;
const decoders = bookmarkDecoderKinds(bookmark);
- const checked = selectedIds.has(bookmark.id) ? " checked" : "";
+ const selected = selectedIds.has(bookmark.id);
+ if (selected) row.classList.add("is-selected");
row.innerHTML =
- '' +
+ '' +
'' + escHtml(bookmark.name) + '' +
- '' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '';
+ ''
+ + escHtml(formatFreq(bookmark.freq_hz)) + ''
+ + escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + ''
+ + stateBadgeHtml(bookmark.id, selected);
row.querySelector("input")?.addEventListener("change", function (e) {
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
});
container.appendChild(row);
});
+ renderSelectionSummary();
+ }
+
+ /** Why the list is empty, in the terms the operator can act on. */
+ function emptyListText(supportedCount: number): string {
+ if (supportedCount > 0) return "No bookmark matches that filter.";
+ if (bookmarkList.length > 0) {
+ return "None of your bookmarks name a decoder that can run in the background."
+ + " Give one a decoder on the Bookmarks tab to list it here.";
+ }
+ return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
+ }
+
+ /** The live state of a selected bookmark, as the row's own badge. */
+ function stateBadgeHtml(bookmarkId: string, selected: boolean): string {
+ if (!selected) return '';
+ const entry = statusByBookmark.get(bookmarkId);
+ const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
+ return ''
+ + '' + escHtml(prettyState(state)) + "";
+ }
+
+ function renderSelectionSummary(): void {
+ const el = document.getElementById("bgd-selection-summary");
+ if (!el) return;
+ const selected = currentConfig?.bookmark_ids.length ?? 0;
+ if (selected === 0) {
+ el.textContent = "Nothing selected — background decoding is idle.";
+ return;
+ }
+ const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
+ const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
+ el.textContent = currentConfig?.enabled
+ ? `${noun}, ${String(active)} decoding now.`
+ : `${noun}. Switch Enabled on to start decoding them.`;
}
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
@@ -260,7 +311,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
showToast(`Save failed: ${errorMessage(err)}`, true);
})
.finally(function () {
- if (btn) btn.disabled = false;
+ syncSaveButton();
});
}
@@ -295,58 +346,77 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
function renderStatus(status: BackgroundDecodeStatus): void {
- const card = document.getElementById("background-decode-status-card");
- if (!card) return;
- const entries = status.entries ?? [];
- if (!entries.length) {
- card.textContent = "No background decode bookmarks configured.";
- return;
- }
- const summary = [];
- if (status.active_rig) {
- if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
- if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
- } else {
- summary.push("This rig is not currently selected for audio.");
- }
- let html = summary.length ? '' + escHtml(summary.join(" · ")) + "
" : "";
- html += '';
- entries.forEach(function (entry) {
- const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
- const parts = [];
- if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
- if (entry.mode) parts.push(entry.mode);
- if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
- parts.push(entry.decoder_kinds.join("/").toUpperCase());
- }
- html +=
- '
' +
- '
' +
- '
' + escHtml(name) + '
' +
- '
' + escHtml(parts.join(" · ")) + '
' +
- '
' +
- '
' +
- '' +
- escHtml(prettyState(entry.state)) + '
' +
- '
';
- });
- html += "
";
- card.innerHTML = html;
+ lastStatus = status;
+ statusByBookmark = new Map(
+ (status.entries ?? [])
+ .filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0)
+ .map((entry) => [entry.bookmark_id as string, entry]),
+ );
+ renderSpanSummary();
+ // Rows carry the state, so the list repaints — keeping the filter the
+ // operator typed and the selection they have not saved yet.
+ renderBookmarkChecklist(currentFilterText());
}
+ /** What the rig is listening across, which is what decides whether a
+ * selected bookmark can be decoded at all. */
+ function renderSpanSummary(): void {
+ const el = document.getElementById("bgd-span-summary");
+ if (!el) return;
+ const status = lastStatus;
+ if (!status) {
+ el.textContent = "";
+ return;
+ }
+ if (!status.active_rig) {
+ el.textContent = "This rig is not the one playing audio.";
+ el.dataset.tone = "warn";
+ return;
+ }
+ const centre = typeof status.center_hz === "number" && Number.isFinite(status.center_hz)
+ ? formatFreq(status.center_hz)
+ : null;
+ const half = typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0
+ ? formatFreq(status.sample_rate / 2)
+ : null;
+ el.dataset.tone = "";
+ el.textContent = centre && half ? `Span ${centre} ±${half}` : centre ? `Centre ${centre}` : "";
+ }
+
+ /** The one-line reason behind a state, for the row's tooltip. */
+ function stateHelp(state: string | undefined): string {
+ switch (state) {
+ case "active": return "Decoding on a hidden channel.";
+ case "out_of_span": return "Outside the span the rig is tuned across, so it cannot be heard from here.";
+ case "waiting_for_spectrum": return "Waiting for the first spectrum frame from the rig.";
+ case "waiting_for_user": return "Nobody is listening to this rig, so no audio is being pulled.";
+ case "missing_bookmark": return "The bookmark this was selected from is gone.";
+ case "no_supported_decoders": return "No decoder that runs in the background can decode this bookmark.";
+ case "disabled": return "Background decoding is switched off.";
+ case "handled_by_scheduler":
+ case "scheduler_has_control": return "The scheduler is running this bookmark instead.";
+ case "handled_by_virtual_channel": return "A virtual channel is already on this frequency.";
+ case "pending": return "Selected, and not started yet — save to apply.";
+ default: return "Selected, but not decoding.";
+ }
+ }
+
+ // The dot beside the word already says whether this is running, so the
+ // words no longer carry a tick or a triangle of their own.
function prettyState(state: string | undefined): string {
switch (state) {
- case "active": return "\u2713 Active";
- case "out_of_span": return "\u25B3 Out of span";
- case "waiting_for_spectrum": return "\u25B3 Waiting";
- case "waiting_for_user": return "\u25B3 No user";
- case "missing_bookmark": return "\u2717 Missing";
- case "no_supported_decoders": return "\u2717 Unsupported";
- case "disabled": return "\u25B3 Disabled";
- case "handled_by_scheduler": return "\u25B3 Scheduler";
- case "scheduler_has_control": return "\u25B3 Scheduler";
- case "handled_by_virtual_channel": return "\u25B3 VChan";
- default: return "\u25B3 Inactive";
+ case "active": return "Decoding";
+ case "out_of_span": return "Out of span";
+ case "waiting_for_spectrum": return "Waiting for spectrum";
+ case "waiting_for_user": return "Nobody listening";
+ case "missing_bookmark": return "Bookmark gone";
+ case "no_supported_decoders": return "No decoder";
+ case "disabled": return "Off";
+ case "handled_by_scheduler":
+ case "scheduler_has_control": return "Scheduler has it";
+ case "handled_by_virtual_channel": return "On a channel";
+ case "pending": return "Not saved";
+ default: return "Idle";
}
}
@@ -380,14 +450,25 @@ const bgdWindow = window as unknown as BackgroundBridge;
function markBgdDirty() {
if (bgdDirty) return;
bgdDirty = true;
- const btn = document.getElementById("background-decode-save-btn");
- if (btn) btn.classList.add("sch-dirty");
+ syncSaveButton();
}
function clearBgdDirty() {
bgdDirty = false;
- const btn = document.getElementById("background-decode-save-btn");
- if (btn) btn.classList.remove("sch-dirty");
+ syncSaveButton();
+ }
+
+ /** Save offers itself only when there is something to save. */
+ function syncSaveButton() {
+ const btn = document.getElementById("background-decode-save-btn") as HTMLButtonElement | null;
+ if (!btn) return;
+ btn.classList.toggle("sch-dirty", bgdDirty);
+ btn.disabled = !bgdDirty || !isControlRole();
+ btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
+ }
+
+ function isControlRole(): boolean {
+ return backgroundDecodeRole === "control" || hostState.authEnabled === false;
}
function showToast(msg: string, isError: boolean): void {
@@ -407,7 +488,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
currentConfig.bookmark_ids = ids;
- renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
+ renderBookmarkChecklist(currentFilterText());
markBgdDirty();
}
@@ -416,7 +497,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
currentConfig.bookmark_ids = [];
- renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
+ renderBookmarkChecklist(currentFilterText());
markBgdDirty();
}
@@ -432,7 +513,11 @@ const bgdWindow = window as unknown as BackgroundBridge;
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
if (enabledCb && !enabledCb._wired) {
enabledCb._wired = true;
- enabledCb.addEventListener("change", function () { markBgdDirty(); });
+ enabledCb.addEventListener("change", function () {
+ if (currentConfig) currentConfig.enabled = enabledCb.checked;
+ markBgdDirty();
+ renderBookmarkChecklist(currentFilterText());
+ });
}
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.mjs
new file mode 100644
index 00000000..87bc8250
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/background-decode.mjs
@@ -0,0 +1,138 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// The background decode panel: one list that both picks the bookmarks and says
+// what each one is doing. It used to be two lists of the same bookmarks — one
+// to choose from, one to read state off — and it filled neither until the
+// operator switched rigs, because it took whatever rig it was handed at load
+// and that was nothing yet.
+
+import assert from "node:assert/strict";
+import { chromium } from "playwright-core";
+import { startBrowser, startWebFixture } from "./web-fixture.mjs";
+
+/* global document */
+
+const BOOKMARKS = [
+ { id: "bm-ft8-20", name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoders: ["ft8"] },
+ { id: "bm-ft8-40", name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoders: ["ft8"] },
+ { id: "bm-ft4-20", name: "FT4 20 m", freq_hz: 14_080_000, mode: "USB", decoders: ["ft4"] },
+ { id: "bm-aprs", name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoders: ["aprs"] },
+ // Nothing that runs in the background can decode CW, so it must not be offered.
+ { id: "bm-cw", name: "CW practice", freq_hz: 7_030_000, mode: "CW", decoders: ["cw"] },
+];
+
+const backgroundDecode = {
+ config: { remote: "rig-a", enabled: true, bookmark_ids: ["bm-ft8-20", "bm-ft8-40", "bm-aprs"] },
+ status: {
+ active_rig: true,
+ center_hz: 14_100_000,
+ sample_rate: 2_400_000,
+ entries: [
+ { bookmark_id: "bm-ft8-20", bookmark_name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "active" },
+ { bookmark_id: "bm-ft8-40", bookmark_name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "out_of_span" },
+ { bookmark_id: "bm-aprs", bookmark_name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoder_kinds: ["aprs"], state: "handled_by_scheduler" },
+ ],
+ },
+};
+
+const readRows = () => page.evaluate(() => {
+ const rows = [...document.querySelectorAll("#bgd-bookmark-checklist .bgd-checklist-row")];
+ return rows.map((row) => ({
+ name: row.querySelector(".bgd-checklist-name")?.textContent ?? "",
+ checked: row.querySelector("input[type=checkbox]")?.checked ?? false,
+ state: row.querySelector(".bgd-state")?.dataset.state ?? null,
+ stateText: row.querySelector(".bgd-state")?.textContent?.trim() ?? "",
+ }));
+});
+
+const openPanel = async () => {
+ await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
+ await page.locator("#tab-settings").waitFor({ state: "visible" });
+ await page.waitForTimeout(2000);
+ await page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
+ await page.waitForTimeout(1200);
+};
+
+const fixture = await startWebFixture({ spectrum: true, bookmarks: BOOKMARKS, backgroundDecode });
+const { browser, page, runtimeErrors } = await startBrowser(chromium);
+
+try {
+ await page.setViewportSize({ width: 1400, height: 950 });
+ await openPanel();
+
+ // Nothing was switched, clicked or reloaded: the panel asked the host which
+ // rig it is on and filled itself.
+ const rows = await readRows();
+ assert.deepEqual(rows.map((row) => row.name), ["FT8 20 m", "FT8 40 m", "FT4 20 m", "APRS 2 m"],
+ "the list does not hold the bookmarks a background channel can decode");
+ assert.deepEqual(rows.map((row) => row.checked), [true, true, false, true],
+ "the saved selection is not reflected in the list");
+
+ // Each row carries its own state, so there is no second list to consult.
+ assert.deepEqual(rows.map((row) => row.state),
+ ["active", "out_of_span", "unselected", "handled_by_scheduler"],
+ "the rows do not carry the state of the bookmark they name");
+ assert.match(rows[0].stateText, /Decoding/);
+ assert.match(rows[1].stateText, /Out of span/);
+
+ // What the rig can hear, and what the selection adds up to.
+ const context = await page.evaluate(() => ({
+ span: document.getElementById("bgd-span-summary")?.textContent?.trim(),
+ summary: document.getElementById("bgd-selection-summary")?.textContent?.trim(),
+ }));
+ assert.match(context.span, /14\.1 MHz/, `the span reads "${context.span}"`);
+ assert.match(context.summary, /3 bookmarks selected, 1 decoding now\./, `the summary reads "${context.summary}"`);
+
+ // Save has nothing to do until something changes, and says so again once it
+ // has been done.
+ const saveState = () => page.evaluate(() => {
+ const btn = document.getElementById("background-decode-save-btn");
+ return { disabled: btn.disabled, dirty: btn.classList.contains("sch-dirty") };
+ });
+ assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save offers itself with nothing to save");
+
+ await page.locator('#bgd-bookmark-checklist .bgd-checklist-row:nth-child(3) input').click();
+ await page.waitForTimeout(300);
+ assert.deepEqual(await saveState(), { disabled: false, dirty: true }, "Save stayed inert after a change");
+
+ await page.locator("#background-decode-save-btn").click();
+ await page.waitForTimeout(1000);
+ assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save stayed live after saving");
+
+ // The selection reached the server and comes back on the next load.
+ await openPanel();
+ const saved = await readRows();
+ assert.deepEqual(saved.map((row) => row.checked), [true, true, true, true],
+ "the newly selected bookmark did not survive a reload");
+
+ assert.deepEqual(runtimeErrors, []);
+} finally {
+ await browser.close();
+ await fixture.close();
+}
+
+// With no bookmarks at all the panel says what to do about it rather than
+// showing an empty box.
+const emptyFixture = await startWebFixture({ spectrum: true, bookmarks: [] });
+const empty = await startBrowser(chromium);
+try {
+ await empty.page.setViewportSize({ width: 1400, height: 950 });
+ await empty.page.goto(`${emptyFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
+ await empty.page.locator("#tab-settings").waitFor({ state: "visible" });
+ await empty.page.waitForTimeout(2000);
+ await empty.page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
+ await empty.page.waitForTimeout(1000);
+ const text = await empty.page.evaluate(() =>
+ document.getElementById("bgd-bookmark-checklist")?.textContent?.trim() ?? "");
+ assert.match(text, /No bookmarks yet/, `the empty list reads "${text}"`);
+ const summary = await empty.page.evaluate(() =>
+ document.getElementById("bgd-selection-summary")?.textContent?.trim() ?? "");
+ assert.match(summary, /Nothing selected/, `the summary reads "${summary}"`);
+} finally {
+ await empty.browser.close();
+ await emptyFixture.close();
+}
+
+console.log("background decode panel tests passed");
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
index 1e2682f2..f79e60f0 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
@@ -34,7 +34,14 @@ const DECODER_REGISTRY = [
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
-].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));
+].map((decoder) => ({
+ ...decoder,
+ // Which decoders a background channel can run, as the server's own registry
+ // has it: the background decode panel offers a bookmark only if one of these
+ // can decode it, so marking them all false left that panel with nothing.
+ background_decode: ["ft8", "ft4", "ft2", "wspr", "ais", "aprs", "hf-aprs"].includes(decoder.id),
+ bookmark_selectable: true,
+}));
const CONTENT_TYPES = new Map([
[".css", "text/css; charset=utf-8"],
@@ -132,6 +139,7 @@ export async function startWebFixture({
mode = "FM",
history = {},
bookmarks = [],
+ backgroundDecode = null,
bandplan = {},
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
@@ -271,6 +279,34 @@ export async function startWebFixture({
response.writeHead(200).end();
return;
}
+ // Background decode: the panel reads its config, its status, and the
+ // bookmark list, and writes the config back.
+ if (url.pathname.startsWith("/background-decode/")) {
+ const isStatus = url.pathname.endsWith("/status");
+ if (isStatus) {
+ response.writeHead(200, { "content-type": "application/json" });
+ response.end(JSON.stringify(backgroundDecode?.status ?? { entries: [] }));
+ return;
+ }
+ if (request.method === "PUT") {
+ const body = await new Promise((resolve) => {
+ let raw = "";
+ request.on("data", (chunk) => { raw += chunk; });
+ request.on("end", () => resolve(raw));
+ });
+ try {
+ const parsed = JSON.parse(body);
+ if (backgroundDecode) backgroundDecode.config = parsed;
+ } catch { /* leave the config as it was */ }
+ }
+ if (request.method === "DELETE" && backgroundDecode) {
+ backgroundDecode.config = { remote: "rig-a", enabled: false, bookmark_ids: [] };
+ }
+ response.writeHead(200, { "content-type": "application/json" });
+ response.end(JSON.stringify(backgroundDecode?.config
+ ?? { remote: "rig-a", enabled: false, bookmark_ids: [] }));
+ return;
+ }
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {