refactor: convert virtual channels to TypeScript

This commit is contained in:
sjg
2026-08-01 12:57:09 +02:00
parent fc2f55bab9
commit 6a83e2e90a
6 changed files with 639 additions and 544 deletions
@@ -10,7 +10,7 @@ const pluginGroups = {
};
const loaded = /* @__PURE__ */ new Set();
const loading = /* @__PURE__ */ new Map();
const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"]);
const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js"]);
function loadLegacyScript(path) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
@@ -1,453 +1,459 @@
"use strict";
let vchanSessionId = null;
let vchanRigId = null;
let vchanChannels = [];
let vchanActiveId = null;
let schedulerReleaseState = null;
let schedulerReleasePollTimer = null;
function vchanFmtFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "GHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "kHz";
return hz + "Hz";
}
function schedulerReleaseSummaryText(state) {
if (!state) return "Scheduler is controlling the rig.";
const connected = Number(state.connected_sessions) || 0;
const released = Number(state.released_sessions) || 0;
if (connected === 0) return "Scheduler can control the rig.";
if (state.all_released) {
return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`;
(() => {
// src/plugins/vchan.ts
var vchanWindow = window;
var vchanSessionId = null;
var vchanRigId = null;
var vchanChannels = [];
var vchanActiveId = null;
var schedulerReleaseState = null;
var schedulerReleasePollTimer = null;
function vchanFmtFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "GHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "kHz";
return `${String(hz)}Hz`;
}
if (!state.current_session_released) {
const othersReleased = Math.max(released, 0);
return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler.";
}
const blocking = Math.max(connected - released, 0);
return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig.";
}
function vchanRenderSchedulerRelease() {
const btn = document.getElementById("scheduler-release-btn");
const status = document.getElementById("scheduler-release-status");
if (!btn || !status) return;
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
btn.disabled = !vchanSessionId || currentReleased;
btn.classList.toggle("active", !currentReleased);
btn.textContent = "Release to Scheduler";
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
}
async function vchanPollSchedulerRelease() {
if (!vchanSessionId) {
schedulerReleaseState = null;
vchanRenderSchedulerRelease();
return;
}
try {
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json();
vchanRenderSchedulerRelease();
} catch (e) {
console.error("scheduler release status failed", e);
}
}
function vchanStartSchedulerReleasePolling() {
if (schedulerReleasePollTimer) {
clearInterval(schedulerReleasePollTimer);
}
schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 1e4);
}
async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return;
const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null);
try {
const resp = await fetch("/scheduler-control", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId })
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json();
vchanRenderSchedulerRelease();
} catch (e) {
console.error("scheduler release toggle failed", e);
}
}
async function vchanTakeSchedulerControl() {
if (!vchanSessionId) return;
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
try {
const resp = await fetch("/scheduler-control", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId, released: false })
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json();
vchanRenderSchedulerRelease();
} catch (e) {
console.error("scheduler control takeover failed", e);
}
}
window.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
function vchanHandleSession(data) {
try {
const d = JSON.parse(data);
vchanSessionId = d.session_id || null;
vchanPollSchedulerRelease();
} catch (e) {
console.warn("vchan: bad session event", e);
}
}
function vchanHandleChannels(data) {
try {
const d = JSON.parse(data);
vchanRigId = d.remote || null;
vchanChannels = d.channels || [];
const ids = new Set(vchanChannels.map((c) => c.id));
if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) {
vchanAutoJoinPrimary(vchanChannels[0].id);
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null;
vchanReconnectAudio();
function schedulerReleaseSummaryText(state) {
if (!state) return "Scheduler is controlling the rig.";
const connected = Number(state.connected_sessions) || 0;
const released = Number(state.released_sessions) || 0;
if (connected === 0) return "Scheduler can control the rig.";
if (state.all_released) {
return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`;
}
vchanRender();
vchanRenderSchedulerRelease();
if (typeof renderRdsOverlays === "function") renderRdsOverlays();
} catch (e) {
console.warn("vchan: bad channels event", e);
if (!state.current_session_released) {
const othersReleased = Math.max(released, 0);
return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler.";
}
const blocking = Math.max(connected - released, 0);
return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig.";
}
}
function vchanRender() {
const picker = document.getElementById("vchan-picker");
if (!picker) return;
picker.innerHTML = "";
vchanChannels.forEach((ch) => {
const btn = document.createElement("button");
btn.type = "button";
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
if (ch.id === vchanActiveId) btn.classList.add("active");
const label = document.createElement("span");
label.className = "vchan-label";
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
btn.appendChild(label);
if (!ch.permanent) {
const del = document.createElement("span");
del.className = "vchan-del";
del.textContent = "×";
del.title = "Delete channel";
del.addEventListener("click", (e) => {
e.stopPropagation();
vchanDelete(ch.id);
function vchanRenderSchedulerRelease() {
const btn = document.getElementById("scheduler-release-btn");
const status = document.getElementById("scheduler-release-status");
if (!btn || !status) return;
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
btn.disabled = !vchanSessionId || currentReleased;
btn.classList.toggle("active", !currentReleased);
btn.textContent = "Release to Scheduler";
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
}
async function vchanPollSchedulerRelease() {
if (!vchanSessionId) {
schedulerReleaseState = null;
vchanRenderSchedulerRelease();
return;
}
try {
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json();
vchanRenderSchedulerRelease();
} catch (e) {
console.error("scheduler release status failed", e);
}
}
function vchanStartSchedulerReleasePolling() {
if (schedulerReleasePollTimer) {
clearInterval(schedulerReleasePollTimer);
}
schedulerReleasePollTimer = setInterval(() => {
void vchanPollSchedulerRelease();
}, 1e4);
}
async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return;
const rigId = vchanRigId || vchanWindow.lastActiveRigId || null;
try {
const resp = await fetch("/scheduler-control", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId })
});
btn.appendChild(del);
}
btn.addEventListener("click", () => {
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id);
});
picker.appendChild(btn);
});
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "vchan-add";
addBtn.textContent = "+";
addBtn.title = "Allocate new virtual channel at current frequency";
addBtn.addEventListener("click", vchanAllocate);
picker.appendChild(addBtn);
vchanSyncAccentUI();
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
updateDocumentTitle(activeChannelRds());
}
vchanRenderSchedulerRelease();
}
async function vchanAllocate() {
if (!vchanSessionId || !vchanRigId) return;
const freqHz = typeof lastFreqHz === "number" && lastFreqHz > 0 ? lastFreqHz : 0;
const modeEl = document.getElementById("mode");
const mode = modeEl ? modeEl.value || "USB" : "USB";
try {
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode })
});
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
console.warn("vchan: allocate failed —", msg);
return;
}
const ch = await resp.json();
vchanActiveId = ch.id;
vchanRender();
vchanReconnectAudio();
} catch (e) {
console.error("vchan: allocate error", e);
}
}
async function vchanDelete(channelId) {
if (!vchanRigId) return;
try {
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
{ method: "DELETE" }
);
if (!resp.ok) {
console.warn("vchan: delete failed", resp.status);
}
} catch (e) {
console.error("vchan: delete error", e);
}
}
async function vchanAutoJoinPrimary(channelId) {
if (!vchanSessionId || !vchanRigId) return;
try {
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId })
}
);
if (!resp.ok) {
console.warn("vchan: auto-join primary failed", resp.status);
return;
}
vchanActiveId = channelId;
vchanRender();
} catch (e) {
console.error("vchan: auto-join error", e);
}
}
async function vchanSubscribe(channelId) {
if (!vchanSessionId || !vchanRigId) return;
try {
await vchanTakeSchedulerControl();
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId })
}
);
if (!resp.ok) {
console.warn("vchan: subscribe failed", resp.status);
return;
}
vchanActiveId = channelId;
vchanRender();
vchanSyncModeDisplay();
vchanReconnectAudio();
} catch (e) {
console.error("vchan: subscribe error", e);
}
}
function vchanReconnectAudio() {
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
if (typeof _audioChannelOverride !== "undefined") {
_audioChannelOverride = ch ? ch.id : null;
}
if (typeof rxActive === "undefined" || !rxActive) return;
if (typeof stopRxAudio === "function") stopRxAudio();
setTimeout(() => {
if (typeof startRxAudio === "function") startRxAudio();
}, 300);
}
function vchanApplyCapabilities(caps) {
const picker = document.getElementById("vchan-picker");
if (!picker) return;
picker.style.display = caps && caps.filter_controls ? "" : "none";
vchanRenderSchedulerRelease();
}
function vchanIsOnVirtual() {
if (!vchanActiveId || vchanChannels.length === 0) return false;
return vchanActiveId !== vchanChannels[0].id;
}
function vchanActiveChannel() {
return vchanChannels.find((c) => c.id === vchanActiveId) || null;
}
function vchanUpdateFreqDisplay() {
const ch = vchanActiveChannel();
if (!ch) return;
const el = document.getElementById("freq");
if (!el) return;
if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") {
el.value = formatFreqForStep(ch.freq_hz, jogUnit);
} else {
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
}
}
function vchanSyncModeDisplay() {
const modeEl = document.getElementById("mode");
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
}
const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof lastModeName !== "undefined") {
if (modeUpper === "WFM" && lastModeName !== "WFM") {
if (typeof setJogDivisor === "function") setJogDivisor(10);
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
} else if (modeUpper !== "WFM" && lastModeName === "WFM") {
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
}
lastModeName = modeUpper;
}
if (typeof updateWfmControls === "function") updateWfmControls();
if (typeof updateSdrSquelchControlVisibility === "function") {
updateSdrSquelchControlVisibility();
}
if (typeof refreshRdsUi === "function") {
refreshRdsUi();
} else if (typeof positionRdsPsOverlay === "function") {
positionRdsPsOverlay();
}
}
function vchanSyncBwDisplay() {
if (!vchanIsOnVirtual()) return;
const ch = vchanActiveChannel();
if (!ch) return;
const bwEl = document.getElementById("spectrum-bw-input");
if (!bwEl) return;
let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0 && typeof mwDefaultsForMode === "function") {
bwHz = mwDefaultsForMode(ch.mode)[0] || 0;
}
if (bwHz > 0) {
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
if (typeof currentBandwidthHz !== "undefined") {
currentBandwidthHz = bwHz;
window.currentBandwidthHz = bwHz;
} else {
window.currentBandwidthHz = bwHz;
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json();
vchanRenderSchedulerRelease();
} catch (e) {
console.error("scheduler release toggle failed", e);
}
}
}
function vchanSyncAccentUI() {
const onVirtual = vchanIsOnVirtual();
const freqEl = document.getElementById("freq");
const bwEl = document.getElementById("spectrum-bw-input");
if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
if (onVirtual) {
vchanUpdateFreqDisplay();
vchanSyncModeDisplay();
vchanSyncBwDisplay();
} else if (typeof _origRefreshFreqDisplay === "function") {
_origRefreshFreqDisplay();
}
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
updateDocumentTitle(activeChannelRds());
}
}
let _origRefreshFreqDisplay = null;
function vchanSetChannelFreq(freqHz) {
if (!vchanRigId || !vchanActiveId) return;
if (typeof lastSpectrumData !== "undefined" && lastSpectrumData && lastSpectrumData.sample_rate > 0) {
const halfSpan = Number(lastSpectrumData.sample_rate) / 2;
const center = Number(lastSpectrumData.center_hz);
if (Math.abs(freqHz - center) > halfSpan) {
if (typeof showHint === "function") {
showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3e3
);
}
return;
}
}
vchanTakeSchedulerControl();
fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ freq_hz: Math.round(freqHz) })
}
).catch((e) => console.error("vchan: set freq error", e));
}
async function vchanSetChannelBandwidth(bwHz) {
if (!vchanRigId || !vchanActiveId) return;
try {
await vchanTakeSchedulerControl();
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
{
async function vchanTakeSchedulerControl() {
if (!vchanSessionId) return;
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
try {
const resp = await fetch("/scheduler-control", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) })
}
);
if (!resp.ok) console.warn("vchan: set bw failed", resp.status);
} catch (e) {
console.error("vchan: set bw error", e);
body: JSON.stringify({ session_id: vchanSessionId, released: false })
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json();
vchanRenderSchedulerRelease();
} catch (e) {
console.error("scheduler control takeover failed", e);
}
}
}
async function vchanSetChannelMode(mode) {
if (!vchanRigId || !vchanActiveId) return;
try {
await vchanTakeSchedulerControl();
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
{
method: "PUT",
vchanWindow.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
function vchanHandleSession(data) {
try {
const d = JSON.parse(data);
vchanSessionId = d.session_id || null;
void vchanPollSchedulerRelease();
} catch (e) {
console.warn("vchan: bad session event", e);
}
}
function vchanHandleChannels(data) {
try {
const d = JSON.parse(data);
vchanRigId = d.remote || null;
vchanChannels = d.channels || [];
const ids = new Set(vchanChannels.map((c) => c.id));
const primaryChannel = vchanChannels[0];
if (!vchanActiveId && primaryChannel && vchanSessionId) {
void vchanAutoJoinPrimary(primaryChannel.id);
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
vchanActiveId = vchanChannels[0]?.id ?? null;
vchanReconnectAudio();
}
vchanRender();
vchanRenderSchedulerRelease();
vchanWindow.renderRdsOverlays?.();
} catch (e) {
console.warn("vchan: bad channels event", e);
}
}
vchanWindow.vchanHandleSession = vchanHandleSession;
vchanWindow.vchanHandleChannels = vchanHandleChannels;
function vchanRender() {
const picker = document.getElementById("vchan-picker");
if (!picker) return;
picker.innerHTML = "";
vchanChannels.forEach((ch) => {
const btn = document.createElement("button");
btn.type = "button";
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
if (ch.id === vchanActiveId) btn.classList.add("active");
const label = document.createElement("span");
label.className = "vchan-label";
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
btn.appendChild(label);
if (!ch.permanent) {
const del = document.createElement("span");
del.className = "vchan-del";
del.textContent = "×";
del.title = "Delete channel";
del.addEventListener("click", (e) => {
e.stopPropagation();
void vchanDelete(ch.id);
});
btn.appendChild(del);
}
btn.addEventListener("click", () => {
if (ch.id !== vchanActiveId) void vchanSubscribe(ch.id);
});
picker.appendChild(btn);
});
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "vchan-add";
addBtn.textContent = "+";
addBtn.title = "Allocate new virtual channel at current frequency";
addBtn.addEventListener("click", () => {
void vchanAllocate();
});
picker.appendChild(addBtn);
vchanSyncAccentUI();
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
}
vchanRenderSchedulerRelease();
}
async function vchanAllocate() {
if (!vchanSessionId || !vchanRigId) return;
const freqHz = typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0 ? vchanWindow.lastFreqHz : 0;
const modeEl = document.getElementById("mode");
const mode = modeEl ? modeEl.value || "USB" : "USB";
try {
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode })
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode })
});
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
console.warn("vchan: allocate failed —", msg);
return;
}
);
if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
} catch (e) {
console.error("vchan: set mode error", e);
const ch = await resp.json();
vchanActiveId = ch.id;
vchanRender();
vchanReconnectAudio();
} catch (e) {
console.error("vchan: allocate error", e);
}
}
}
window.vchanInterceptMode = async function(mode) {
if (!vchanIsOnVirtual()) return false;
await vchanSetChannelMode(mode);
return true;
};
window.vchanInterceptBandwidth = async function(bwHz) {
if (!vchanIsOnVirtual()) return false;
await vchanSetChannelBandwidth(bwHz);
return true;
};
(function() {
const _orig = window.setRigFrequency;
window.setRigFrequency = function(freqHz) {
if (vchanIsOnVirtual()) {
if (typeof applyLocalTunedFrequency === "function") {
if (typeof _freqOptimisticSeq !== "undefined") {
++_freqOptimisticSeq;
_freqOptimisticHz = Math.round(freqHz);
async function vchanDelete(channelId) {
if (!vchanRigId) return;
try {
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
{ method: "DELETE" }
);
if (!resp.ok) {
console.warn("vchan: delete failed", resp.status);
}
} catch (e) {
console.error("vchan: delete error", e);
}
}
async function vchanAutoJoinPrimary(channelId) {
if (!vchanSessionId || !vchanRigId) return;
try {
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId })
}
applyLocalTunedFrequency(Math.round(freqHz));
);
if (!resp.ok) {
console.warn("vchan: auto-join primary failed", resp.status);
return;
}
vchanSetChannelFreq(freqHz);
return;
vchanActiveId = channelId;
vchanRender();
} catch (e) {
console.error("vchan: auto-join error", e);
}
vchanTakeSchedulerControl();
if (typeof _orig === "function") _orig(freqHz);
};
})();
(function initSchedulerReleaseControl() {
const btn = document.getElementById("scheduler-release-btn");
if (btn) {
btn.addEventListener("click", () => {
vchanToggleSchedulerRelease();
}
async function vchanSubscribe(channelId) {
if (!vchanSessionId || !vchanRigId) return;
try {
await vchanTakeSchedulerControl();
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId })
}
);
if (!resp.ok) {
console.warn("vchan: subscribe failed", resp.status);
return;
}
vchanActiveId = channelId;
vchanRender();
vchanSyncModeDisplay();
vchanReconnectAudio();
} catch (e) {
console.error("vchan: subscribe error", e);
}
}
function vchanReconnectAudio() {
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
vchanWindow._audioChannelOverride = ch?.id ?? null;
if (!vchanWindow.rxActive) return;
vchanWindow.stopRxAudio?.();
setTimeout(() => {
vchanWindow.startRxAudio?.();
}, 300);
}
function vchanApplyCapabilities(caps) {
const picker = document.getElementById("vchan-picker");
if (!picker) return;
picker.style.display = caps && caps.filter_controls ? "" : "none";
vchanRenderSchedulerRelease();
}
vchanWindow.vchanApplyCapabilities = vchanApplyCapabilities;
function vchanIsOnVirtual() {
if (!vchanActiveId || vchanChannels.length === 0) return false;
return vchanActiveId !== vchanChannels[0]?.id;
}
vchanWindow.vchanIsOnVirtual = vchanIsOnVirtual;
function vchanActiveChannel() {
return vchanChannels.find((c) => c.id === vchanActiveId) || null;
}
function vchanUpdateFreqDisplay() {
const ch = vchanActiveChannel();
if (!ch) return;
const el = document.getElementById("freq");
if (!el) return;
if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") {
el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit);
} else {
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
}
}
function vchanSyncModeDisplay() {
const modeEl = document.getElementById("mode");
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
}
const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof vchanWindow.lastModeName === "string") {
if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") {
vchanWindow.setJogDivisor?.(10);
vchanWindow.resetRdsDisplay?.();
} else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") {
vchanWindow.resetRdsDisplay?.();
}
vchanWindow.lastModeName = modeUpper;
}
vchanWindow.updateWfmControls?.();
vchanWindow.updateSdrSquelchControlVisibility?.();
if (vchanWindow.refreshRdsUi) {
vchanWindow.refreshRdsUi();
} else {
vchanWindow.positionRdsPsOverlay?.();
}
}
function vchanSyncBwDisplay() {
if (!vchanIsOnVirtual()) return;
const ch = vchanActiveChannel();
if (!ch) return;
const bwEl = document.getElementById("spectrum-bw-input");
if (!bwEl) return;
let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0 && vchanWindow.mwDefaultsForMode) {
bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0;
}
if (bwHz > 0) {
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
vchanWindow.currentBandwidthHz = bwHz;
}
}
function vchanSyncAccentUI() {
const onVirtual = vchanIsOnVirtual();
const freqEl = document.getElementById("freq");
const bwEl = document.getElementById("spectrum-bw-input");
if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
if (onVirtual) {
vchanUpdateFreqDisplay();
vchanSyncModeDisplay();
vchanSyncBwDisplay();
} else {
origRefreshFreqDisplay?.();
}
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
}
}
var origRefreshFreqDisplay = null;
function vchanSetChannelFreq(freqHz) {
if (!vchanRigId || !vchanActiveId) return;
if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) {
const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2;
const center = vchanWindow.lastSpectrumData.center_hz;
if (Math.abs(freqHz - center) > halfSpan) {
if (vchanWindow.showHint) {
vchanWindow.showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3e3
);
}
return;
}
}
void vchanTakeSchedulerControl();
void fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ freq_hz: Math.round(freqHz) })
}
).catch((error) => {
console.error("vchan: set freq error", error);
});
}
vchanStartSchedulerReleasePolling();
vchanRenderSchedulerRelease();
})();
(function() {
_origRefreshFreqDisplay = window.refreshFreqDisplay;
window.refreshFreqDisplay = function() {
if (vchanIsOnVirtual()) {
vchanUpdateFreqDisplay();
return;
async function vchanSetChannelBandwidth(bwHz) {
if (!vchanRigId || !vchanActiveId) return;
try {
await vchanTakeSchedulerControl();
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) })
}
);
if (!resp.ok) console.warn("vchan: set bw failed", resp.status);
} catch (e) {
console.error("vchan: set bw error", e);
}
if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay();
}
async function vchanSetChannelMode(mode) {
if (!vchanRigId || !vchanActiveId) return;
try {
await vchanTakeSchedulerControl();
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode })
}
);
if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
} catch (e) {
console.error("vchan: set mode error", e);
}
}
vchanWindow.vchanInterceptMode = async function(mode) {
if (!vchanIsOnVirtual()) return false;
await vchanSetChannelMode(mode);
return true;
};
vchanWindow.vchanInterceptBandwidth = async function(bwHz) {
if (!vchanIsOnVirtual()) return false;
await vchanSetChannelBandwidth(bwHz);
return true;
};
(function() {
const original = vchanWindow.setRigFrequency;
vchanWindow.setRigFrequency = function(freqHz) {
if (vchanIsOnVirtual()) {
if (vchanWindow.applyLocalTunedFrequency) {
if (typeof vchanWindow._freqOptimisticSeq === "number") {
vchanWindow._freqOptimisticSeq += 1;
vchanWindow._freqOptimisticHz = Math.round(freqHz);
}
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
}
vchanSetChannelFreq(freqHz);
return;
}
void vchanTakeSchedulerControl();
original?.(freqHz);
};
})();
(function initSchedulerReleaseControl() {
const btn = document.getElementById("scheduler-release-btn");
if (btn) {
btn.addEventListener("click", () => {
void vchanToggleSchedulerRelease();
});
}
vchanStartSchedulerReleasePolling();
vchanRenderSchedulerRelease();
})();
(function() {
origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null;
vchanWindow.refreshFreqDisplay = function() {
if (vchanIsOnVirtual()) {
vchanUpdateFreqDisplay();
return;
}
origRefreshFreqDisplay?.();
};
})();
})();