feat(ui): improve radio operator experience
This commit was merged in pull request #15.
This commit is contained in:
@@ -1317,6 +1317,9 @@ function showHint(msg, duration) {
|
||||
powerHint.textContent = msg;
|
||||
if (hintTimer) clearTimeout(hintTimer);
|
||||
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration);
|
||||
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
|
||||
window.trxUi?.notify(msg, { kind: "error" });
|
||||
}
|
||||
}
|
||||
let supportedModes = [];
|
||||
let supportedBands = [];
|
||||
@@ -2831,7 +2834,7 @@ function showUnsupportedFreqPopup(hz) {
|
||||
const now = Date.now();
|
||||
if (now - lastUnsupportedFreqPopupAt < 1200) return;
|
||||
lastUnsupportedFreqPopupAt = now;
|
||||
window.alert(message);
|
||||
window.trxUi?.notify(message.replaceAll("\n", " "), { kind: "error", duration: 7000 });
|
||||
}
|
||||
|
||||
// Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit).
|
||||
@@ -3429,7 +3432,11 @@ function render(update) {
|
||||
if (update.status && typeof update.status.tx_en === "boolean" && update.status.tx_en !== prevRenderData.txEn) {
|
||||
prevRenderData.txEn = update.status.tx_en;
|
||||
lastTxEn = update.status.tx_en;
|
||||
pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off";
|
||||
window.trxUi?.setButtonState(pttBtn, {
|
||||
active: update.status.tx_en,
|
||||
activeLabel: "Stop TX",
|
||||
inactiveLabel: "Start TX",
|
||||
});
|
||||
if (update.status.tx_en) {
|
||||
pttBtn.style.background = "var(--accent-red)";
|
||||
pttBtn.style.borderColor = "var(--accent-red)";
|
||||
@@ -3538,11 +3545,15 @@ function render(update) {
|
||||
bandLabel.textContent = typeof update.band === "string" ? update.band : "--";
|
||||
}
|
||||
if (typeof update.enabled === "boolean") {
|
||||
powerBtn.disabled = false;
|
||||
powerBtn.textContent = update.enabled ? "Power Off" : "Power On";
|
||||
window.trxUi?.setButtonState(powerBtn, {
|
||||
active: update.enabled,
|
||||
activeLabel: "Power Off",
|
||||
inactiveLabel: "Power On",
|
||||
});
|
||||
} else {
|
||||
powerBtn.disabled = true;
|
||||
powerBtn.textContent = "Toggle Power";
|
||||
powerBtn.textContent = "Power unavailable";
|
||||
powerBtn.setAttribute("aria-pressed", "false");
|
||||
powerHint.textContent = "State unknown";
|
||||
}
|
||||
lastControl = update.enabled;
|
||||
@@ -3657,7 +3668,11 @@ function render(update) {
|
||||
}
|
||||
powerHint.textContent = readyText();
|
||||
lastLocked = update.status && update.status.lock === true;
|
||||
lockBtn.textContent = lastLocked ? "Unlock" : "Lock";
|
||||
window.trxUi?.setButtonState(lockBtn, {
|
||||
active: lastLocked,
|
||||
activeLabel: "Unlock Tuning",
|
||||
inactiveLabel: "Lock Tuning",
|
||||
});
|
||||
|
||||
const tx = update.status && update.status.tx ? update.status.tx : null;
|
||||
txMeters.style.display = lastHasTx ? "" : "none";
|
||||
@@ -3930,8 +3945,15 @@ if (headerRigSwitchSelect) {
|
||||
headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); });
|
||||
}
|
||||
|
||||
function setControlPending(control, pending) {
|
||||
if (!control) return;
|
||||
control.disabled = pending;
|
||||
control.classList.toggle("is-busy", pending);
|
||||
control.setAttribute("aria-busy", String(pending));
|
||||
}
|
||||
|
||||
powerBtn.addEventListener("click", async () => {
|
||||
powerBtn.disabled = true;
|
||||
setControlPending(powerBtn, true);
|
||||
showHint("Sending...");
|
||||
try {
|
||||
await postPath("/toggle_power");
|
||||
@@ -3940,12 +3962,12 @@ powerBtn.addEventListener("click", async () => {
|
||||
showHint("Toggle failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
powerBtn.disabled = false;
|
||||
setControlPending(powerBtn, false);
|
||||
}
|
||||
});
|
||||
|
||||
pttBtn.addEventListener("click", async () => {
|
||||
pttBtn.disabled = true;
|
||||
setControlPending(pttBtn, true);
|
||||
showHint("Toggling PTT…");
|
||||
try {
|
||||
const desired = lastTxEn ? "false" : "true";
|
||||
@@ -3955,7 +3977,7 @@ pttBtn.addEventListener("click", async () => {
|
||||
showHint("PTT toggle failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
pttBtn.disabled = false;
|
||||
setControlPending(pttBtn, false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3988,7 +4010,7 @@ async function applyCenterFreqFromInput() {
|
||||
return;
|
||||
}
|
||||
centerFreqDirty = false;
|
||||
centerFreqEl.disabled = true;
|
||||
setControlPending(centerFreqEl, true);
|
||||
showHint("Setting central frequency…");
|
||||
try {
|
||||
await postPath(`/set_center_freq?hz=${parsed}`);
|
||||
@@ -3997,7 +4019,7 @@ async function applyCenterFreqFromInput() {
|
||||
showHint("Set central freq failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
centerFreqEl.disabled = false;
|
||||
setControlPending(centerFreqEl, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4198,7 +4220,7 @@ async function applyModeFromPicker() {
|
||||
return;
|
||||
}
|
||||
updateWfmControls();
|
||||
modeEl.disabled = true;
|
||||
setControlPending(modeEl, true);
|
||||
showHint("Setting mode…");
|
||||
try {
|
||||
if (typeof vchanInterceptMode === "function" && await vchanInterceptMode(mode)) {
|
||||
@@ -4216,7 +4238,7 @@ async function applyModeFromPicker() {
|
||||
showHint("Set mode failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
modeEl.disabled = false;
|
||||
setControlPending(modeEl, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4235,7 +4257,7 @@ txLimitBtn.addEventListener("click", async () => {
|
||||
showHint("Limit missing", 1500);
|
||||
return;
|
||||
}
|
||||
txLimitBtn.disabled = true;
|
||||
setControlPending(txLimitBtn, true);
|
||||
showHint("Setting TX limit…");
|
||||
try {
|
||||
await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`);
|
||||
@@ -4244,22 +4266,22 @@ txLimitBtn.addEventListener("click", async () => {
|
||||
showHint("TX limit failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
txLimitBtn.disabled = false;
|
||||
setControlPending(txLimitBtn, false);
|
||||
}
|
||||
});
|
||||
|
||||
lockBtn.addEventListener("click", async () => {
|
||||
lockBtn.disabled = true;
|
||||
setControlPending(lockBtn, true);
|
||||
showHint("Toggling lock…");
|
||||
try {
|
||||
const nextLock = lockBtn.textContent === "Lock";
|
||||
const nextLock = !lastLocked;
|
||||
await postPath(nextLock ? "/lock" : "/unlock");
|
||||
showHint("Lock toggled", 1500);
|
||||
} catch (err) {
|
||||
showHint("Lock toggle failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
lockBtn.disabled = false;
|
||||
setControlPending(lockBtn, false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4326,7 +4348,8 @@ async function applyBwDefaultForMode(mode, sendToServer) {
|
||||
scheduleSpectrumDraw();
|
||||
}
|
||||
if (sendToServer) {
|
||||
try { await postPath(`/set_bandwidth?hz=${def}`); } catch (_) {}
|
||||
try { await postPath(`/set_bandwidth?hz=${def}`); }
|
||||
catch (error) { window.trxUi?.notify("Default bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: () => applyBwDefaultForMode(mode, true) } }); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4353,7 +4376,9 @@ async function applyBandwidthFromInput() {
|
||||
if (Number.isFinite(lastFreqHz)) {
|
||||
await ensureTunedBandwidthCoverage(lastFreqHz);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (error) {
|
||||
window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } });
|
||||
}
|
||||
}
|
||||
|
||||
function estimateOccupiedBandwidth(data, centerHz, interference = {}) {
|
||||
@@ -4471,13 +4496,24 @@ async function applyAutoBandwidth() {
|
||||
if (lastSpectrumData) {
|
||||
scheduleSpectrumDraw();
|
||||
}
|
||||
const mode = (modeEl?.value || "").toUpperCase();
|
||||
let reason = "measured occupied spectrum";
|
||||
if (mode === "WFM") {
|
||||
if (estimated === 60_000 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`;
|
||||
else if (estimated === 60_000) reason = "weak-signal noise rejection";
|
||||
else if (lastWfmAci >= lastWfmCci && lastWfmAci >= 10) reason = `${Math.round(lastWfmAci)}% ACI cap`;
|
||||
else if (lastWfmCci >= 10) reason = `${Math.round(lastWfmCci)}% CCI confidence cap`;
|
||||
}
|
||||
window.trxUi?.notify(`Auto BW: ${formatBwLabel(estimated)} — ${reason}`, { kind: "success", duration: 5000 });
|
||||
try {
|
||||
if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return;
|
||||
await postPath(`/set_bandwidth?hz=${estimated}`);
|
||||
if (Number.isFinite(lastFreqHz)) {
|
||||
await ensureTunedBandwidthCoverage(lastFreqHz);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (error) {
|
||||
window.trxUi?.notify("Automatic bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: applyAutoBandwidth } });
|
||||
}
|
||||
}
|
||||
|
||||
if (spectrumBwInput) {
|
||||
@@ -4576,6 +4612,7 @@ function navigateToTab(name, options = {}) {
|
||||
_activeTab = name;
|
||||
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
|
||||
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
|
||||
const panel = document.getElementById(`tab-${name}`);
|
||||
panel.style.display = "";
|
||||
@@ -4604,6 +4641,7 @@ function navigateToTab(name, options = {}) {
|
||||
refreshRecorderStatus();
|
||||
}
|
||||
}
|
||||
window.navigateToTab = navigateToTab;
|
||||
|
||||
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".tab[data-tab]");
|
||||
@@ -4762,7 +4800,7 @@ if (headerAuthBtn) {
|
||||
headerAuthBtn.addEventListener("click", async () => {
|
||||
if (authRole) {
|
||||
// Logged in - show logout confirmation
|
||||
if (confirm("Are you sure you want to logout?")) {
|
||||
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
|
||||
await authLogout();
|
||||
}
|
||||
} else {
|
||||
@@ -4963,11 +5001,15 @@ function latLonToMaidenhead(lat, lon) {
|
||||
function _wireSubTabBar(bar) {
|
||||
if (bar._subtabWired) return;
|
||||
bar._subtabWired = true;
|
||||
window.trxUi?.prepareTabList(bar, "secondary");
|
||||
bar.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".sub-tab[data-subtab]");
|
||||
if (!btn) return;
|
||||
bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
window.trxUi?.syncSelectedTab(bar, btn);
|
||||
const decoderPicker = document.getElementById("decoder-tab-select");
|
||||
if (decoderPicker && btn.closest("#tab-digital-modes")) decoderPicker.value = btn.dataset.subtab;
|
||||
const parent = bar.parentElement;
|
||||
parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
|
||||
const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`);
|
||||
@@ -5458,6 +5500,7 @@ function configureRxStream(nextInfo) {
|
||||
ensureRxAudioContext(nextSampleRate);
|
||||
rxGainNode.gain.value = rxVolSlider.value / 100;
|
||||
rxActive = true;
|
||||
window.trxUi?.setButtonState(rxAudioBtn, { active: true, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
|
||||
setAudioLevel(0);
|
||||
rxAudioBtn.style.borderColor = "#00d17f";
|
||||
rxAudioBtn.style.color = "#00d17f";
|
||||
@@ -5659,6 +5702,7 @@ function startRxAudio() {
|
||||
// If TX was active when WS closed, release PTT
|
||||
if (txActive) { stopTxAudio(); }
|
||||
rxActive = false;
|
||||
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
|
||||
streamInfo = null;
|
||||
updateWfmControls();
|
||||
rxAudioBtn.style.borderColor = "";
|
||||
@@ -5685,6 +5729,7 @@ function startRxAudio() {
|
||||
|
||||
function stopRxAudio() {
|
||||
rxActive = false;
|
||||
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
|
||||
streamInfo = null;
|
||||
if (audioWs) { audioWs.close(); audioWs = null; }
|
||||
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
||||
@@ -5723,6 +5768,7 @@ function startTxAudio() {
|
||||
}).then(async (stream) => {
|
||||
txStream = stream;
|
||||
txActive = true;
|
||||
window.trxUi?.setButtonState(txAudioBtn, { active: true, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
|
||||
txAudioBtn.style.borderColor = "#e55353";
|
||||
txAudioBtn.style.color = "#e55353";
|
||||
audioStatus.textContent = "RX+TX";
|
||||
@@ -5800,6 +5846,7 @@ function startTxAudio() {
|
||||
async function stopTxAudio() {
|
||||
if (!txActive) return;
|
||||
txActive = false;
|
||||
window.trxUi?.setButtonState(txAudioBtn, { active: false, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
|
||||
clearTxTimeout();
|
||||
|
||||
// Release PTT automatically
|
||||
@@ -6045,7 +6092,7 @@ function renderRecorderFiles() {
|
||||
el.querySelectorAll(".rec-delete-btn").forEach(function (btn) {
|
||||
btn.addEventListener("click", async function () {
|
||||
const name = btn.dataset.name;
|
||||
if (!confirm("Delete recording " + name + "?")) return;
|
||||
if (!await window.trxUi.confirm({ title: "Delete recording?", message: `${name} will be permanently removed.`, confirmLabel: "Delete" })) return;
|
||||
try {
|
||||
const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" });
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
@@ -6053,6 +6100,7 @@ function renderRecorderFiles() {
|
||||
renderRecorderFiles();
|
||||
} catch (e) {
|
||||
console.error("Delete failed", e);
|
||||
window.trxUi?.notify("Recording could not be deleted", { kind: "error" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6989,6 +7037,13 @@ function startSpectrumStreaming() {
|
||||
const rds = lastSpectrumData?.rds;
|
||||
lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds };
|
||||
window.lastSpectrumData = lastSpectrumData;
|
||||
const spectrumSummary = document.getElementById("spectrum-text-summary");
|
||||
if (spectrumSummary && bins.length) {
|
||||
let peakIndex = 0;
|
||||
for (let i = 1; i < bins.length; i += 1) if (bins[i] > bins[peakIndex]) peakIndex = i;
|
||||
const peakHz = centerHz - sampleRate / 2 + (peakIndex / Math.max(1, bins.length - 1)) * sampleRate;
|
||||
spectrumSummary.textContent = `Spectrum centered at ${formatFreqForHumans(centerHz)}, spanning ${formatFreqForHumans(sampleRate)}. Strongest visible bin near ${formatFreqForHumans(peakHz)} at ${bins[peakIndex]} dB.`;
|
||||
}
|
||||
// Server confirmed a new center — clear optimistic pending value.
|
||||
if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1000) {
|
||||
spectrumCenterPendingHz = null;
|
||||
@@ -8451,7 +8506,12 @@ if (spectrumCanvas || overviewCanvas) {
|
||||
await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (error) {
|
||||
window.trxUi?.notify("Bandwidth could not be changed", {
|
||||
kind: "error",
|
||||
action: { label: "Retry", run: () => postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`) },
|
||||
});
|
||||
}
|
||||
_bwDragEdge = null;
|
||||
_bwDragCanvas = null;
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user