/** * Intercept - DMR / Digital Voice Mode * Decoding DMR, P25, NXDN, D-STAR digital voice protocols */ // ============== STATE ============== let isDmrRunning = false; let dmrEventSource = null; let dmrCallCount = 0; let dmrSyncCount = 0; let dmrCallHistory = []; let dmrCurrentProtocol = '--'; let dmrModeLabel = 'dmr'; // Protocol label for device reservation let dmrHasAudio = false; // ============== BOOKMARKS ============== let dmrBookmarks = []; const DMR_BOOKMARKS_KEY = 'dmrBookmarks'; const DMR_SETTINGS_KEY = 'dmrSettings'; const DMR_BOOKMARK_PROTOCOLS = new Set(['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']); // ============== SYNTHESIZER STATE ============== let dmrSynthCanvas = null; let dmrSynthCtx = null; let dmrSynthBars = []; let dmrSynthAnimationId = null; let dmrSynthInitialized = false; let dmrActivityLevel = 0; let dmrActivityTarget = 0; let dmrEventType = 'idle'; let dmrLastEventTime = 0; const DMR_BAR_COUNT = 48; const DMR_DECAY_RATE = 0.015; const DMR_BURST_SYNC = 0.6; const DMR_BURST_CALL = 0.85; const DMR_BURST_VOICE = 0.95; // ============== TOOLS CHECK ============== function checkDmrTools() { fetch('/dmr/tools') .then(r => r.json()) .then(data => { const warning = document.getElementById('dmrToolsWarning'); const warningText = document.getElementById('dmrToolsWarningText'); if (!warning) return; const selectedType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr'; const missing = []; if (!data.dsd) missing.push('dsd (Digital Speech Decoder)'); if (selectedType === 'rtlsdr') { if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)'); } else if (!data.rx_fm) { missing.push('rx_fm (SoapySDR demodulator)'); } if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)'); if (missing.length > 0) { warning.style.display = 'block'; if (warningText) warningText.textContent = missing.join(', '); } else { warning.style.display = 'none'; } // Update audio panel availability updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE'); }) .catch(() => {}); } // ============== START / STOP ============== function startDmr() { const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625); const protocol = document.getElementById('dmrProtocol')?.value || 'auto'; const gain = parseInt(document.getElementById('dmrGain')?.value || 40); const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0); const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false; const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr'; // Use protocol name for device reservation so panel shows "D-STAR", "P25", etc. dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr'; // Check device availability before starting if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) { return; } // Save settings to localStorage for persistence try { localStorage.setItem(DMR_SETTINGS_KEY, JSON.stringify({ frequency, protocol, gain, ppm, relaxCrc })); } catch (e) { /* localStorage unavailable */ } fetch('/dmr/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc, sdr_type: sdrType }) }) .then(r => r.json()) .then(data => { if (data.status === 'started') { isDmrRunning = true; dmrCallCount = 0; dmrSyncCount = 0; dmrCallHistory = []; updateDmrUI(); connectDmrSSE(); dmrEventType = 'idle'; dmrActivityTarget = 0.1; dmrLastEventTime = Date.now(); if (!dmrSynthInitialized) initDmrSynthesizer(); updateDmrSynthStatus(); const statusEl = document.getElementById('dmrStatus'); if (statusEl) statusEl.textContent = 'DECODING'; if (typeof reserveDevice === 'function') { reserveDevice(parseInt(device), dmrModeLabel); } // Start audio if available dmrHasAudio = !!data.has_audio; if (dmrHasAudio) startDmrAudio(); updateDmrAudioStatus(dmrHasAudio ? 'STREAMING' : 'UNAVAILABLE'); if (typeof showNotification === 'function') { showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`); } } else if (data.status === 'error' && data.message === 'Already running') { // Backend has an active session the frontend lost track of — resync isDmrRunning = true; updateDmrUI(); connectDmrSSE(); if (!dmrSynthInitialized) initDmrSynthesizer(); dmrEventType = 'idle'; dmrActivityTarget = 0.1; dmrLastEventTime = Date.now(); updateDmrSynthStatus(); const statusEl = document.getElementById('dmrStatus'); if (statusEl) statusEl.textContent = 'DECODING'; if (typeof showNotification === 'function') { showNotification('DMR', 'Reconnected to active session'); } } else { if (typeof showNotification === 'function') { showNotification('Error', data.message || 'Failed to start DMR'); } } }) .catch(err => console.error('[DMR] Start error:', err)); } function stopDmr() { stopDmrAudio(); fetch('/dmr/stop', { method: 'POST' }) .then(r => r.json()) .then(() => { isDmrRunning = false; if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; } updateDmrUI(); dmrEventType = 'stopped'; dmrActivityTarget = 0; updateDmrSynthStatus(); updateDmrAudioStatus('OFF'); const statusEl = document.getElementById('dmrStatus'); if (statusEl) statusEl.textContent = 'STOPPED'; if (typeof releaseDevice === 'function') { releaseDevice(dmrModeLabel); } }) .catch(err => console.error('[DMR] Stop error:', err)); } // ============== SSE STREAMING ============== function connectDmrSSE() { if (dmrEventSource) dmrEventSource.close(); dmrEventSource = new EventSource('/dmr/stream'); dmrEventSource.onmessage = function(event) { const msg = JSON.parse(event.data); handleDmrMessage(msg); }; dmrEventSource.onerror = function() { if (isDmrRunning) { setTimeout(connectDmrSSE, 2000); } }; } function handleDmrMessage(msg) { if (dmrSynthInitialized) dmrSynthPulse(msg.type); if (msg.type === 'sync') { dmrCurrentProtocol = msg.protocol || '--'; const protocolEl = document.getElementById('dmrActiveProtocol'); if (protocolEl) protocolEl.textContent = dmrCurrentProtocol; const mainProtocolEl = document.getElementById('dmrMainProtocol'); if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol; dmrSyncCount++; const syncCountEl = document.getElementById('dmrSyncCount'); if (syncCountEl) syncCountEl.textContent = dmrSyncCount; } else if (msg.type === 'call') { dmrCallCount++; const countEl = document.getElementById('dmrCallCount'); if (countEl) countEl.textContent = dmrCallCount; const mainCountEl = document.getElementById('dmrMainCallCount'); if (mainCountEl) mainCountEl.textContent = dmrCallCount; // Update current call display const slotInfo = msg.slot != null ? `
Slot ${msg.slot}
` : ''; const callEl = document.getElementById('dmrCurrentCall'); if (callEl) { callEl.innerHTML = `
Talkgroup ${msg.talkgroup}
Source ID ${msg.source_id}
${slotInfo}
Time ${msg.timestamp}
`; } // Add to history dmrCallHistory.unshift({ talkgroup: msg.talkgroup, source_id: msg.source_id, protocol: dmrCurrentProtocol, time: msg.timestamp, }); if (dmrCallHistory.length > 50) dmrCallHistory.length = 50; renderDmrHistory(); } else if (msg.type === 'slot') { // Update slot info in current call } else if (msg.type === 'raw') { // Raw DSD output — triggers synthesizer activity via dmrSynthPulse } else if (msg.type === 'heartbeat') { // Decoder is alive and listening — keep synthesizer in listening state if (isDmrRunning && dmrSynthInitialized) { if (dmrEventType === 'idle' || dmrEventType === 'raw') { dmrEventType = 'raw'; dmrActivityTarget = Math.max(dmrActivityTarget, 0.15); dmrLastEventTime = Date.now(); updateDmrSynthStatus(); } } } else if (msg.type === 'status') { const statusEl = document.getElementById('dmrStatus'); if (msg.text === 'started') { if (statusEl) statusEl.textContent = 'DECODING'; } else if (msg.text === 'crashed') { isDmrRunning = false; stopDmrAudio(); updateDmrUI(); dmrEventType = 'stopped'; dmrActivityTarget = 0; updateDmrSynthStatus(); updateDmrAudioStatus('OFF'); if (statusEl) statusEl.textContent = 'CRASHED'; if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel); const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`; if (typeof showNotification === 'function') { showNotification('DMR Error', detail); } } else if (msg.text === 'stopped') { isDmrRunning = false; stopDmrAudio(); updateDmrUI(); dmrEventType = 'stopped'; dmrActivityTarget = 0; updateDmrSynthStatus(); updateDmrAudioStatus('OFF'); if (statusEl) statusEl.textContent = 'STOPPED'; if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel); } } } // ============== UI ============== function updateDmrUI() { const startBtn = document.getElementById('startDmrBtn'); const stopBtn = document.getElementById('stopDmrBtn'); if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block'; if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none'; } function renderDmrHistory() { const container = document.getElementById('dmrHistoryBody'); if (!container) return; const historyCountEl = document.getElementById('dmrHistoryCount'); if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`; if (dmrCallHistory.length === 0) { container.innerHTML = 'No calls recorded'; return; } container.innerHTML = dmrCallHistory.slice(0, 20).map(call => ` ${call.time} ${call.talkgroup} ${call.source_id} ${call.protocol} `).join(''); } // ============== SYNTHESIZER ============== function initDmrSynthesizer() { dmrSynthCanvas = document.getElementById('dmrSynthCanvas'); if (!dmrSynthCanvas) return; // Use the canvas element's own rendered size for the backing buffer const rect = dmrSynthCanvas.getBoundingClientRect(); const w = Math.round(rect.width) || 600; const h = Math.round(rect.height) || 70; dmrSynthCanvas.width = w; dmrSynthCanvas.height = h; dmrSynthCtx = dmrSynthCanvas.getContext('2d'); dmrSynthBars = []; for (let i = 0; i < DMR_BAR_COUNT; i++) { dmrSynthBars[i] = { height: 2, targetHeight: 2, velocity: 0 }; } dmrActivityLevel = 0; dmrActivityTarget = 0; dmrEventType = isDmrRunning ? 'idle' : 'stopped'; dmrSynthInitialized = true; updateDmrSynthStatus(); if (dmrSynthAnimationId) cancelAnimationFrame(dmrSynthAnimationId); drawDmrSynthesizer(); } function drawDmrSynthesizer() { if (!dmrSynthCtx || !dmrSynthCanvas) return; const width = dmrSynthCanvas.width; const height = dmrSynthCanvas.height; const barWidth = (width / DMR_BAR_COUNT) - 2; const now = Date.now(); // Clear canvas dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'; dmrSynthCtx.fillRect(0, 0, width, height); // Decay activity toward target. Window must exceed the backend // heartbeat interval (3s) so the status doesn't flip-flop between // LISTENING and IDLE on every heartbeat cycle. const timeSinceEvent = now - dmrLastEventTime; if (timeSinceEvent > 5000) { // No events for 5s — decay target toward idle dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE); if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') { dmrEventType = 'idle'; updateDmrSynthStatus(); } } // Smooth approach to target dmrActivityLevel += (dmrActivityTarget - dmrActivityLevel) * 0.08; // Determine effective activity (idle breathing when stopped/idle) let effectiveActivity = dmrActivityLevel; if (dmrEventType === 'stopped') { effectiveActivity = 0; } else if (effectiveActivity < 0.1 && isDmrRunning) { // Visible idle breathing — shows decoder is alive and listening effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06; } // Ripple timing for sync events const syncRippleAge = (dmrEventType === 'sync' && timeSinceEvent < 500) ? 1 - (timeSinceEvent / 500) : 0; // Voice ripple overlay const voiceRipple = (dmrEventType === 'voice') ? Math.sin(now / 60) * 0.15 : 0; // Update bar targets and physics for (let i = 0; i < DMR_BAR_COUNT; i++) { const time = now / 200; const wave1 = Math.sin(time + i * 0.3) * 0.2; const wave2 = Math.sin(time * 1.7 + i * 0.5) * 0.15; const randomAmount = 0.05 + effectiveActivity * 0.25; const random = (Math.random() - 0.5) * randomAmount; // Bell curve — center bars taller const centerDist = Math.abs(i - DMR_BAR_COUNT / 2) / (DMR_BAR_COUNT / 2); const centerBoost = 1 - centerDist * 0.5; // Sync ripple: center-outward wave burst let rippleBoost = 0; if (syncRippleAge > 0) { const ripplePos = (1 - syncRippleAge) * DMR_BAR_COUNT / 2; const distFromRipple = Math.abs(i - DMR_BAR_COUNT / 2) - ripplePos; rippleBoost = Math.max(0, 1 - Math.abs(distFromRipple) / 4) * syncRippleAge * 0.4; } const baseHeight = 0.1 + effectiveActivity * 0.55; dmrSynthBars[i].targetHeight = Math.max(2, (baseHeight + wave1 + wave2 + random + rippleBoost + voiceRipple) * effectiveActivity * centerBoost * height ); // Spring physics const springStrength = effectiveActivity > 0.3 ? 0.15 : 0.1; const diff = dmrSynthBars[i].targetHeight - dmrSynthBars[i].height; dmrSynthBars[i].velocity += diff * springStrength; dmrSynthBars[i].velocity *= 0.78; dmrSynthBars[i].height += dmrSynthBars[i].velocity; dmrSynthBars[i].height = Math.max(2, Math.min(height - 4, dmrSynthBars[i].height)); } // Draw bars for (let i = 0; i < DMR_BAR_COUNT; i++) { const x = i * (barWidth + 2) + 1; const barHeight = dmrSynthBars[i].height; const y = (height - barHeight) / 2; // HSL color by event type let hue, saturation, lightness; if (dmrEventType === 'voice' && timeSinceEvent < 3000) { hue = 30; // Orange saturation = 85; lightness = 40 + (barHeight / height) * 25; } else if (dmrEventType === 'call' && timeSinceEvent < 3000) { hue = 120; // Green saturation = 80; lightness = 35 + (barHeight / height) * 30; } else if (dmrEventType === 'sync' && timeSinceEvent < 2000) { hue = 185; // Cyan saturation = 85; lightness = 38 + (barHeight / height) * 25; } else if (dmrEventType === 'stopped') { hue = 220; saturation = 20; lightness = 18 + (barHeight / height) * 8; } else { // Idle / decayed hue = 210; saturation = 40; lightness = 25 + (barHeight / height) * 15; } // Vertical gradient per bar const gradient = dmrSynthCtx.createLinearGradient(x, y, x, y + barHeight); gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`); gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`); gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`); dmrSynthCtx.fillStyle = gradient; dmrSynthCtx.fillRect(x, y, barWidth, barHeight); // Glow on tall bars if (barHeight > height * 0.5 && effectiveActivity > 0.4) { dmrSynthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`; dmrSynthCtx.shadowBlur = 8; dmrSynthCtx.fillRect(x, y, barWidth, barHeight); dmrSynthCtx.shadowBlur = 0; } } // Center line dmrSynthCtx.strokeStyle = 'rgba(0, 212, 255, 0.15)'; dmrSynthCtx.lineWidth = 1; dmrSynthCtx.beginPath(); dmrSynthCtx.moveTo(0, height / 2); dmrSynthCtx.lineTo(width, height / 2); dmrSynthCtx.stroke(); dmrSynthAnimationId = requestAnimationFrame(drawDmrSynthesizer); } function dmrSynthPulse(type) { dmrLastEventTime = Date.now(); if (type === 'sync') { dmrActivityTarget = Math.max(dmrActivityTarget, DMR_BURST_SYNC); dmrEventType = 'sync'; } else if (type === 'call') { dmrActivityTarget = DMR_BURST_CALL; dmrEventType = 'call'; } else if (type === 'voice') { dmrActivityTarget = DMR_BURST_VOICE; dmrEventType = 'voice'; } else if (type === 'slot' || type === 'nac') { dmrActivityTarget = Math.max(dmrActivityTarget, 0.5); } else if (type === 'raw') { // Any DSD output means the decoder is alive and processing dmrActivityTarget = Math.max(dmrActivityTarget, 0.25); if (dmrEventType === 'idle') dmrEventType = 'raw'; } // keepalive and status don't change visuals updateDmrSynthStatus(); } function updateDmrSynthStatus() { const el = document.getElementById('dmrSynthStatus'); if (!el) return; const labels = { stopped: 'STOPPED', idle: 'IDLE', raw: 'LISTENING', sync: 'SYNC', call: 'CALL', voice: 'VOICE' }; const colors = { stopped: 'var(--text-muted)', idle: 'var(--text-muted)', raw: '#607d8b', sync: '#00e5ff', call: '#4caf50', voice: '#ff9800' }; el.textContent = labels[dmrEventType] || 'IDLE'; el.style.color = colors[dmrEventType] || 'var(--text-muted)'; } function resizeDmrSynthesizer() { if (!dmrSynthCanvas) return; const rect = dmrSynthCanvas.getBoundingClientRect(); if (rect.width > 0) { dmrSynthCanvas.width = Math.round(rect.width); dmrSynthCanvas.height = Math.round(rect.height) || 70; } } function stopDmrSynthesizer() { if (dmrSynthAnimationId) { cancelAnimationFrame(dmrSynthAnimationId); dmrSynthAnimationId = null; } } window.addEventListener('resize', resizeDmrSynthesizer); // ============== AUDIO ============== function startDmrAudio() { const audioPlayer = document.getElementById('dmrAudioPlayer'); if (!audioPlayer) return; const streamUrl = `/dmr/audio/stream?t=${Date.now()}`; audioPlayer.src = streamUrl; const volSlider = document.getElementById('dmrAudioVolume'); if (volSlider) audioPlayer.volume = volSlider.value / 100; audioPlayer.onplaying = () => updateDmrAudioStatus('STREAMING'); audioPlayer.onerror = () => { // Retry if decoder is still running (stream may have dropped) if (isDmrRunning && dmrHasAudio) { console.warn('[DMR] Audio stream error, retrying in 2s...'); updateDmrAudioStatus('RECONNECTING'); setTimeout(() => { if (isDmrRunning && dmrHasAudio) startDmrAudio(); }, 2000); } else { updateDmrAudioStatus('OFF'); } }; audioPlayer.play().catch(e => { console.warn('[DMR] Audio autoplay blocked:', e); if (typeof showNotification === 'function') { showNotification('Audio Ready', 'Click the page or interact to enable audio playback'); } }); } function stopDmrAudio() { const audioPlayer = document.getElementById('dmrAudioPlayer'); if (audioPlayer) { audioPlayer.pause(); audioPlayer.src = ''; } dmrHasAudio = false; } function setDmrAudioVolume(value) { const audioPlayer = document.getElementById('dmrAudioPlayer'); if (audioPlayer) audioPlayer.volume = value / 100; } function updateDmrAudioStatus(status) { const el = document.getElementById('dmrAudioStatus'); if (!el) return; el.textContent = status; const colors = { 'OFF': 'var(--text-muted)', 'STREAMING': 'var(--accent-green)', 'ERROR': 'var(--accent-red)', 'UNAVAILABLE': 'var(--text-muted)', }; el.style.color = colors[status] || 'var(--text-muted)'; } // ============== SETTINGS PERSISTENCE ============== function restoreDmrSettings() { try { const saved = localStorage.getItem(DMR_SETTINGS_KEY); if (!saved) return; const s = JSON.parse(saved); const freqEl = document.getElementById('dmrFrequency'); const protoEl = document.getElementById('dmrProtocol'); const gainEl = document.getElementById('dmrGain'); const ppmEl = document.getElementById('dmrPPM'); const crcEl = document.getElementById('dmrRelaxCrc'); if (freqEl && s.frequency != null) freqEl.value = s.frequency; if (protoEl && s.protocol) protoEl.value = s.protocol; if (gainEl && s.gain != null) gainEl.value = s.gain; if (ppmEl && s.ppm != null) ppmEl.value = s.ppm; if (crcEl && s.relaxCrc != null) crcEl.checked = s.relaxCrc; } catch (e) { /* localStorage unavailable */ } } // ============== BOOKMARKS ============== function loadDmrBookmarks() { try { const saved = localStorage.getItem(DMR_BOOKMARKS_KEY); const parsed = saved ? JSON.parse(saved) : []; if (!Array.isArray(parsed)) { dmrBookmarks = []; } else { dmrBookmarks = parsed .map((entry) => { const freq = Number(entry?.freq); if (!Number.isFinite(freq) || freq <= 0) return null; const protocol = sanitizeDmrBookmarkProtocol(entry?.protocol); const rawLabel = String(entry?.label || '').trim(); const label = rawLabel || `${freq.toFixed(4)} MHz`; return { freq, protocol, label, added: entry?.added, }; }) .filter(Boolean); } } catch (e) { dmrBookmarks = []; } renderDmrBookmarks(); } function saveDmrBookmarks() { try { localStorage.setItem(DMR_BOOKMARKS_KEY, JSON.stringify(dmrBookmarks)); } catch (e) { /* localStorage unavailable */ } } function sanitizeDmrBookmarkProtocol(protocol) { const value = String(protocol || 'auto').toLowerCase(); return DMR_BOOKMARK_PROTOCOLS.has(value) ? value : 'auto'; } function addDmrBookmark() { const freqInput = document.getElementById('dmrBookmarkFreq'); const labelInput = document.getElementById('dmrBookmarkLabel'); if (!freqInput) return; const freq = parseFloat(freqInput.value); if (isNaN(freq) || freq <= 0) { if (typeof showNotification === 'function') { showNotification('Invalid Frequency', 'Enter a valid frequency'); } return; } const protocol = sanitizeDmrBookmarkProtocol(document.getElementById('dmrProtocol')?.value || 'auto'); const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`; // Duplicate check if (dmrBookmarks.some(b => b.freq === freq && b.protocol === protocol)) { if (typeof showNotification === 'function') { showNotification('Duplicate', 'This frequency/protocol is already bookmarked'); } return; } dmrBookmarks.push({ freq, protocol, label, added: new Date().toISOString() }); saveDmrBookmarks(); renderDmrBookmarks(); freqInput.value = ''; if (labelInput) labelInput.value = ''; if (typeof showNotification === 'function') { showNotification('Bookmark Added', `${freq.toFixed(4)} MHz saved`); } } function addCurrentDmrFreqBookmark() { const freqEl = document.getElementById('dmrFrequency'); const freqInput = document.getElementById('dmrBookmarkFreq'); if (freqEl && freqInput) { freqInput.value = freqEl.value; } addDmrBookmark(); } function removeDmrBookmark(index) { dmrBookmarks.splice(index, 1); saveDmrBookmarks(); renderDmrBookmarks(); } function dmrQuickTune(freq, protocol) { const freqEl = document.getElementById('dmrFrequency'); const protoEl = document.getElementById('dmrProtocol'); if (freqEl && Number.isFinite(freq)) freqEl.value = freq; if (protoEl) protoEl.value = sanitizeDmrBookmarkProtocol(protocol); } function renderDmrBookmarks() { const container = document.getElementById('dmrBookmarksList'); if (!container) return; container.replaceChildren(); if (dmrBookmarks.length === 0) { const emptyEl = document.createElement('div'); emptyEl.style.color = 'var(--text-muted)'; emptyEl.style.textAlign = 'center'; emptyEl.style.padding = '10px'; emptyEl.style.fontSize = '11px'; emptyEl.textContent = 'No bookmarks saved'; container.appendChild(emptyEl); return; } dmrBookmarks.forEach((b, i) => { const row = document.createElement('div'); row.style.display = 'flex'; row.style.justifyContent = 'space-between'; row.style.alignItems = 'center'; row.style.padding = '4px 6px'; row.style.background = 'rgba(0,0,0,0.2)'; row.style.borderRadius = '3px'; row.style.marginBottom = '3px'; const tuneBtn = document.createElement('button'); tuneBtn.type = 'button'; tuneBtn.style.cursor = 'pointer'; tuneBtn.style.color = 'var(--accent-cyan)'; tuneBtn.style.fontSize = '11px'; tuneBtn.style.flex = '1'; tuneBtn.style.background = 'none'; tuneBtn.style.border = 'none'; tuneBtn.style.textAlign = 'left'; tuneBtn.style.padding = '0'; tuneBtn.textContent = b.label; tuneBtn.title = `${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})`; tuneBtn.addEventListener('click', () => dmrQuickTune(b.freq, b.protocol)); const protocolEl = document.createElement('span'); protocolEl.style.color = 'var(--text-muted)'; protocolEl.style.fontSize = '9px'; protocolEl.style.margin = '0 6px'; protocolEl.textContent = b.protocol.toUpperCase(); const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.style.background = 'none'; deleteBtn.style.border = 'none'; deleteBtn.style.color = 'var(--accent-red)'; deleteBtn.style.cursor = 'pointer'; deleteBtn.style.fontSize = '12px'; deleteBtn.style.padding = '0 4px'; deleteBtn.textContent = '\u00d7'; deleteBtn.addEventListener('click', () => removeDmrBookmark(i)); row.appendChild(tuneBtn); row.appendChild(protocolEl); row.appendChild(deleteBtn); container.appendChild(row); }); } // ============== STATUS SYNC ============== function checkDmrStatus() { fetch('/dmr/status') .then(r => r.json()) .then(data => { if (data.running && !isDmrRunning) { // Backend is running but frontend lost track — resync isDmrRunning = true; updateDmrUI(); connectDmrSSE(); if (!dmrSynthInitialized) initDmrSynthesizer(); dmrEventType = 'idle'; dmrActivityTarget = 0.1; dmrLastEventTime = Date.now(); updateDmrSynthStatus(); const statusEl = document.getElementById('dmrStatus'); if (statusEl) statusEl.textContent = 'DECODING'; } else if (!data.running && isDmrRunning) { // Backend stopped but frontend didn't know isDmrRunning = false; if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; } updateDmrUI(); dmrEventType = 'stopped'; dmrActivityTarget = 0; updateDmrSynthStatus(); const statusEl = document.getElementById('dmrStatus'); if (statusEl) statusEl.textContent = 'STOPPED'; } }) .catch(() => {}); } // ============== INIT ============== document.addEventListener('DOMContentLoaded', () => { restoreDmrSettings(); loadDmrBookmarks(); }); // ============== EXPORTS ============== window.startDmr = startDmr; window.stopDmr = stopDmr; window.checkDmrTools = checkDmrTools; window.checkDmrStatus = checkDmrStatus; window.initDmrSynthesizer = initDmrSynthesizer; window.setDmrAudioVolume = setDmrAudioVolume; window.addDmrBookmark = addDmrBookmark; window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark; window.removeDmrBookmark = removeDmrBookmark; window.dmrQuickTune = dmrQuickTune;