[feat](trx-frontend-http): persist settings and APRS state across refresh
Add localStorage persistence (trx_ prefix) for UI settings: - Jog step, RX/TX volume (app.js) - CW WPM, tone, threshold, auto-detect flags (cw.js) - APRS decoded packets and running state (aprs.js) APRS decoder auto-restarts on page refresh if it was active, and all decoded packets plus map markers are restored from storage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -1,3 +1,15 @@
|
|||||||
|
// --- Persistent settings (localStorage) ---
|
||||||
|
const STORAGE_PREFIX = "trx_";
|
||||||
|
function saveSetting(key, value) {
|
||||||
|
try { localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); } catch(e) {}
|
||||||
|
}
|
||||||
|
function loadSetting(key, fallback) {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(STORAGE_PREFIX + key);
|
||||||
|
return v !== null ? JSON.parse(v) : fallback;
|
||||||
|
} catch(e) { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
const freqEl = document.getElementById("freq");
|
const freqEl = document.getElementById("freq");
|
||||||
const modeEl = document.getElementById("mode");
|
const modeEl = document.getElementById("mode");
|
||||||
const bandLabel = document.getElementById("band-label");
|
const bandLabel = document.getElementById("band-label");
|
||||||
@@ -32,7 +44,7 @@ let hintTimer = null;
|
|||||||
let sigMeasuring = false;
|
let sigMeasuring = false;
|
||||||
let sigSamples = [];
|
let sigSamples = [];
|
||||||
let lastFreqHz = null;
|
let lastFreqHz = null;
|
||||||
let jogStep = 1000; // default 1 kHz
|
let jogStep = loadSetting("jogStep", 1000);
|
||||||
const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"];
|
const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"];
|
||||||
function vfoColor(idx) {
|
function vfoColor(idx) {
|
||||||
if (idx < VFO_COLORS.length) return VFO_COLORS[idx];
|
if (idx < VFO_COLORS.length) return VFO_COLORS[idx];
|
||||||
@@ -562,6 +574,12 @@ jogStepEl.addEventListener("click", (e) => {
|
|||||||
jogStep = parseInt(btn.dataset.step, 10);
|
jogStep = parseInt(btn.dataset.step, 10);
|
||||||
jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active"));
|
jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active"));
|
||||||
btn.classList.add("active");
|
btn.classList.add("active");
|
||||||
|
saveSetting("jogStep", jogStep);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore active jog step button from saved setting
|
||||||
|
jogStepEl.querySelectorAll("button").forEach((b) => {
|
||||||
|
b.classList.toggle("active", parseInt(b.dataset.step, 10) === jogStep);
|
||||||
});
|
});
|
||||||
|
|
||||||
modeBtn.addEventListener("click", async () => {
|
modeBtn.addEventListener("click", async () => {
|
||||||
@@ -1076,24 +1094,31 @@ txAudioBtn.addEventListener("click", startTxAudio);
|
|||||||
const rxVolPct = document.getElementById("rx-vol-pct");
|
const rxVolPct = document.getElementById("rx-vol-pct");
|
||||||
const txVolPct = document.getElementById("tx-vol-pct");
|
const txVolPct = document.getElementById("tx-vol-pct");
|
||||||
|
|
||||||
|
// Restore saved volumes
|
||||||
|
rxVolSlider.value = loadSetting("rxVol", 80);
|
||||||
|
txVolSlider.value = loadSetting("txVol", 80);
|
||||||
|
rxVolPct.textContent = `${rxVolSlider.value}%`;
|
||||||
|
txVolPct.textContent = `${txVolSlider.value}%`;
|
||||||
|
|
||||||
function updateVolSlider(slider, pctEl, gainNode) {
|
function updateVolSlider(slider, pctEl, gainNode) {
|
||||||
pctEl.textContent = `${slider.value}%`;
|
pctEl.textContent = `${slider.value}%`;
|
||||||
if (gainNode) gainNode.gain.value = slider.value / 100;
|
if (gainNode) gainNode.gain.value = slider.value / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
rxVolSlider.addEventListener("input", () => updateVolSlider(rxVolSlider, rxVolPct, rxGainNode));
|
rxVolSlider.addEventListener("input", () => { updateVolSlider(rxVolSlider, rxVolPct, rxGainNode); saveSetting("rxVol", Number(rxVolSlider.value)); });
|
||||||
txVolSlider.addEventListener("input", () => updateVolSlider(txVolSlider, txVolPct, txGainNode));
|
txVolSlider.addEventListener("input", () => { updateVolSlider(txVolSlider, txVolPct, txGainNode); saveSetting("txVol", Number(txVolSlider.value)); });
|
||||||
|
|
||||||
function volWheel(slider, pctEl, getGain) {
|
function volWheel(slider, pctEl, getGain, storageKey) {
|
||||||
slider.addEventListener("wheel", (e) => {
|
slider.addEventListener("wheel", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const step = e.deltaY < 0 ? 2 : -2;
|
const step = e.deltaY < 0 ? 2 : -2;
|
||||||
slider.value = Math.max(0, Math.min(100, Number(slider.value) + step));
|
slider.value = Math.max(0, Math.min(100, Number(slider.value) + step));
|
||||||
updateVolSlider(slider, pctEl, getGain());
|
updateVolSlider(slider, pctEl, getGain());
|
||||||
|
saveSetting(storageKey, Number(slider.value));
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
}
|
}
|
||||||
volWheel(rxVolSlider, rxVolPct, () => rxGainNode);
|
volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol");
|
||||||
volWheel(txVolSlider, txVolPct, () => txGainNode);
|
volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol");
|
||||||
|
|
||||||
document.getElementById("copyright-year").textContent = new Date().getFullYear();
|
document.getElementById("copyright-year").textContent = new Date().getFullYear();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ let aprsWs = null;
|
|||||||
let aprsAudioCtx = null;
|
let aprsAudioCtx = null;
|
||||||
let aprsDecoder = null;
|
let aprsDecoder = null;
|
||||||
|
|
||||||
|
// Persistent packet history
|
||||||
|
let aprsPacketHistory = loadSetting("aprsPackets", []);
|
||||||
|
|
||||||
// CRC-16-CCITT lookup table
|
// CRC-16-CCITT lookup table
|
||||||
const CRC_CCITT_TABLE = new Uint16Array(256);
|
const CRC_CCITT_TABLE = new Uint16Array(256);
|
||||||
(function initCrc() {
|
(function initCrc() {
|
||||||
@@ -448,14 +451,11 @@ function escapeAprsInfo(str) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAprsPacket(pkt) {
|
function renderAprsRow(pkt) {
|
||||||
const tag = pkt.crcOk ? "[APRS]" : "[APRS-CRC-FAIL]";
|
|
||||||
console.log(tag, `${pkt.srcCall}>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: ${pkt.info}`, pkt);
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "aprs-packet";
|
row.className = "aprs-packet";
|
||||||
if (!pkt.crcOk) row.style.opacity = "0.5";
|
if (!pkt.crcOk) row.style.opacity = "0.5";
|
||||||
const now = new Date();
|
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
const ts = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
const crcTag = pkt.crcOk ? "" : ' <span style="color:var(--accent-red);">[CRC]</span>';
|
const crcTag = pkt.crcOk ? "" : ' <span style="color:var(--accent-red);">[CRC]</span>';
|
||||||
let symbolHtml = "";
|
let symbolHtml = "";
|
||||||
if (pkt.symbolTable && pkt.symbolCode) {
|
if (pkt.symbolTable && pkt.symbolCode) {
|
||||||
@@ -473,6 +473,22 @@ function addAprsPacket(pkt) {
|
|||||||
posHtml = ` <a class="aprs-pos" href="${osmUrl}" target="_blank">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`;
|
posHtml = ` <a class="aprs-pos" href="${osmUrl}" target="_blank">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`;
|
||||||
}
|
}
|
||||||
row.innerHTML = `<span class="aprs-time">${ts}</span>${symbolHtml}<span class="aprs-call">${pkt.srcCall}</span>>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: <span title="${pkt.type}">${escapeAprsInfo(pkt.info)}</span>${posHtml}${crcTag}`;
|
row.innerHTML = `<span class="aprs-time">${ts}</span>${symbolHtml}<span class="aprs-call">${pkt.srcCall}</span>>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: <span title="${pkt.type}">${escapeAprsInfo(pkt.info)}</span>${posHtml}${crcTag}`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAprsPacket(pkt) {
|
||||||
|
const tag = pkt.crcOk ? "[APRS]" : "[APRS-CRC-FAIL]";
|
||||||
|
console.log(tag, `${pkt.srcCall}>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: ${pkt.info}`, pkt);
|
||||||
|
|
||||||
|
// Stamp timestamp for persistence
|
||||||
|
pkt._ts = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
|
||||||
|
// Persist to history
|
||||||
|
aprsPacketHistory.unshift(pkt);
|
||||||
|
if (aprsPacketHistory.length > APRS_MAX_PACKETS) aprsPacketHistory.length = APRS_MAX_PACKETS;
|
||||||
|
saveSetting("aprsPackets", aprsPacketHistory);
|
||||||
|
|
||||||
|
const row = renderAprsRow(pkt);
|
||||||
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
|
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
|
||||||
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode);
|
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode);
|
||||||
}
|
}
|
||||||
@@ -483,7 +499,7 @@ function addAprsPacket(pkt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startAprs() {
|
function startAprs() {
|
||||||
if (aprsActive) { stopAprs(); return; }
|
if (aprsActive) return;
|
||||||
if (!hasWebCodecs) {
|
if (!hasWebCodecs) {
|
||||||
aprsStatus.textContent = "Requires Chrome/Edge";
|
aprsStatus.textContent = "Requires Chrome/Edge";
|
||||||
return;
|
return;
|
||||||
@@ -565,6 +581,7 @@ function startAprs() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
aprsActive = true;
|
aprsActive = true;
|
||||||
|
saveSetting("aprsRunning", true);
|
||||||
aprsToggleBtn.style.borderColor = "#00d17f";
|
aprsToggleBtn.style.borderColor = "#00d17f";
|
||||||
aprsToggleBtn.style.color = "#00d17f";
|
aprsToggleBtn.style.color = "#00d17f";
|
||||||
aprsToggleBtn.textContent = "Stop APRS";
|
aprsToggleBtn.textContent = "Stop APRS";
|
||||||
@@ -590,7 +607,7 @@ function startAprs() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
aprsWs.onclose = () => {
|
aprsWs.onclose = () => {
|
||||||
stopAprs();
|
stopAprs(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
aprsWs.onerror = () => {
|
aprsWs.onerror = () => {
|
||||||
@@ -598,8 +615,9 @@ function startAprs() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopAprs() {
|
function stopAprs(explicit) {
|
||||||
aprsActive = false;
|
aprsActive = false;
|
||||||
|
if (explicit) saveSetting("aprsRunning", false);
|
||||||
if (aprsWs) { aprsWs.close(); aprsWs = null; }
|
if (aprsWs) { aprsWs.close(); aprsWs = null; }
|
||||||
if (aprsAudioCtx) { aprsAudioCtx.close(); aprsAudioCtx = null; }
|
if (aprsAudioCtx) { aprsAudioCtx.close(); aprsAudioCtx = null; }
|
||||||
if (aprsDecoder) {
|
if (aprsDecoder) {
|
||||||
@@ -612,4 +630,20 @@ function stopAprs() {
|
|||||||
aprsStatus.textContent = "Stopped";
|
aprsStatus.textContent = "Stopped";
|
||||||
}
|
}
|
||||||
|
|
||||||
aprsToggleBtn.addEventListener("click", startAprs);
|
aprsToggleBtn.addEventListener("click", () => {
|
||||||
|
if (aprsActive) { stopAprs(true); } else { startAprs(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore saved packets and map markers on page load
|
||||||
|
for (let i = aprsPacketHistory.length - 1; i >= 0; i--) {
|
||||||
|
const pkt = aprsPacketHistory[i];
|
||||||
|
aprsPacketsEl.prepend(renderAprsRow(pkt));
|
||||||
|
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
|
||||||
|
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-start APRS if it was running before page refresh
|
||||||
|
if (loadSetting("aprsRunning", false) && hasWebCodecs) {
|
||||||
|
startAprs();
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ const cwWpmAutoCheck = document.getElementById("cw-wpm-auto");
|
|||||||
const cwToneAutoCheck = document.getElementById("cw-tone-auto");
|
const cwToneAutoCheck = document.getElementById("cw-tone-auto");
|
||||||
const CW_MAX_LINES = 200;
|
const CW_MAX_LINES = 200;
|
||||||
|
|
||||||
|
// Restore saved CW settings
|
||||||
|
cwWpmInput.value = loadSetting("cwWpm", 15);
|
||||||
|
cwToneInput.value = loadSetting("cwTone", 700);
|
||||||
|
cwThresholdInput.value = loadSetting("cwThreshold", 5);
|
||||||
|
cwThresholdVal.textContent = (cwThresholdInput.value / 100).toFixed(2);
|
||||||
|
cwWpmAutoCheck.checked = loadSetting("cwWpmAuto", true);
|
||||||
|
cwToneAutoCheck.checked = loadSetting("cwToneAuto", true);
|
||||||
|
cwWpmInput.readOnly = cwWpmAutoCheck.checked;
|
||||||
|
cwToneInput.readOnly = cwToneAutoCheck.checked;
|
||||||
|
|
||||||
let cwActive = false;
|
let cwActive = false;
|
||||||
let cwWs = null;
|
let cwWs = null;
|
||||||
let cwAudioCtx = null;
|
let cwAudioCtx = null;
|
||||||
@@ -36,18 +46,25 @@ const MORSE_TABLE = {
|
|||||||
// Update threshold display
|
// Update threshold display
|
||||||
cwThresholdInput.addEventListener("input", () => {
|
cwThresholdInput.addEventListener("input", () => {
|
||||||
cwThresholdVal.textContent = (cwThresholdInput.value / 100).toFixed(2);
|
cwThresholdVal.textContent = (cwThresholdInput.value / 100).toFixed(2);
|
||||||
|
saveSetting("cwThreshold", Number(cwThresholdInput.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle readonly on WPM input based on Auto checkbox
|
// Toggle readonly on WPM input based on Auto checkbox
|
||||||
cwWpmAutoCheck.addEventListener("change", () => {
|
cwWpmAutoCheck.addEventListener("change", () => {
|
||||||
cwWpmInput.readOnly = cwWpmAutoCheck.checked;
|
cwWpmInput.readOnly = cwWpmAutoCheck.checked;
|
||||||
|
saveSetting("cwWpmAuto", cwWpmAutoCheck.checked);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle readonly on Tone input based on Auto checkbox
|
// Toggle readonly on Tone input based on Auto checkbox
|
||||||
cwToneAutoCheck.addEventListener("change", () => {
|
cwToneAutoCheck.addEventListener("change", () => {
|
||||||
cwToneInput.readOnly = cwToneAutoCheck.checked;
|
cwToneInput.readOnly = cwToneAutoCheck.checked;
|
||||||
|
saveSetting("cwToneAuto", cwToneAutoCheck.checked);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save WPM/Tone when manually changed
|
||||||
|
cwWpmInput.addEventListener("change", () => { saveSetting("cwWpm", Number(cwWpmInput.value)); });
|
||||||
|
cwToneInput.addEventListener("change", () => { saveSetting("cwTone", Number(cwToneInput.value)); });
|
||||||
|
|
||||||
function createCwDecoder(sampleRate) {
|
function createCwDecoder(sampleRate) {
|
||||||
let wpm = parseInt(cwWpmInput.value, 10) || 15;
|
let wpm = parseInt(cwWpmInput.value, 10) || 15;
|
||||||
let toneFreq = parseInt(cwToneInput.value, 10) || 700;
|
let toneFreq = parseInt(cwToneInput.value, 10) || 700;
|
||||||
@@ -163,6 +180,7 @@ function createCwDecoder(sampleRate) {
|
|||||||
if (Math.abs(detectedFreq - toneFreq) > TONE_SCAN_STEP) {
|
if (Math.abs(detectedFreq - toneFreq) > TONE_SCAN_STEP) {
|
||||||
recomputeGoertzel(detectedFreq);
|
recomputeGoertzel(detectedFreq);
|
||||||
cwToneInput.value = detectedFreq;
|
cwToneInput.value = detectedFreq;
|
||||||
|
saveSetting("cwTone", detectedFreq);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,6 +219,7 @@ function createCwDecoder(sampleRate) {
|
|||||||
if (newWpm !== wpm) {
|
if (newWpm !== wpm) {
|
||||||
wpm = newWpm;
|
wpm = newWpm;
|
||||||
cwWpmInput.value = wpm;
|
cwWpmInput.value = wpm;
|
||||||
|
saveSetting("cwWpm", wpm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user