[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);
|
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