CI / lint (pull_request) Successful in 2m16s
CI / frontend (pull_request) Successful in 4m12s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m15s
CI / test (pull_request) Successful in 9m37s
CI / test (push) Successful in 7m36s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
Wires the SSTV decoder into the stack, from the audio the server already has to a panel in the browser that shows the picture arriving. Server: a decoder task alongside the WEFAX one, running whenever the decoder is enabled and the rig is in a mode SSTV is sent in. A finished picture is written to the cache as a PNG and sent on as a message; the rows are sent as they decode, so a client can watch two minutes of Martin M1 fill in rather than waiting for it. Pictures join the decode history, are replayed to a client that connects later, and survive a restart. Protocol: SetSstvDecodeEnabled and ResetSstvDecoder, a sstv_decode _enabled flag in the rig state, two audio message types, and Sstv and SstvProgress on DecodedMessage. The history stores the message without its base64 payload -- the picture is already on disk, and a megabyte per entry is not what a history is for. Client: pictures land in their own history, and the PNG the server sent is written to the local cache so /sstv-images/ can serve it back. That endpoint and the WEFAX one now share their filename checks rather than each carrying a copy: no separators, no parent references, .png only. Web UI: an SSTV sub-tab beside WEFAX, with a live canvas the rows paint into at the line number they carry, a card for the last picture, and a filterable history with links to the files. Rows below the one arriving are grey rather than black -- not yet received is a different thing from received as black. A picture is not a spot, so neither pictures nor their progress updates reach the decode statistics; that exclusion list had grown by hand for LRPT and WEFAX and is now one named set. The decoder crate gains what the server needed to hand a picture on: to_png, to_png_base64 and save_png, with file names stamped in UTC so they sort. Panel behaviour is tested with the plugin runtime: rows painting at their own line numbers rather than in arrival order, a completed picture linked by file name alone with no server path in the page, a cut-off picture reported as partial, clearing, and the toggle following the rig state. Signed-off-by: Stan Grams <sjg@haxx.space>
250 lines
9.2 KiB
JavaScript
250 lines
9.2 KiB
JavaScript
import {
|
||
hostCore
|
||
} from "./chunk-KL66PICH.js";
|
||
|
||
// src/plugins/sstv.ts
|
||
var sstvWindow = window;
|
||
var sstvDom = {
|
||
status: document.getElementById("sstv-status"),
|
||
liveView: document.getElementById("sstv-live-view"),
|
||
historyView: document.getElementById("sstv-history-view"),
|
||
liveContainer: document.getElementById("sstv-live-container"),
|
||
liveInfo: document.getElementById("sstv-live-info"),
|
||
liveCanvas: document.getElementById("sstv-live-canvas"),
|
||
liveLatest: document.getElementById("sstv-live-latest"),
|
||
historyList: document.getElementById("sstv-history-list"),
|
||
historyCount: document.getElementById("sstv-history-count"),
|
||
filterInput: document.getElementById("sstv-filter"),
|
||
sortSelect: document.getElementById("sstv-sort"),
|
||
toggleBtn: document.getElementById("sstv-decode-toggle-btn"),
|
||
clearBtn: document.getElementById("sstv-clear-btn"),
|
||
viewLiveBtn: document.getElementById("sstv-view-live"),
|
||
viewHistoryBtn: document.getElementById("sstv-view-history")
|
||
};
|
||
var SSTV_MAX_IMAGES = 100;
|
||
var sstvHistory = [];
|
||
var liveCtx = null;
|
||
var liveMode = "";
|
||
var liveHeight = 0;
|
||
var liveRows = 0;
|
||
var activeView = "live";
|
||
var filterText = "";
|
||
function retentionMs() {
|
||
return sstvWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3;
|
||
}
|
||
function pruneHistory() {
|
||
const cutoff = Date.now() - retentionMs();
|
||
sstvHistory = sstvHistory.filter((image) => (image._tsMs || 0) > cutoff);
|
||
}
|
||
function escapeHtml(value) {
|
||
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
function scheduleUi(key, job) {
|
||
if (typeof sstvWindow.trxScheduleUiFrameJob === "function") {
|
||
sstvWindow.trxScheduleUiFrameJob(key, job);
|
||
return;
|
||
}
|
||
job();
|
||
}
|
||
function imageUrl(image) {
|
||
if (!image.path) return null;
|
||
const filename = image.path.split(/[\\/]/).pop();
|
||
return filename ? `/sstv-images/${encodeURIComponent(filename)}` : null;
|
||
}
|
||
function decodeBase64(data) {
|
||
const binary = atob(data);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||
return bytes;
|
||
}
|
||
function switchView(view) {
|
||
activeView = view;
|
||
if (sstvDom.liveView) sstvDom.liveView.style.display = view === "live" ? "" : "none";
|
||
if (sstvDom.historyView) sstvDom.historyView.style.display = view === "history" ? "" : "none";
|
||
for (const button of [sstvDom.viewLiveBtn, sstvDom.viewHistoryBtn]) {
|
||
button?.classList.remove("sat-view-active");
|
||
}
|
||
if (view === "live") sstvDom.viewLiveBtn?.classList.add("sat-view-active");
|
||
else sstvDom.viewHistoryBtn?.classList.add("sat-view-active");
|
||
if (view === "history") renderHistoryTable();
|
||
}
|
||
sstvDom.viewLiveBtn?.addEventListener("click", () => {
|
||
switchView("live");
|
||
});
|
||
sstvDom.viewHistoryBtn?.addEventListener("click", () => {
|
||
switchView("history");
|
||
});
|
||
function beginPicture(mode, width, height) {
|
||
const canvas = sstvDom.liveCanvas;
|
||
if (!canvas || width <= 0 || height <= 0) return;
|
||
liveMode = mode;
|
||
liveHeight = height;
|
||
liveRows = 0;
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
liveCtx = canvas.getContext("2d");
|
||
if (!liveCtx) return;
|
||
liveCtx.fillStyle = "#606060";
|
||
liveCtx.fillRect(0, 0, width, height);
|
||
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "";
|
||
updateLiveInfo();
|
||
}
|
||
function updateLiveInfo() {
|
||
if (!sstvDom.liveInfo) return;
|
||
const canvas = sstvDom.liveCanvas;
|
||
const size = canvas ? `${canvas.width}×${canvas.height}` : "";
|
||
sstvDom.liveInfo.textContent = liveMode ? `${liveMode} · ${size} · line ${liveRows}${liveHeight ? ` of ${liveHeight}` : ""}` : "";
|
||
}
|
||
function paintRow(line, rgb) {
|
||
const canvas = sstvDom.liveCanvas;
|
||
if (!liveCtx || !canvas) return;
|
||
const width = canvas.width;
|
||
if (line < 0 || line >= canvas.height || rgb.length < width * 3) return;
|
||
const row = liveCtx.createImageData(width, 1);
|
||
for (let x = 0; x < width; x += 1) {
|
||
row.data[x * 4] = rgb[x * 3] ?? 0;
|
||
row.data[x * 4 + 1] = rgb[x * 3 + 1] ?? 0;
|
||
row.data[x * 4 + 2] = rgb[x * 3 + 2] ?? 0;
|
||
row.data[x * 4 + 3] = 255;
|
||
}
|
||
liveCtx.putImageData(row, 0, line);
|
||
liveRows = Math.max(liveRows, line + 1);
|
||
}
|
||
function onProgress(msg) {
|
||
if (msg.state) {
|
||
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
||
return;
|
||
}
|
||
if (typeof msg.line !== "number" || !msg.line_data) return;
|
||
const line = msg.line;
|
||
const rgb = decodeBase64(msg.line_data);
|
||
scheduleUi(`sstv-row-${line}`, () => {
|
||
paintRow(line, rgb);
|
||
updateLiveInfo();
|
||
});
|
||
}
|
||
function onImage(msg) {
|
||
const image = { ...msg };
|
||
image._tsMs = typeof msg.ts_ms === "number" ? msg.ts_ms : Date.now();
|
||
image._ts = new Date(image._tsMs).toLocaleTimeString();
|
||
sstvHistory.push(image);
|
||
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||
pruneHistory();
|
||
if (sstvDom.status) {
|
||
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
||
}
|
||
scheduleUi("sstv-latest", renderLatestCard);
|
||
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
||
}
|
||
function renderLatestCard() {
|
||
if (!sstvDom.liveLatest) return;
|
||
const latest = sstvHistory[sstvHistory.length - 1];
|
||
if (!latest) {
|
||
sstvDom.liveLatest.innerHTML = "";
|
||
return;
|
||
}
|
||
const url = imageUrl(latest);
|
||
const lines = `${latest.lines ?? 0}${latest.height ? ` of ${latest.height}` : ""} lines`;
|
||
const state = latest.complete ? "complete" : "partial";
|
||
sstvDom.liveLatest.innerHTML = `
|
||
<div class="sat-latest-card">
|
||
<div style="display:flex; align-items:baseline; gap:0.5rem; flex-wrap:wrap;">
|
||
<strong>${escapeHtml(latest.mode ?? "SSTV")}</strong>
|
||
<small style="color:var(--text-muted);">${escapeHtml(latest._ts ?? "")} · ${lines} · ${state}</small>
|
||
</div>
|
||
${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">
|
||
<img src="${escapeHtml(url)}" alt="Received ${escapeHtml(latest.mode ?? "SSTV")} picture"
|
||
style="margin-top:0.4rem; width:100%; max-width:640px; image-rendering:pixelated;" />
|
||
</a>` : ""}
|
||
</div>`;
|
||
}
|
||
function filteredHistory() {
|
||
const text = filterText.trim().toLowerCase();
|
||
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice();
|
||
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||
return matching;
|
||
}
|
||
function renderHistoryTable() {
|
||
if (!sstvDom.historyList) return;
|
||
pruneHistory();
|
||
const rows = filteredHistory();
|
||
sstvDom.historyList.innerHTML = rows.map((image) => {
|
||
const url = imageUrl(image);
|
||
const size = image.width && image.height ? `${image.width}×${image.height}` : "--";
|
||
const lines = image.complete ? String(image.lines ?? 0) : `${image.lines ?? 0} (partial)`;
|
||
return `<div class="sat-history-row">
|
||
<span class="sat-col-time">${escapeHtml(image._ts ?? "")}</span>
|
||
<span class="sat-col-type">${escapeHtml(image.mode ?? "--")}</span>
|
||
<span class="sat-col-sat">${escapeHtml(size)}</span>
|
||
<span class="sat-col-lines">${escapeHtml(lines)}</span>
|
||
<span class="sat-col-link">${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">View</a>` : "--"}</span>
|
||
</div>`;
|
||
}).join("");
|
||
if (sstvDom.historyCount) {
|
||
sstvDom.historyCount.textContent = rows.length ? `${rows.length} picture${rows.length === 1 ? "" : "s"}` : "No pictures yet";
|
||
}
|
||
}
|
||
sstvDom.filterInput?.addEventListener("input", () => {
|
||
filterText = sstvDom.filterInput?.value ?? "";
|
||
renderHistoryTable();
|
||
});
|
||
sstvDom.sortSelect?.addEventListener("change", () => {
|
||
renderHistoryTable();
|
||
});
|
||
function restoreHistory(entries) {
|
||
if (!Array.isArray(entries)) return;
|
||
for (const entry of entries) onImage(entry);
|
||
}
|
||
function resetHistoryView() {
|
||
sstvHistory = [];
|
||
liveRows = 0;
|
||
liveMode = "";
|
||
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "none";
|
||
if (sstvDom.status) sstvDom.status.textContent = "Idle";
|
||
renderLatestCard();
|
||
renderHistoryTable();
|
||
}
|
||
sstvWindow.syncSstvToggle = function syncSstvToggle(enabled) {
|
||
const button = sstvDom.toggleBtn;
|
||
if (!button) return;
|
||
button.textContent = enabled ? "Disable SSTV" : "Enable SSTV";
|
||
button.setAttribute("aria-pressed", String(enabled));
|
||
button.classList.toggle("is-active", enabled);
|
||
};
|
||
sstvDom.toggleBtn?.addEventListener("click", () => {
|
||
void (async () => {
|
||
try {
|
||
if (sstvDom.toggleBtn) {
|
||
await sstvWindow.takeSchedulerControlForDecoderDisable?.(sstvDom.toggleBtn);
|
||
}
|
||
await hostCore.postPath("/toggle_sstv_decode");
|
||
} catch (e) {
|
||
console.error("SSTV toggle failed", e);
|
||
}
|
||
})();
|
||
});
|
||
sstvDom.clearBtn?.addEventListener("click", () => {
|
||
void (async () => {
|
||
try {
|
||
await hostCore.postPath("/clear_sstv_decode");
|
||
resetHistoryView();
|
||
} catch (e) {
|
||
console.error("SSTV clear failed", e);
|
||
}
|
||
})();
|
||
});
|
||
renderLatestCard();
|
||
sstvWindow.trxPluginRuntime.registerDecoder({
|
||
id: "sstv",
|
||
onMessage: onImage,
|
||
restore: restoreHistory,
|
||
prune: renderHistoryTable,
|
||
reset: resetHistoryView
|
||
});
|
||
sstvWindow.trxPluginRuntime.registerDecoder({
|
||
id: "sstv_progress",
|
||
onMessage: onProgress
|
||
});
|