[fix](trx-frontend-http): rebuild the background decode panel around one list #58
@@ -2905,9 +2905,9 @@ function applyRigList(activeRigId, rigIds, displayNames) {
|
||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||
updateRigSubtitle(lastActiveRigId);
|
||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||
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")?.value || "");
|
||||
}
|
||||
@@ -6001,7 +6001,9 @@ async function initializeApp() {
|
||||
showAuthGate(allowGuest);
|
||||
}
|
||||
}
|
||||
var settingsUiReady = false;
|
||||
function initSettingsUI() {
|
||||
settingsUiReady = true;
|
||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
||||
window.trx.modules.scheduler?.wireEvents();
|
||||
if (window.trx.modules.backgroundDecode) {
|
||||
@@ -6272,7 +6274,9 @@ Object.defineProperties(trxState, {
|
||||
} }
|
||||
});
|
||||
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
|
||||
void loadEagerPlugins().catch((error) => {
|
||||
void loadEagerPlugins().then(() => {
|
||||
if (settingsUiReady) initSettingsUI();
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
void initializeApp();
|
||||
|
||||
+116
-47
@@ -19,9 +19,11 @@ var bgdWindow = window;
|
||||
let bookmarkList = [];
|
||||
let statusInterval = null;
|
||||
let bgdDirty = false;
|
||||
let statusByBookmark = /* @__PURE__ */ new Map();
|
||||
let lastStatus = null;
|
||||
function initBackgroundDecode(rigId, role) {
|
||||
backgroundDecodeRole = role;
|
||||
currentRigId = rigId || null;
|
||||
currentRigId = rigId || hostState.lastActiveRigId || null;
|
||||
if (currentRigId) loadBackgroundDecode();
|
||||
startStatusPolling();
|
||||
}
|
||||
@@ -108,7 +110,7 @@ var bgdWindow = window;
|
||||
}
|
||||
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) {
|
||||
@@ -119,6 +121,10 @@ var bgdWindow = window;
|
||||
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() {
|
||||
return document.getElementById("bgd-bookmark-filter")?.value ?? "";
|
||||
}
|
||||
function renderBookmarkChecklist(filterText = "") {
|
||||
const container = document.getElementById("bgd-bookmark-checklist");
|
||||
@@ -134,20 +140,49 @@ var bgdWindow = window;
|
||||
return text.indexOf(filter) >= 0;
|
||||
}) : all;
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = '<div class="bgd-checklist-empty">' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "</div>";
|
||||
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
|
||||
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" : "";
|
||||
row.innerHTML = '<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span>";
|
||||
const selected = selectedIds.has(bookmark.id);
|
||||
if (selected) row.classList.add("is-selected");
|
||||
row.innerHTML = '<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">' + escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span></span>" + stateBadgeHtml(bookmark.id, selected);
|
||||
row.querySelector("input")?.addEventListener("change", function(e) {
|
||||
onChecklistToggle(bookmark.id, e.currentTarget.checked);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
renderSelectionSummary();
|
||||
}
|
||||
function emptyListText(supportedCount) {
|
||||
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.";
|
||||
}
|
||||
function stateBadgeHtml(bookmarkId, selected) {
|
||||
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
|
||||
const entry = statusByBookmark.get(bookmarkId);
|
||||
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
|
||||
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '"><span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
|
||||
}
|
||||
function renderSelectionSummary() {
|
||||
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, checked) {
|
||||
if (!currentConfig) {
|
||||
@@ -182,7 +217,7 @@ var bgdWindow = window;
|
||||
}).catch(function(err) {
|
||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||
}).finally(function() {
|
||||
if (btn) btn.disabled = false;
|
||||
syncSaveButton();
|
||||
});
|
||||
}
|
||||
async function resetBackgroundDecode() {
|
||||
@@ -210,59 +245,83 @@ var bgdWindow = window;
|
||||
});
|
||||
}
|
||||
function renderStatus(status) {
|
||||
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.";
|
||||
lastStatus = status;
|
||||
statusByBookmark = new Map(
|
||||
(status.entries ?? []).filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0).map((entry) => [entry.bookmark_id, entry])
|
||||
);
|
||||
renderSpanSummary();
|
||||
renderBookmarkChecklist(currentFilterText());
|
||||
}
|
||||
function renderSpanSummary() {
|
||||
const el = document.getElementById("bgd-span-summary");
|
||||
if (!el) return;
|
||||
const status = lastStatus;
|
||||
if (!status) {
|
||||
el.textContent = "";
|
||||
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.");
|
||||
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}` : "";
|
||||
}
|
||||
function stateHelp(state) {
|
||||
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.";
|
||||
}
|
||||
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
||||
html += '<div class="bgd-status-list">';
|
||||
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 += '<div class="bgd-status-row"><div><div class="bgd-status-name">' + escHtml(name) + '</div><div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div></div><div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '"><svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' + escHtml(prettyState(entry.state)) + "</div></div>";
|
||||
});
|
||||
html += "</div>";
|
||||
card.innerHTML = html;
|
||||
}
|
||||
function prettyState(state) {
|
||||
switch (state) {
|
||||
case "active":
|
||||
return "✓ Active";
|
||||
return "Decoding";
|
||||
case "out_of_span":
|
||||
return "△ Out of span";
|
||||
return "Out of span";
|
||||
case "waiting_for_spectrum":
|
||||
return "△ Waiting";
|
||||
return "Waiting for spectrum";
|
||||
case "waiting_for_user":
|
||||
return "△ No user";
|
||||
return "Nobody listening";
|
||||
case "missing_bookmark":
|
||||
return "✗ Missing";
|
||||
return "Bookmark gone";
|
||||
case "no_supported_decoders":
|
||||
return "✗ Unsupported";
|
||||
return "No decoder";
|
||||
case "disabled":
|
||||
return "△ Disabled";
|
||||
return "Off";
|
||||
case "handled_by_scheduler":
|
||||
return "△ Scheduler";
|
||||
case "scheduler_has_control":
|
||||
return "△ Scheduler";
|
||||
return "Scheduler has it";
|
||||
case "handled_by_virtual_channel":
|
||||
return "△ VChan";
|
||||
return "On a channel";
|
||||
case "pending":
|
||||
return "Not saved";
|
||||
default:
|
||||
return "△ Inactive";
|
||||
return "Idle";
|
||||
}
|
||||
}
|
||||
function setCheckbox(id, value) {
|
||||
@@ -285,13 +344,21 @@ var bgdWindow = window;
|
||||
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;
|
||||
syncSaveButton();
|
||||
}
|
||||
function syncSaveButton() {
|
||||
const btn = document.getElementById("background-decode-save-btn");
|
||||
if (btn) btn.classList.remove("sch-dirty");
|
||||
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() {
|
||||
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||
}
|
||||
function showToast(msg, isError) {
|
||||
const el = document.getElementById("background-decode-toast");
|
||||
@@ -311,7 +378,7 @@ var bgdWindow = window;
|
||||
return bm.id;
|
||||
});
|
||||
currentConfig.bookmark_ids = ids;
|
||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||
renderBookmarkChecklist(currentFilterText());
|
||||
markBgdDirty();
|
||||
}
|
||||
function deselectAllBookmarks() {
|
||||
@@ -319,7 +386,7 @@ var bgdWindow = window;
|
||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
currentConfig.bookmark_ids = [];
|
||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||
renderBookmarkChecklist(currentFilterText());
|
||||
markBgdDirty();
|
||||
}
|
||||
function wireBackgroundDecodeEvents() {
|
||||
@@ -334,7 +401,9 @@ var bgdWindow = window;
|
||||
if (enabledCb && !enabledCb._wired) {
|
||||
enabledCb._wired = true;
|
||||
enabledCb.addEventListener("change", function() {
|
||||
if (currentConfig) currentConfig.enabled = enabledCb.checked;
|
||||
markBgdDirty();
|
||||
renderBookmarkChecklist(currentFilterText());
|
||||
});
|
||||
}
|
||||
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
||||
|
||||
@@ -1467,30 +1467,31 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
|
||||
<div id="background-decode-panel" class="sch-panel">
|
||||
<div class="sch-toast" id="background-decode-toast" role="alert" aria-live="polite" style="display:none;"></div>
|
||||
<!-- Now Playing status card (moved to top) -->
|
||||
<div class="now-playing-card">
|
||||
<div id="background-decode-status-card" class="sch-status-card">No background decode bookmarks configured.</div>
|
||||
</div>
|
||||
<div class="sch-section">
|
||||
<div class="sch-section-title">Configuration</div>
|
||||
<div class="sch-row">
|
||||
<label class="sch-label bgd-toggle-wrap">Background decode
|
||||
<span class="bgd-toggle-row">
|
||||
<input type="checkbox" id="background-decode-enabled" />
|
||||
<span>Enable hidden background decoder channels</span>
|
||||
</span>
|
||||
<div class="sch-section-title">Background Decode</div>
|
||||
<p class="bgd-intro">
|
||||
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.
|
||||
</p>
|
||||
<div class="bgd-controls">
|
||||
<label class="bgd-enable">
|
||||
<input type="checkbox" id="background-decode-enabled" />
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
<span id="bgd-span-summary" class="bgd-span" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="sch-row" style="flex-direction:column;gap:0.5rem;">
|
||||
<label class="sch-label" style="min-width:100%;">Bookmarks
|
||||
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter" placeholder="Filter bookmarks..." />
|
||||
</label>
|
||||
<div class="bgd-list-toolbar">
|
||||
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter"
|
||||
placeholder="Filter bookmarks…" aria-label="Filter bookmarks" />
|
||||
<div class="bgd-select-actions">
|
||||
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select all bookmarks">Select All</button>
|
||||
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Deselect all bookmarks">Deselect All</button>
|
||||
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select every bookmark">All</button>
|
||||
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Select no bookmarks">None</button>
|
||||
</div>
|
||||
<div id="bgd-bookmark-checklist" class="bgd-checklist"></div>
|
||||
</div>
|
||||
<div id="bgd-bookmark-checklist" class="bgd-checklist" role="group"
|
||||
aria-label="Bookmarks decoded in the background"></div>
|
||||
<div id="bgd-selection-summary" class="bgd-summary" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="sch-actions">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+149
-64
@@ -66,10 +66,17 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
let bookmarkList: Bookmark[] = [];
|
||||
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let bgdDirty = false;
|
||||
/** Last polled state per bookmark, so a row can say what it is doing. */
|
||||
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
|
||||
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<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("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 = '<div class="bgd-checklist-empty">' +
|
||||
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
|
||||
'</div>';
|
||||
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
|
||||
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 =
|
||||
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
||||
'<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
||||
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
||||
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
|
||||
'<span class="bgd-checklist-meta">'
|
||||
+ escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">'
|
||||
+ escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span></span>'
|
||||
+ stateBadgeHtml(bookmark.id, selected);
|
||||
row.querySelector<HTMLInputElement>("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 '<span class="bgd-state" data-state="unselected"></span>';
|
||||
const entry = statusByBookmark.get(bookmarkId);
|
||||
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
|
||||
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '">'
|
||||
+ '<span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
|
||||
}
|
||||
|
||||
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 ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
||||
html += '<div class="bgd-status-list">';
|
||||
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 +=
|
||||
'<div class="bgd-status-row">' +
|
||||
'<div>' +
|
||||
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
|
||||
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
|
||||
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
|
||||
escHtml(prettyState(entry.state)) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += "</div>";
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// 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");
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user