[fix](trx-frontend-http): rebuild the background decode panel around one list
The panel listed the same bookmarks twice: a status card at the top naming
the selected ones and what each was doing, and a checklist below naming all
of them with the checkboxes. Neither list said which row in the other it
meant, so choosing what to decode and reading what was being decoded were two
passes over the same names. The checklist itself sat squeezed against the
right-hand edge, because the row it lives in sets `align-items: flex-end` and
an inline `flex-direction: column` turned that into "push everything right".
It is one list now. A row carries its checkbox, its name, its frequency,
mode and decoder, and its own state, so picking and watching happen in the
same place. What the rig can hear moves up beside the switch, where it
explains why a selected bookmark reads out of span, and the selection adds
itself up under the list. The states lose the ✓/△/✗ they carried next to a
dot that already said as much, and say what they mean: "Out of span",
"Scheduler has it", "Nobody listening", each with the reason on hover. The
empty list now says which of the two reasons it is empty for, and what to do.
Save was live from the moment the panel opened, with nothing to save; it now
offers itself only when something has changed.
Two races behind it, both of which left the panel useless rather than ugly:
* The rig was whatever the panel was handed at load. Loading before the rig
list arrived handed it null, and the next telling only came when the
operator switched rigs — so the panel stayed empty and silent. Every rig
list refresh now passes the rig on, and both panels ignore one they have.
* The settings panels are wired once the session is up, but their modules
import asynchronously and the wiring skipped whichever had not arrived.
A panel that missed it kept dead buttons for the rest of the session: no
filter, no Select All, no Save. Wiring runs again when the modules land.
Closes #52
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit was merged in pull request #58.
This commit is contained in:
@@ -2905,9 +2905,9 @@ function applyRigList(activeRigId, rigIds, displayNames) {
|
|||||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||||
updateRigSubtitle(lastActiveRigId);
|
updateRigSubtitle(lastActiveRigId);
|
||||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||||
|
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||||||
|
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||||||
if (rigListChanged) {
|
if (rigListChanged) {
|
||||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
|
||||||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
|
||||||
window.trx.modules.bookmarks?.populateScopePicker();
|
window.trx.modules.bookmarks?.populateScopePicker();
|
||||||
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
|
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
|
||||||
}
|
}
|
||||||
@@ -6001,7 +6001,9 @@ async function initializeApp() {
|
|||||||
showAuthGate(allowGuest);
|
showAuthGate(allowGuest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var settingsUiReady = false;
|
||||||
function initSettingsUI() {
|
function initSettingsUI() {
|
||||||
|
settingsUiReady = true;
|
||||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
||||||
window.trx.modules.scheduler?.wireEvents();
|
window.trx.modules.scheduler?.wireEvents();
|
||||||
if (window.trx.modules.backgroundDecode) {
|
if (window.trx.modules.backgroundDecode) {
|
||||||
@@ -6272,7 +6274,9 @@ Object.defineProperties(trxState, {
|
|||||||
} }
|
} }
|
||||||
});
|
});
|
||||||
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
|
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);
|
console.error(error);
|
||||||
});
|
});
|
||||||
void initializeApp();
|
void initializeApp();
|
||||||
|
|||||||
+116
-47
@@ -19,9 +19,11 @@ var bgdWindow = window;
|
|||||||
let bookmarkList = [];
|
let bookmarkList = [];
|
||||||
let statusInterval = null;
|
let statusInterval = null;
|
||||||
let bgdDirty = false;
|
let bgdDirty = false;
|
||||||
|
let statusByBookmark = /* @__PURE__ */ new Map();
|
||||||
|
let lastStatus = null;
|
||||||
function initBackgroundDecode(rigId, role) {
|
function initBackgroundDecode(rigId, role) {
|
||||||
backgroundDecodeRole = role;
|
backgroundDecodeRole = role;
|
||||||
currentRigId = rigId || null;
|
currentRigId = rigId || hostState.lastActiveRigId || null;
|
||||||
if (currentRigId) loadBackgroundDecode();
|
if (currentRigId) loadBackgroundDecode();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
}
|
}
|
||||||
@@ -108,7 +110,7 @@ var bgdWindow = window;
|
|||||||
}
|
}
|
||||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||||
renderBookmarkChecklist();
|
renderBookmarkChecklist();
|
||||||
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
const isControl = isControlRole();
|
||||||
const panel = document.getElementById("background-decode-panel");
|
const panel = document.getElementById("background-decode-panel");
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
|
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");
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||||
|
syncSaveButton();
|
||||||
|
}
|
||||||
|
function currentFilterText() {
|
||||||
|
return document.getElementById("bgd-bookmark-filter")?.value ?? "";
|
||||||
}
|
}
|
||||||
function renderBookmarkChecklist(filterText = "") {
|
function renderBookmarkChecklist(filterText = "") {
|
||||||
const container = document.getElementById("bgd-bookmark-checklist");
|
const container = document.getElementById("bgd-bookmark-checklist");
|
||||||
@@ -134,20 +140,49 @@ var bgdWindow = window;
|
|||||||
return text.indexOf(filter) >= 0;
|
return text.indexOf(filter) >= 0;
|
||||||
}) : all;
|
}) : all;
|
||||||
if (filtered.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
filtered.forEach(function(bookmark) {
|
filtered.forEach(function(bookmark) {
|
||||||
const row = document.createElement("label");
|
const row = document.createElement("label");
|
||||||
row.className = "bgd-checklist-row";
|
row.className = "bgd-checklist-row";
|
||||||
|
row.dataset.bmId = bookmark.id;
|
||||||
const decoders = bookmarkDecoderKinds(bookmark);
|
const decoders = bookmarkDecoderKinds(bookmark);
|
||||||
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
const selected = selectedIds.has(bookmark.id);
|
||||||
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>";
|
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) {
|
row.querySelector("input")?.addEventListener("change", function(e) {
|
||||||
onChecklistToggle(bookmark.id, e.currentTarget.checked);
|
onChecklistToggle(bookmark.id, e.currentTarget.checked);
|
||||||
});
|
});
|
||||||
container.appendChild(row);
|
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) {
|
function onChecklistToggle(bookmarkId, checked) {
|
||||||
if (!currentConfig) {
|
if (!currentConfig) {
|
||||||
@@ -182,7 +217,7 @@ var bgdWindow = window;
|
|||||||
}).catch(function(err) {
|
}).catch(function(err) {
|
||||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||||
}).finally(function() {
|
}).finally(function() {
|
||||||
if (btn) btn.disabled = false;
|
syncSaveButton();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function resetBackgroundDecode() {
|
async function resetBackgroundDecode() {
|
||||||
@@ -210,59 +245,83 @@ var bgdWindow = window;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function renderStatus(status) {
|
function renderStatus(status) {
|
||||||
const card = document.getElementById("background-decode-status-card");
|
lastStatus = status;
|
||||||
if (!card) return;
|
statusByBookmark = new Map(
|
||||||
const entries = status.entries ?? [];
|
(status.entries ?? []).filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0).map((entry) => [entry.bookmark_id, entry])
|
||||||
if (!entries.length) {
|
);
|
||||||
card.textContent = "No background decode bookmarks configured.";
|
renderSpanSummary();
|
||||||
|
renderBookmarkChecklist(currentFilterText());
|
||||||
|
}
|
||||||
|
function renderSpanSummary() {
|
||||||
|
const el = document.getElementById("bgd-span-summary");
|
||||||
|
if (!el) return;
|
||||||
|
const status = lastStatus;
|
||||||
|
if (!status) {
|
||||||
|
el.textContent = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const summary = [];
|
if (!status.active_rig) {
|
||||||
if (status.active_rig) {
|
el.textContent = "This rig is not the one playing audio.";
|
||||||
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
el.dataset.tone = "warn";
|
||||||
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
return;
|
||||||
} else {
|
}
|
||||||
summary.push("This rig is not currently selected for audio.");
|
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) {
|
function prettyState(state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "active":
|
case "active":
|
||||||
return "✓ Active";
|
return "Decoding";
|
||||||
case "out_of_span":
|
case "out_of_span":
|
||||||
return "△ Out of span";
|
return "Out of span";
|
||||||
case "waiting_for_spectrum":
|
case "waiting_for_spectrum":
|
||||||
return "△ Waiting";
|
return "Waiting for spectrum";
|
||||||
case "waiting_for_user":
|
case "waiting_for_user":
|
||||||
return "△ No user";
|
return "Nobody listening";
|
||||||
case "missing_bookmark":
|
case "missing_bookmark":
|
||||||
return "✗ Missing";
|
return "Bookmark gone";
|
||||||
case "no_supported_decoders":
|
case "no_supported_decoders":
|
||||||
return "✗ Unsupported";
|
return "No decoder";
|
||||||
case "disabled":
|
case "disabled":
|
||||||
return "△ Disabled";
|
return "Off";
|
||||||
case "handled_by_scheduler":
|
case "handled_by_scheduler":
|
||||||
return "△ Scheduler";
|
|
||||||
case "scheduler_has_control":
|
case "scheduler_has_control":
|
||||||
return "△ Scheduler";
|
return "Scheduler has it";
|
||||||
case "handled_by_virtual_channel":
|
case "handled_by_virtual_channel":
|
||||||
return "△ VChan";
|
return "On a channel";
|
||||||
|
case "pending":
|
||||||
|
return "Not saved";
|
||||||
default:
|
default:
|
||||||
return "△ Inactive";
|
return "Idle";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function setCheckbox(id, value) {
|
function setCheckbox(id, value) {
|
||||||
@@ -285,13 +344,21 @@ var bgdWindow = window;
|
|||||||
function markBgdDirty() {
|
function markBgdDirty() {
|
||||||
if (bgdDirty) return;
|
if (bgdDirty) return;
|
||||||
bgdDirty = true;
|
bgdDirty = true;
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
syncSaveButton();
|
||||||
if (btn) btn.classList.add("sch-dirty");
|
|
||||||
}
|
}
|
||||||
function clearBgdDirty() {
|
function clearBgdDirty() {
|
||||||
bgdDirty = false;
|
bgdDirty = false;
|
||||||
|
syncSaveButton();
|
||||||
|
}
|
||||||
|
function syncSaveButton() {
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
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) {
|
function showToast(msg, isError) {
|
||||||
const el = document.getElementById("background-decode-toast");
|
const el = document.getElementById("background-decode-toast");
|
||||||
@@ -311,7 +378,7 @@ var bgdWindow = window;
|
|||||||
return bm.id;
|
return bm.id;
|
||||||
});
|
});
|
||||||
currentConfig.bookmark_ids = ids;
|
currentConfig.bookmark_ids = ids;
|
||||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
renderBookmarkChecklist(currentFilterText());
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
function deselectAllBookmarks() {
|
function deselectAllBookmarks() {
|
||||||
@@ -319,7 +386,7 @@ var bgdWindow = window;
|
|||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
currentConfig.bookmark_ids = [];
|
currentConfig.bookmark_ids = [];
|
||||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
renderBookmarkChecklist(currentFilterText());
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
function wireBackgroundDecodeEvents() {
|
function wireBackgroundDecodeEvents() {
|
||||||
@@ -334,7 +401,9 @@ var bgdWindow = window;
|
|||||||
if (enabledCb && !enabledCb._wired) {
|
if (enabledCb && !enabledCb._wired) {
|
||||||
enabledCb._wired = true;
|
enabledCb._wired = true;
|
||||||
enabledCb.addEventListener("change", function() {
|
enabledCb.addEventListener("change", function() {
|
||||||
|
if (currentConfig) currentConfig.enabled = enabledCb.checked;
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
|
renderBookmarkChecklist(currentFilterText());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
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="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
|
||||||
<div id="background-decode-panel" class="sch-panel">
|
<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>
|
<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">
|
||||||
<div class="sch-section-title">Configuration</div>
|
<div class="sch-section-title">Background Decode</div>
|
||||||
<div class="sch-row">
|
<p class="bgd-intro">
|
||||||
<label class="sch-label bgd-toggle-wrap">Background decode
|
Decodes saved bookmarks on hidden channels while you work another band.
|
||||||
<span class="bgd-toggle-row">
|
Needs an SDR rig, and a bookmark is only decoded while it falls inside
|
||||||
<input type="checkbox" id="background-decode-enabled" />
|
the span the rig is tuned across.
|
||||||
<span>Enable hidden background decoder channels</span>
|
</p>
|
||||||
</span>
|
<div class="bgd-controls">
|
||||||
|
<label class="bgd-enable">
|
||||||
|
<input type="checkbox" id="background-decode-enabled" />
|
||||||
|
<span>Enabled</span>
|
||||||
</label>
|
</label>
|
||||||
|
<span id="bgd-span-summary" class="bgd-span" aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sch-row" style="flex-direction:column;gap:0.5rem;">
|
<div class="bgd-list-toolbar">
|
||||||
<label class="sch-label" style="min-width:100%;">Bookmarks
|
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter"
|
||||||
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter" placeholder="Filter bookmarks..." />
|
placeholder="Filter bookmarks…" aria-label="Filter bookmarks" />
|
||||||
</label>
|
|
||||||
<div class="bgd-select-actions">
|
<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-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="Deselect all bookmarks">Deselect All</button>
|
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Select no bookmarks">None</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="bgd-bookmark-checklist" class="bgd-checklist"></div>
|
|
||||||
</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>
|
||||||
|
|
||||||
<div class="sch-actions">
|
<div class="sch-actions">
|
||||||
|
|||||||
@@ -5589,18 +5589,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
#sch-sat-form-cancel:hover {
|
#sch-sat-form-cancel:hover {
|
||||||
background: var(--border);
|
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 {
|
.bgd-add-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
@@ -5624,48 +5612,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.78rem;
|
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" status card (top of scheduler & bgd panels) ──── */
|
||||||
.now-playing-card {
|
.now-playing-card {
|
||||||
border-left: 3px solid var(--accent-green);
|
border-left: 3px solid var(--accent-green);
|
||||||
@@ -5881,18 +5827,65 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
opacity: 0.7;
|
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 {
|
.bgd-checklist {
|
||||||
max-height: 16rem;
|
max-height: 22rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
/* One row per bookmark: pick it here, and read what it is doing here too. */
|
||||||
.bgd-checklist-row {
|
.bgd-checklist-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(6rem, 1fr) auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
padding: 0.45rem 0.65rem;
|
padding: 0.5rem 0.65rem;
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -5904,28 +5897,83 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
.bgd-checklist-row:hover {
|
.bgd-checklist-row:hover {
|
||||||
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
|
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"] {
|
.bgd-checklist-row input[type="checkbox"] {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.bgd-checklist-name {
|
.bgd-checklist-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
overflow: hidden;
|
||||||
.bgd-checklist-meta {
|
text-overflow: ellipsis;
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
margin-left: auto;
|
|
||||||
white-space: nowrap;
|
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 {
|
.bgd-checklist-empty {
|
||||||
padding: 0.75rem;
|
padding: 1rem 0.75rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
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 {
|
.bgd-select-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.bgd-select-btn {
|
.bgd-select-btn {
|
||||||
@@ -5943,15 +5991,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
color: var(--text);
|
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) {
|
@media (max-width: 600px) {
|
||||||
.channel-scheduler-controls {
|
.channel-scheduler-controls {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -5992,8 +6031,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
.bm-page-summary {
|
.bm-page-summary {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.bgd-add-row,
|
.bgd-add-row {
|
||||||
.bgd-status-row {
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
@@ -6003,8 +6041,17 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
.interleave-ring-label {
|
.interleave-ring-label {
|
||||||
max-width: 8rem;
|
max-width: 8rem;
|
||||||
}
|
}
|
||||||
.bgd-checklist-meta {
|
/* Two lines rather than four columns: the name and its state stay legible
|
||||||
margin-left: 0;
|
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;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"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"
|
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1468,9 +1468,13 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
|
|||||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||||
updateRigSubtitle(lastActiveRigId);
|
updateRigSubtitle(lastActiveRigId);
|
||||||
window.trxUi?.setActiveRig(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) {
|
if (rigListChanged) {
|
||||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
|
||||||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
|
||||||
window.trx.modules.bookmarks?.populateScopePicker();
|
window.trx.modules.bookmarks?.populateScopePicker();
|
||||||
void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
|
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() {
|
function initSettingsUI() {
|
||||||
|
settingsUiReady = true;
|
||||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
||||||
window.trx.modules.scheduler?.wireEvents();
|
window.trx.modules.scheduler?.wireEvents();
|
||||||
if (window.trx.modules.backgroundDecode) {
|
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
|
// 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.
|
// 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
|
// Start the app
|
||||||
void initializeApp();
|
void initializeApp();
|
||||||
|
|||||||
+149
-64
@@ -66,10 +66,17 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
let bookmarkList: Bookmark[] = [];
|
let bookmarkList: Bookmark[] = [];
|
||||||
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let bgdDirty = false;
|
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 {
|
function initBackgroundDecode(rigId: string | null, role: string | null): void {
|
||||||
backgroundDecodeRole = role;
|
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();
|
if (currentRigId) loadBackgroundDecode();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
}
|
}
|
||||||
@@ -171,7 +178,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||||
renderBookmarkChecklist();
|
renderBookmarkChecklist();
|
||||||
|
|
||||||
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
const isControl = isControlRole();
|
||||||
const panel = document.getElementById("background-decode-panel");
|
const panel = document.getElementById("background-decode-panel");
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
|
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");
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||||
if (resetBtn) resetBtn.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 {
|
function renderBookmarkChecklist(filterText = ""): void {
|
||||||
@@ -203,26 +215,65 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
: all;
|
: all;
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
container.innerHTML = '<div class="bgd-checklist-empty">' +
|
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
|
||||||
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
|
renderSelectionSummary();
|
||||||
'</div>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered.forEach(function (bookmark) {
|
filtered.forEach(function (bookmark) {
|
||||||
const row = document.createElement("label");
|
const row = document.createElement("label");
|
||||||
row.className = "bgd-checklist-row";
|
row.className = "bgd-checklist-row";
|
||||||
|
row.dataset.bmId = bookmark.id;
|
||||||
const decoders = bookmarkDecoderKinds(bookmark);
|
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 =
|
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-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) {
|
row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
|
||||||
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
|
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
|
||||||
});
|
});
|
||||||
container.appendChild(row);
|
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 {
|
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
|
||||||
@@ -260,7 +311,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||||
})
|
})
|
||||||
.finally(function () {
|
.finally(function () {
|
||||||
if (btn) btn.disabled = false;
|
syncSaveButton();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,58 +346,77 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(status: BackgroundDecodeStatus): void {
|
function renderStatus(status: BackgroundDecodeStatus): void {
|
||||||
const card = document.getElementById("background-decode-status-card");
|
lastStatus = status;
|
||||||
if (!card) return;
|
statusByBookmark = new Map(
|
||||||
const entries = status.entries ?? [];
|
(status.entries ?? [])
|
||||||
if (!entries.length) {
|
.filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0)
|
||||||
card.textContent = "No background decode bookmarks configured.";
|
.map((entry) => [entry.bookmark_id as string, entry]),
|
||||||
return;
|
);
|
||||||
}
|
renderSpanSummary();
|
||||||
const summary = [];
|
// Rows carry the state, so the list repaints — keeping the filter the
|
||||||
if (status.active_rig) {
|
// operator typed and the selection they have not saved yet.
|
||||||
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
renderBookmarkChecklist(currentFilterText());
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
function prettyState(state: string | undefined): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "active": return "\u2713 Active";
|
case "active": return "Decoding";
|
||||||
case "out_of_span": return "\u25B3 Out of span";
|
case "out_of_span": return "Out of span";
|
||||||
case "waiting_for_spectrum": return "\u25B3 Waiting";
|
case "waiting_for_spectrum": return "Waiting for spectrum";
|
||||||
case "waiting_for_user": return "\u25B3 No user";
|
case "waiting_for_user": return "Nobody listening";
|
||||||
case "missing_bookmark": return "\u2717 Missing";
|
case "missing_bookmark": return "Bookmark gone";
|
||||||
case "no_supported_decoders": return "\u2717 Unsupported";
|
case "no_supported_decoders": return "No decoder";
|
||||||
case "disabled": return "\u25B3 Disabled";
|
case "disabled": return "Off";
|
||||||
case "handled_by_scheduler": return "\u25B3 Scheduler";
|
case "handled_by_scheduler":
|
||||||
case "scheduler_has_control": return "\u25B3 Scheduler";
|
case "scheduler_has_control": return "Scheduler has it";
|
||||||
case "handled_by_virtual_channel": return "\u25B3 VChan";
|
case "handled_by_virtual_channel": return "On a channel";
|
||||||
default: return "\u25B3 Inactive";
|
case "pending": return "Not saved";
|
||||||
|
default: return "Idle";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,14 +450,25 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
function markBgdDirty() {
|
function markBgdDirty() {
|
||||||
if (bgdDirty) return;
|
if (bgdDirty) return;
|
||||||
bgdDirty = true;
|
bgdDirty = true;
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
syncSaveButton();
|
||||||
if (btn) btn.classList.add("sch-dirty");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearBgdDirty() {
|
function clearBgdDirty() {
|
||||||
bgdDirty = false;
|
bgdDirty = false;
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
syncSaveButton();
|
||||||
if (btn) btn.classList.remove("sch-dirty");
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
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; });
|
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
||||||
currentConfig.bookmark_ids = ids;
|
currentConfig.bookmark_ids = ids;
|
||||||
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
renderBookmarkChecklist(currentFilterText());
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,7 +497,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
currentConfig.bookmark_ids = [];
|
currentConfig.bookmark_ids = [];
|
||||||
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
renderBookmarkChecklist(currentFilterText());
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +513,11 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
|
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
|
||||||
if (enabledCb && !enabledCb._wired) {
|
if (enabledCb && !enabledCb._wired) {
|
||||||
enabledCb._wired = true;
|
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;
|
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: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
||||||
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
||||||
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
|
{ 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([
|
const CONTENT_TYPES = new Map([
|
||||||
[".css", "text/css; charset=utf-8"],
|
[".css", "text/css; charset=utf-8"],
|
||||||
@@ -132,6 +139,7 @@ export async function startWebFixture({
|
|||||||
mode = "FM",
|
mode = "FM",
|
||||||
history = {},
|
history = {},
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
|
backgroundDecode = null,
|
||||||
bandplan = {},
|
bandplan = {},
|
||||||
bandplanEnabled = false,
|
bandplanEnabled = false,
|
||||||
bandplanUnauthorizedFirst = false,
|
bandplanUnauthorizedFirst = false,
|
||||||
@@ -271,6 +279,34 @@ export async function startWebFixture({
|
|||||||
response.writeHead(200).end();
|
response.writeHead(200).end();
|
||||||
return;
|
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") {
|
if (url.pathname === "/select_rig" && request.method === "POST") {
|
||||||
const remote = url.searchParams.get("remote");
|
const remote = url.searchParams.get("remote");
|
||||||
if (remote) {
|
if (remote) {
|
||||||
|
|||||||
Reference in New Issue
Block a user