/** * Intercept - WebSDR Mode * HF/Shortwave KiwiSDR Network Integration with In-App Audio */ // ============== STATE ============== let websdrMap = null; let websdrMarkers = []; let websdrReceivers = []; let websdrInitialized = false; let websdrSpyStationsLoaded = false; let websdrMapType = null; let websdrGlobe = null; let websdrGlobePopup = null; let websdrSelectedReceiverIndex = null; let websdrGlobeScriptPromise = null; let websdrResizeObserver = null; let websdrResizeHooked = false; let websdrGlobeFallbackNotified = false; const WEBSDR_GLOBE_SCRIPT_URLS = [ 'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js', ]; const WEBSDR_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg'; // KiwiSDR audio state let kiwiWebSocket = null; let kiwiAudioContext = null; let kiwiScriptProcessor = null; let kiwiGainNode = null; let kiwiAudioBuffer = []; let kiwiConnected = false; let kiwiCurrentFreq = 0; let kiwiCurrentMode = 'am'; let kiwiSmeter = 0; let kiwiSmeterInterval = null; let kiwiReceiverName = ''; const KIWI_SAMPLE_RATE = 12000; // ============== INITIALIZATION ============== async function initWebSDR() { if (websdrInitialized) { setTimeout(invalidateWebSDRViewport, 100); return; } const mapEl = document.getElementById('websdrMap'); if (!mapEl) return; const globeReady = await ensureWebsdrGlobeLibrary(); // Wait for a paint frame so the browser computes layout after the // display:flex change in switchMode. Without this, Globe()(mapEl) can // run before clientWidth/clientHeight are non-zero (especially when // scripts are served from cache and resolve before the first layout pass). await new Promise(resolve => requestAnimationFrame(resolve)); // If the mode was switched away while scripts were loading, abort so // websdrInitialized stays false and we retry cleanly next time. if (!mapEl.clientWidth || !mapEl.clientHeight) return; if (globeReady && initWebsdrGlobe(mapEl)) { websdrMapType = 'globe'; } else if (typeof L !== 'undefined' && await initWebsdrLeaflet(mapEl)) { websdrMapType = 'leaflet'; if (!websdrGlobeFallbackNotified && typeof showNotification === 'function') { showNotification('WebSDR', '3D globe unavailable, using fallback map'); websdrGlobeFallbackNotified = true; } } else { console.error('[WEBSDR] Unable to initialize globe or map renderer'); return; } websdrInitialized = true; if (!websdrSpyStationsLoaded) { loadSpyStationPresets(); } setupWebsdrResizeHandling(mapEl); if (websdrReceivers.length > 0) { plotReceiversOnMap(websdrReceivers); } [100, 300, 600, 1000].forEach(delay => { setTimeout(invalidateWebSDRViewport, delay); }); } // ============== RECEIVER SEARCH ============== function searchReceivers(refresh) { const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 0); let url = '/websdr/receivers?available=true'; if (freqKhz > 0) url += `&freq_khz=${freqKhz}`; if (refresh) url += '&refresh=true'; fetch(url) .then(r => r.json()) .then(data => { if (data.status === 'success') { websdrReceivers = data.receivers || []; websdrSelectedReceiverIndex = null; hideWebsdrGlobePopup(); renderReceiverList(websdrReceivers); plotReceiversOnMap(websdrReceivers); const countEl = document.getElementById('websdrReceiverCount'); if (countEl) countEl.textContent = `${websdrReceivers.length} found`; } }) .catch(err => console.error('[WEBSDR] Search error:', err)); } // ============== MAP ============== function plotReceiversOnMap(receivers) { if (websdrMapType === 'globe' && websdrGlobe) { plotReceiversOnGlobe(receivers); return; } if (!websdrMap) return; websdrMarkers.forEach(m => websdrMap.removeLayer(m)); websdrMarkers = []; receivers.forEach((rx, idx) => { if (rx.lat == null || rx.lon == null) return; const marker = L.circleMarker([rx.lat, rx.lon], { radius: 6, fillColor: rx.available ? '#00d4ff' : '#666', color: rx.available ? '#00d4ff' : '#666', weight: 1, opacity: 0.8, fillOpacity: 0.6, }); marker.bindPopup(`
${escapeHtmlWebsdr(rx.name)}
${rx.location ? `${escapeHtmlWebsdr(rx.location)}
` : ''} Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}
Users: ${rx.users}/${rx.users_max}
`); marker.addTo(websdrMap); websdrMarkers.push(marker); }); if (websdrMarkers.length > 0) { const group = L.featureGroup(websdrMarkers); websdrMap.fitBounds(group.getBounds(), { padding: [30, 30] }); } } async function ensureWebsdrGlobeLibrary() { if (typeof window.Globe === 'function') return true; if (!isWebglSupported()) return false; if (!websdrGlobeScriptPromise) { websdrGlobeScriptPromise = WEBSDR_GLOBE_SCRIPT_URLS .reduce( (promise, src) => promise.then(() => loadWebsdrScript(src)), Promise.resolve() ) .then(() => typeof window.Globe === 'function') .catch((error) => { console.warn('[WEBSDR] Failed to load globe scripts:', error); return false; }); } const loaded = await websdrGlobeScriptPromise; if (!loaded) { websdrGlobeScriptPromise = null; } return loaded; } function loadWebsdrScript(src) { const state = getSharedGlobeScriptState(); if (!state.promises[src]) { state.promises[src] = loadSharedGlobeScript(src); } return state.promises[src].catch((error) => { delete state.promises[src]; throw error; }); } function getSharedGlobeScriptState() { const key = '__interceptGlobeScriptState'; if (!window[key]) { window[key] = { promises: Object.create(null), }; } return window[key]; } function loadSharedGlobeScript(src) { return new Promise((resolve, reject) => { const selector = [ `script[data-intercept-globe-src="${src}"]`, `script[data-websdr-src="${src}"]`, `script[data-gps-globe-src="${src}"]`, `script[src="${src}"]`, ].join(', '); const existing = document.querySelector(selector); if (existing) { if (existing.dataset.loaded === 'true') { resolve(); return; } if (existing.dataset.failed === 'true') { existing.remove(); } else { existing.addEventListener('load', () => resolve(), { once: true }); existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true }); return; } } const script = document.createElement('script'); script.src = src; script.async = true; script.crossOrigin = 'anonymous'; script.dataset.interceptGlobeSrc = src; script.dataset.websdrSrc = src; script.onload = () => { script.dataset.loaded = 'true'; resolve(); }; script.onerror = () => { script.dataset.failed = 'true'; reject(new Error(`Failed to load ${src}`)); }; document.head.appendChild(script); }); } function isWebglSupported() { try { const canvas = document.createElement('canvas'); return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl')); } catch (_) { return false; } } function initWebsdrGlobe(mapEl) { if (typeof window.Globe !== 'function' || !isWebglSupported()) return false; mapEl.innerHTML = ''; mapEl.style.background = 'radial-gradient(circle at 30% 20%, rgba(14, 42, 68, 0.9), rgba(4, 9, 16, 0.95) 58%, rgba(2, 4, 9, 0.98) 100%)'; mapEl.style.cursor = 'grab'; websdrGlobe = window.Globe()(mapEl) .backgroundColor('rgba(0,0,0,0)') .globeImageUrl(WEBSDR_GLOBE_TEXTURE_URL) .showAtmosphere(true) .atmosphereColor('#3bb9ff') .atmosphereAltitude(0.17) .pointRadius('radius') .pointAltitude('altitude') .pointColor('color') .pointsTransitionDuration(250) .pointLabel(point => point.label || '') .onPointHover(point => { mapEl.style.cursor = point ? 'pointer' : 'grab'; }) .onPointClick((point, event) => { if (!point) return; showWebsdrGlobePopup(point, event); }); const controls = websdrGlobe.controls(); if (controls) { controls.autoRotate = true; controls.autoRotateSpeed = 0.25; controls.enablePan = false; controls.minDistance = 140; controls.maxDistance = 380; controls.rotateSpeed = 0.7; controls.zoomSpeed = 0.8; } ensureWebsdrGlobePopup(mapEl); resizeWebsdrGlobe(); return true; } async function initWebsdrLeaflet(mapEl) { if (typeof L === 'undefined') return false; mapEl.innerHTML = ''; const mapHeight = mapEl.clientHeight || 500; const minZoom = Math.ceil(Math.log2(mapHeight / 256)); websdrMap = L.map('websdrMap', { center: [20, 0], zoom: Math.max(minZoom, 2), minZoom: Math.max(minZoom, 2), zoomControl: true, maxBounds: [[-85, -360], [85, 360]], maxBoundsViscosity: 1.0, }); if (typeof Settings !== 'undefined' && Settings.createTileLayer) { await Settings.init(); Settings.createTileLayer().addTo(websdrMap); Settings.registerMap(websdrMap); } else { L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap contributors © CARTO', subdomains: 'abcd', maxZoom: 19, className: 'tile-layer-cyan', }).addTo(websdrMap); } mapEl.style.background = '#1a1d29'; return true; } function setupWebsdrResizeHandling(mapEl) { if (typeof ResizeObserver !== 'undefined') { if (websdrResizeObserver) { websdrResizeObserver.disconnect(); } websdrResizeObserver = new ResizeObserver(() => invalidateWebSDRViewport()); websdrResizeObserver.observe(mapEl); } if (!websdrResizeHooked) { window.addEventListener('resize', invalidateWebSDRViewport); window.addEventListener('orientationchange', () => setTimeout(invalidateWebSDRViewport, 120)); websdrResizeHooked = true; } } function invalidateWebSDRViewport() { if (websdrMapType === 'globe') { resizeWebsdrGlobe(); return; } if (websdrMap && typeof websdrMap.invalidateSize === 'function') { websdrMap.invalidateSize({ pan: false, animate: false }); } } function resizeWebsdrGlobe() { if (!websdrGlobe) return; const mapEl = document.getElementById('websdrMap'); if (!mapEl) return; const width = mapEl.clientWidth; const height = mapEl.clientHeight; if (!width || !height) return; websdrGlobe.width(width); websdrGlobe.height(height); } function plotReceiversOnGlobe(receivers) { if (!websdrGlobe) return; const points = []; receivers.forEach((rx, idx) => { const lat = Number(rx.lat); const lon = Number(rx.lon); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; const selected = idx === websdrSelectedReceiverIndex; points.push({ lat: lat, lng: lon, receiverIndex: idx, radius: selected ? 0.52 : 0.38, altitude: selected ? 0.1 : 0.04, color: selected ? '#00ff88' : (rx.available ? '#00d4ff' : '#5f6976'), label: buildWebsdrPointLabel(rx, idx), }); }); websdrGlobe.pointsData(points); if (points.length > 0) { if (websdrSelectedReceiverIndex != null) { const selectedPoint = points.find(point => point.receiverIndex === websdrSelectedReceiverIndex); if (selectedPoint) { websdrGlobe.pointOfView({ lat: selectedPoint.lat, lng: selectedPoint.lng, altitude: 1.45 }, 900); return; } } const center = computeWebsdrGlobeCenter(points); websdrGlobe.pointOfView(center, 900); } } function computeWebsdrGlobeCenter(points) { if (!points.length) return { lat: 20, lng: 0, altitude: 2.1 }; let x = 0; let y = 0; let z = 0; points.forEach(point => { const latRad = point.lat * Math.PI / 180; const lonRad = point.lng * Math.PI / 180; x += Math.cos(latRad) * Math.cos(lonRad); y += Math.cos(latRad) * Math.sin(lonRad); z += Math.sin(latRad); }); const count = points.length; x /= count; y /= count; z /= count; const hyp = Math.sqrt((x * x) + (y * y)); const centerLat = Math.atan2(z, hyp) * 180 / Math.PI; const centerLng = Math.atan2(y, x) * 180 / Math.PI; let meanAngularDistance = 0; const centerLatRad = centerLat * Math.PI / 180; const centerLngRad = centerLng * Math.PI / 180; points.forEach(point => { const latRad = point.lat * Math.PI / 180; const lonRad = point.lng * Math.PI / 180; const cosAngle = ( (Math.sin(centerLatRad) * Math.sin(latRad)) + (Math.cos(centerLatRad) * Math.cos(latRad) * Math.cos(lonRad - centerLngRad)) ); const safeCos = Math.max(-1, Math.min(1, cosAngle)); meanAngularDistance += Math.acos(safeCos) * 180 / Math.PI; }); meanAngularDistance /= count; const altitude = Math.min(2.9, Math.max(1.35, 1.35 + (meanAngularDistance / 45))); return { lat: centerLat, lng: centerLng, altitude: altitude }; } function ensureWebsdrGlobePopup(mapEl) { if (websdrGlobePopup) { if (websdrGlobePopup.parentElement !== mapEl) { mapEl.appendChild(websdrGlobePopup); } return; } websdrGlobePopup = document.createElement('div'); websdrGlobePopup.id = 'websdrGlobePopup'; websdrGlobePopup.style.position = 'absolute'; websdrGlobePopup.style.minWidth = '220px'; websdrGlobePopup.style.maxWidth = '260px'; websdrGlobePopup.style.padding = '10px'; websdrGlobePopup.style.borderRadius = '8px'; websdrGlobePopup.style.border = '1px solid rgba(0, 212, 255, 0.35)'; websdrGlobePopup.style.background = 'rgba(5, 13, 20, 0.92)'; websdrGlobePopup.style.backdropFilter = 'blur(4px)'; websdrGlobePopup.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.4)'; websdrGlobePopup.style.color = 'var(--text-primary)'; websdrGlobePopup.style.display = 'none'; websdrGlobePopup.style.zIndex = '20'; mapEl.appendChild(websdrGlobePopup); if (!mapEl.dataset.websdrPopupHooked) { mapEl.addEventListener('click', (event) => { if (!websdrGlobePopup || websdrGlobePopup.style.display === 'none') return; if (event.target.closest('#websdrGlobePopup')) return; hideWebsdrGlobePopup(); }); mapEl.dataset.websdrPopupHooked = 'true'; } } function showWebsdrGlobePopup(point, event) { if (!websdrGlobePopup || !point || point.receiverIndex == null) return; const rx = websdrReceivers[point.receiverIndex]; if (!rx) return; const mapEl = document.getElementById('websdrMap'); if (!mapEl) return; websdrSelectedReceiverIndex = point.receiverIndex; renderReceiverList(websdrReceivers); plotReceiversOnGlobe(websdrReceivers); websdrGlobePopup.innerHTML = `
${escapeHtmlWebsdr(rx.name)}
${rx.location ? `
${escapeHtmlWebsdr(rx.location)}
` : ''}
Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}
Users: ${rx.users}/${rx.users_max}
`; websdrGlobePopup.style.display = 'block'; const rect = mapEl.getBoundingClientRect(); const x = event && Number.isFinite(event.clientX) ? (event.clientX - rect.left) : (rect.width / 2); const y = event && Number.isFinite(event.clientY) ? (event.clientY - rect.top) : (rect.height / 2); const popupWidth = 260; const popupHeight = 155; const left = Math.max(12, Math.min(rect.width - popupWidth - 12, x + 12)); const top = Math.max(12, Math.min(rect.height - popupHeight - 12, y + 12)); websdrGlobePopup.style.left = `${left}px`; websdrGlobePopup.style.top = `${top}px`; const closeBtn = websdrGlobePopup.querySelector('[data-websdr-popup-close]'); if (closeBtn) { closeBtn.onclick = () => hideWebsdrGlobePopup(); } const listenBtn = websdrGlobePopup.querySelector('[data-websdr-listen]'); if (listenBtn) { listenBtn.onclick = () => selectReceiver(point.receiverIndex); } if (event && typeof event.stopPropagation === 'function') { event.stopPropagation(); } } function hideWebsdrGlobePopup() { if (websdrGlobePopup) { websdrGlobePopup.style.display = 'none'; } } function buildWebsdrPointLabel(rx, idx) { const location = rx.location ? escapeHtmlWebsdr(rx.location) : 'Unknown location'; const antenna = escapeHtmlWebsdr(rx.antenna || 'Unknown antenna'); return `
${escapeHtmlWebsdr(rx.name)}
${location}
${antenna} · ${rx.users}/${rx.users_max}
Receiver #${idx + 1}
`; } // ============== RECEIVER LIST ============== function renderReceiverList(receivers) { const container = document.getElementById('websdrReceiverList'); if (!container) return; if (receivers.length === 0) { container.innerHTML = '
No receivers found
'; return; } container.innerHTML = receivers.slice(0, 50).map((rx, idx) => { const selected = idx === websdrSelectedReceiverIndex; const baseBg = selected ? 'rgba(0,212,255,0.14)' : 'transparent'; const hoverBg = selected ? 'rgba(0,212,255,0.18)' : 'rgba(0,212,255,0.05)'; return `
${escapeHtmlWebsdr(rx.name)} ${rx.users}/${rx.users_max}
${rx.location ? escapeHtmlWebsdr(rx.location) + ' · ' : ''}${escapeHtmlWebsdr(rx.antenna || '')} ${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
`; }).join(''); } // ============== SELECT RECEIVER ============== function selectReceiver(index) { const rx = websdrReceivers[index]; if (!rx) return; const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000); const mode = document.getElementById('websdrMode_select')?.value || 'am'; websdrSelectedReceiverIndex = index; renderReceiverList(websdrReceivers); focusReceiverOnMap(rx); hideWebsdrGlobePopup(); kiwiReceiverName = rx.name; // Connect via backend proxy connectToReceiver(rx.url, freqKhz, mode); } function focusReceiverOnMap(rx) { const lat = Number(rx.lat); const lon = Number(rx.lon); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; if (websdrMapType === 'globe' && websdrGlobe) { plotReceiversOnGlobe(websdrReceivers); websdrGlobe.pointOfView({ lat: lat, lng: lon, altitude: 1.4 }, 900); return; } if (websdrMap) { websdrMap.setView([lat, lon], 6); } } // ============== KIWISDR AUDIO CONNECTION ============== function connectToReceiver(receiverUrl, freqKhz, mode) { // Disconnect if already connected if (kiwiWebSocket) { disconnectFromReceiver(); } const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/kiwi-audio`; kiwiWebSocket = new WebSocket(wsUrl); kiwiWebSocket.binaryType = 'arraybuffer'; kiwiWebSocket.onopen = () => { kiwiWebSocket.send(JSON.stringify({ cmd: 'connect', url: receiverUrl, freq_khz: freqKhz, mode: mode, })); updateKiwiUI('connecting'); }; kiwiWebSocket.onmessage = (event) => { if (typeof event.data === 'string') { const msg = JSON.parse(event.data); handleKiwiStatus(msg); } else { handleKiwiAudio(event.data); } }; kiwiWebSocket.onclose = () => { kiwiConnected = false; updateKiwiUI('disconnected'); }; kiwiWebSocket.onerror = () => { updateKiwiUI('disconnected'); }; } function handleKiwiStatus(msg) { switch (msg.type) { case 'connected': kiwiConnected = true; kiwiCurrentFreq = msg.freq_khz; kiwiCurrentMode = msg.mode; initKiwiAudioContext(msg.sample_rate || KIWI_SAMPLE_RATE); updateKiwiUI('connected'); break; case 'tuned': kiwiCurrentFreq = msg.freq_khz; kiwiCurrentMode = msg.mode; updateKiwiUI('connected'); break; case 'error': console.error('[KIWI] Error:', msg.message); if (typeof showNotification === 'function') { showNotification('WebSDR', msg.message); } updateKiwiUI('error'); break; case 'disconnected': kiwiConnected = false; cleanupKiwiAudio(); updateKiwiUI('disconnected'); break; } } function handleKiwiAudio(arrayBuffer) { if (arrayBuffer.byteLength < 4) return; // First 2 bytes: S-meter (big-endian int16) const view = new DataView(arrayBuffer); kiwiSmeter = view.getInt16(0, false); // Remaining bytes: PCM 16-bit signed LE const pcmData = new Int16Array(arrayBuffer, 2); // Convert to float32 [-1, 1] for Web Audio API const float32 = new Float32Array(pcmData.length); for (let i = 0; i < pcmData.length; i++) { float32[i] = pcmData[i] / 32768.0; } // Add to playback buffer (limit buffer size to ~2s) kiwiAudioBuffer.push(float32); const maxChunks = Math.ceil((KIWI_SAMPLE_RATE * 2) / 512); while (kiwiAudioBuffer.length > maxChunks) { kiwiAudioBuffer.shift(); } } function initKiwiAudioContext(sampleRate) { cleanupKiwiAudio(); kiwiAudioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: sampleRate, }); // Resume if suspended (autoplay policy) if (kiwiAudioContext.state === 'suspended') { kiwiAudioContext.resume(); } // ScriptProcessorNode: pulls audio from buffer kiwiScriptProcessor = kiwiAudioContext.createScriptProcessor(2048, 0, 1); kiwiScriptProcessor.onaudioprocess = (e) => { const output = e.outputBuffer.getChannelData(0); let offset = 0; while (offset < output.length && kiwiAudioBuffer.length > 0) { const chunk = kiwiAudioBuffer[0]; const needed = output.length - offset; const available = chunk.length; if (available <= needed) { output.set(chunk, offset); offset += available; kiwiAudioBuffer.shift(); } else { output.set(chunk.subarray(0, needed), offset); kiwiAudioBuffer[0] = chunk.subarray(needed); offset += needed; } } // Fill remaining with silence while (offset < output.length) { output[offset++] = 0; } }; // Volume control kiwiGainNode = kiwiAudioContext.createGain(); const savedVol = localStorage.getItem('kiwiVolume'); kiwiGainNode.gain.value = savedVol !== null ? parseFloat(savedVol) / 100 : 0.8; const volValue = Math.round(kiwiGainNode.gain.value * 100); ['kiwiVolume', 'kiwiBarVolume'].forEach(id => { const el = document.getElementById(id); if (el) el.value = volValue; }); kiwiScriptProcessor.connect(kiwiGainNode); kiwiGainNode.connect(kiwiAudioContext.destination); // S-meter display updates if (kiwiSmeterInterval) clearInterval(kiwiSmeterInterval); kiwiSmeterInterval = setInterval(updateSmeterDisplay, 200); } function disconnectFromReceiver() { if (kiwiWebSocket && kiwiWebSocket.readyState === WebSocket.OPEN) { kiwiWebSocket.send(JSON.stringify({ cmd: 'disconnect' })); } cleanupKiwiAudio(); if (kiwiWebSocket) { kiwiWebSocket.close(); kiwiWebSocket = null; } kiwiConnected = false; kiwiReceiverName = ''; updateKiwiUI('disconnected'); } function cleanupKiwiAudio() { if (kiwiSmeterInterval) { clearInterval(kiwiSmeterInterval); kiwiSmeterInterval = null; } if (kiwiScriptProcessor) { kiwiScriptProcessor.disconnect(); kiwiScriptProcessor = null; } if (kiwiGainNode) { kiwiGainNode.disconnect(); kiwiGainNode = null; } if (kiwiAudioContext) { kiwiAudioContext.close().catch(() => {}); kiwiAudioContext = null; } kiwiAudioBuffer = []; kiwiSmeter = 0; } function tuneKiwi(freqKhz, mode) { if (!kiwiWebSocket || !kiwiConnected) return; kiwiWebSocket.send(JSON.stringify({ cmd: 'tune', freq_khz: freqKhz, mode: mode || kiwiCurrentMode, })); } function tuneFromBar() { const freq = parseFloat(document.getElementById('kiwiBarFrequency')?.value || 0); const mode = document.getElementById('kiwiBarMode')?.value || kiwiCurrentMode; if (freq > 0) { tuneKiwi(freq, mode); // Also update sidebar frequency const freqInput = document.getElementById('websdrFrequency'); if (freqInput) freqInput.value = freq; } } function setKiwiVolume(value) { if (kiwiGainNode) { kiwiGainNode.gain.value = value / 100; localStorage.setItem('kiwiVolume', value); } // Sync both volume sliders ['kiwiVolume', 'kiwiBarVolume'].forEach(id => { const el = document.getElementById(id); if (el && el.value !== String(value)) el.value = value; }); } // ============== S-METER ============== function updateSmeterDisplay() { // KiwiSDR S-meter: value in 0.1 dBm units (e.g., -730 = -73 dBm = S9) const dbm = kiwiSmeter / 10; let sUnit; if (dbm >= -73) { const over = Math.round((dbm + 73)); sUnit = over > 0 ? `S9+${over}` : 'S9'; } else { sUnit = `S${Math.max(0, Math.round((dbm + 127) / 6))}`; } const pct = Math.min(100, Math.max(0, (dbm + 127) / 1.27)); // Update both sidebar and bar S-meter displays ['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => { const el = document.getElementById(id); if (el) el.style.width = pct + '%'; }); ['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => { const el = document.getElementById(id); if (el) el.textContent = sUnit; }); } // ============== UI UPDATES ============== function updateKiwiUI(state) { const statusEl = document.getElementById('kiwiStatus'); const controlsBar = document.getElementById('kiwiAudioControls'); const disconnectBtn = document.getElementById('kiwiDisconnectBtn'); const receiverNameEl = document.getElementById('kiwiReceiverName'); const freqDisplay = document.getElementById('kiwiFreqDisplay'); const barReceiverName = document.getElementById('kiwiBarReceiverName'); const barFreq = document.getElementById('kiwiBarFrequency'); const barMode = document.getElementById('kiwiBarMode'); if (state === 'connected') { if (statusEl) { statusEl.textContent = 'CONNECTED'; statusEl.style.color = 'var(--accent-green)'; } if (controlsBar) controlsBar.style.display = 'block'; if (disconnectBtn) disconnectBtn.style.display = 'block'; if (receiverNameEl) { receiverNameEl.textContent = kiwiReceiverName; receiverNameEl.style.display = 'block'; } if (freqDisplay) freqDisplay.textContent = kiwiCurrentFreq + ' kHz'; if (barReceiverName) barReceiverName.textContent = kiwiReceiverName; if (barFreq) barFreq.value = kiwiCurrentFreq; if (barMode) barMode.value = kiwiCurrentMode; } else if (state === 'connecting') { if (statusEl) { statusEl.textContent = 'CONNECTING...'; statusEl.style.color = 'var(--accent-orange)'; } } else if (state === 'error') { if (statusEl) { statusEl.textContent = 'ERROR'; statusEl.style.color = 'var(--accent-red)'; } } else { // disconnected if (statusEl) { statusEl.textContent = 'DISCONNECTED'; statusEl.style.color = 'var(--text-muted)'; } if (controlsBar) controlsBar.style.display = 'none'; if (disconnectBtn) disconnectBtn.style.display = 'none'; if (receiverNameEl) receiverNameEl.style.display = 'none'; if (freqDisplay) freqDisplay.textContent = '--- kHz'; // Reset both S-meter displays (sidebar + bar) ['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => { const el = document.getElementById(id); if (el) el.style.width = '0%'; }); ['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => { const el = document.getElementById(id); if (el) el.textContent = 'S0'; }); } } // ============== SPY STATION PRESETS ============== function loadSpyStationPresets() { fetch('/spy-stations/stations') .then(r => r.json()) .then(data => { websdrSpyStationsLoaded = true; const container = document.getElementById('websdrSpyPresets'); if (!container) return; const stations = data.stations || data || []; if (!Array.isArray(stations) || stations.length === 0) { container.innerHTML = '
No stations available
'; return; } container.innerHTML = stations.slice(0, 30).map(s => { const primaryFreq = s.frequencies?.find(f => f.primary) || s.frequencies?.[0]; const freqKhz = primaryFreq?.freq_khz || 0; return `
${escapeHtmlWebsdr(s.name)} ${escapeHtmlWebsdr(s.nickname || '')}
${freqKhz} kHz
`; }).join(''); }) .catch(err => { console.error('[WEBSDR] Failed to load spy station presets:', err); }); } function tuneToSpyStation(stationId, freqKhz) { const freqInput = document.getElementById('websdrFrequency'); if (freqInput) freqInput.value = freqKhz; // If already connected, just retune if (kiwiConnected) { const mode = document.getElementById('websdrMode_select')?.value || kiwiCurrentMode; tuneKiwi(freqKhz, mode); return; } // Otherwise, search for receivers at this frequency fetch(`/websdr/spy-station/${encodeURIComponent(stationId)}/receivers`) .then(r => r.json()) .then(data => { if (data.status === 'success') { websdrReceivers = data.receivers || []; websdrSelectedReceiverIndex = null; hideWebsdrGlobePopup(); renderReceiverList(websdrReceivers); plotReceiversOnMap(websdrReceivers); const countEl = document.getElementById('websdrReceiverCount'); if (countEl) countEl.textContent = `${websdrReceivers.length} for ${data.station?.name || stationId}`; if (typeof showNotification === 'function' && data.station) { showNotification('WebSDR', `Found ${websdrReceivers.length} receivers for ${data.station.name} at ${freqKhz} kHz`); } } }) .catch(err => console.error('[WEBSDR] Spy station receivers error:', err)); } // ============== UTILITIES ============== function escapeHtmlWebsdr(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // ============== EXPORTS ============== /** * Destroy — disconnect audio and clear S-meter timer for clean mode switching. */ function destroyWebSDR() { disconnectFromReceiver(); } const WebSDR = { destroy: destroyWebSDR }; window.initWebSDR = initWebSDR; window.searchReceivers = searchReceivers; window.selectReceiver = selectReceiver; window.tuneToSpyStation = tuneToSpyStation; window.loadSpyStationPresets = loadSpyStationPresets; window.connectToReceiver = connectToReceiver; window.disconnectFromReceiver = disconnectFromReceiver; window.tuneKiwi = tuneKiwi; window.tuneFromBar = tuneFromBar; window.setKiwiVolume = setKiwiVolume; window.WebSDR = WebSDR;