import { t } from '../i18n/index.js'; import { internetRadioAudioActive, localSdrCardView, localSdrFeedLine, localSdrFmAudioActive, localSdrGainOptionLabel, } from './localSdrPresentation.js'; import { useRadioStore } from './stores/radio.js'; import { applicationPinia } from './vueRuntime.js'; const ELEMENT_IDS = Object.freeze({ card: 'sdr-radio-card', connectionState: 'sdr-connection-state', connect: 'sdr-connect-btn', locate: 'sdr-locate-btn', changeDevice: 'sdr-change-device-btn', modeFm: 'sdr-mode-fm-btn', modeAdsb: 'sdr-mode-adsb-btn', gain: 'sdr-gain-select', stats: 'sdr-adsb-stats', statRate: 'sdr-stat-rate', statHeard: 'sdr-stat-heard', statPositioned: 'sdr-stat-positioned', statIq: 'sdr-stat-iq', frequency: 'sdr-frequency-input', tune: 'sdr-tune-btn', seekBack: 'sdr-seek-back-btn', seekForward: 'sdr-seek-forward-btn', volume: 'sdr-volume', volumeValue: 'sdr-volume-value', status: 'sdr-status', feedStatus: 'sdr-feed-status', }); /** * Local RTL-SDR card in the Radio panel. * * Owns the card's listeners and its receiver/radio subscriptions; the Radio * view renders the card from what this publishes (`gev-radio` store). The * Radio panel is one listening surface: starting local FM stops internet radio, and * starting internet radio stops local FM. */ export class LocalSdrControls { /** * @param {object} options * @param {Document} options.document Document containing the card. * @param {object} options.receiver Local RTL-SDR session. * @param {object} [options.feeds] Decoder-feed session (getState, * subscribe, probe); the card shows one read-only line when the server * has feeds configured. * @param {object} [options.radio] Internet-radio port (subscribe, stopPlayback). * @param {object} [options.actions] Application actions: * setLocalAdsbEnabled(enabled), isLocalAdsbEnabled(), scheduleLayout(). * @param {object} [options.store] Store the card's view is published to. */ constructor({ document: doc, receiver, feeds = null, radio = null, actions = {}, store = useRadioStore(applicationPinia()), }) { this.store = store; this.receiver = receiver; this.feeds = feeds; this.radio = radio; this.actions = actions; this.elements = Object.fromEntries( Object.entries(ELEMENT_IDS).map(([key, id]) => [ key, doc?.getElementById?.(id) || null, ]), ); this._listeners = new AbortController(); this._state = receiver.getState(); this._radioState = null; this._fmAudioActive = localSdrFmAudioActive(this._state); this._radioAudioActive = false; this._renderFrame = null; this._destroyed = false; this._view = null; if (!this.elements.card) return; this._labelGainOptions(); this._bind(); this._receiverUnsubscribe = receiver.subscribe((state) => this._onReceiverState(state), ); this._radioUnsubscribe = radio?.subscribe?.((state) => this._onRadioState(state)) || null; this._feedsUnsubscribe = feeds?.subscribe?.(() => this._scheduleRender()) || null; // One request, no polling: learn whether the server has decoder feeds. void feeds?.probe?.()?.catch?.(() => {}); this._render(); } /** Whether the receiver session is open (keeps the Radio panel expanded). */ isActive() { return Boolean(this._state?.connected); } /** Numeric gain options read in the page language; values stay numeric. */ _labelGainOptions() { for (const option of this.elements.gain?.options || []) { if (option.value !== 'auto') option.textContent = localSdrGainOptionLabel(option.value); } } _listen(element, type, handler) { element?.addEventListener(type, handler, { signal: this._listeners.signal, }); } _stopInternetRadio() { if (internetRadioAudioActive(this._radioState)) this.radio?.stopPlayback?.({ origin: 'user' }); } async _enableLayerWhenReceiving() { const state = this.receiver.getState(); if (state.connected && state.mode === 'adsb') await this.actions.setLocalAdsbEnabled?.(true); } _bind() { const el = this.elements; this._listen(el.connect, 'click', async () => { const before = this.receiver.getState(); if (before.connected) { await this.receiver.stop(); return; } if (before.mode === 'fm') this._stopInternetRadio(); if (await this.receiver.connect(before.mode)) await this._enableLayerWhenReceiving(); }); this._listen(el.locate, 'click', () => { void this.receiver.requestReceiverLocation(); }); this._listen(el.changeDevice, 'click', async () => { if (this.receiver.getState().mode === 'fm') this._stopInternetRadio(); if (await this.receiver.changeDevice()) await this._enableLayerWhenReceiving(); }); this._listen(el.modeFm, 'click', async () => { this._stopInternetRadio(); if (this.actions.isLocalAdsbEnabled?.()) await this.actions.setLocalAdsbEnabled?.(false); await this.receiver.setMode('fm'); }); this._listen(el.modeAdsb, 'click', async () => { if (await this.receiver.setMode('adsb')) await this._enableLayerWhenReceiving(); }); this._listen(el.gain, 'change', () => { void this.receiver.setGain(el.gain.value); }); const tune = () => { const mhz = Number(el.frequency?.value); if (!Number.isFinite(mhz)) return; this._stopInternetRadio(); void this.receiver.tuneFm(mhz * 1_000_000); }; this._listen(el.tune, 'click', tune); this._listen(el.frequency, 'keydown', (event) => { if (event.key !== 'Enter') return; event.preventDefault(); tune(); }); for (const [button, direction] of [ [el.seekBack, -1], [el.seekForward, 1], ]) { this._listen(button, 'click', () => { this._stopInternetRadio(); void this.receiver.seekFm(direction); }); } this._listen(el.volume, 'input', () => { const value = Number(el.volume.value); this.receiver.setVolume(value / 100); if (this._view) this._publish({ ...this._view, volumeText: `${value}%` }); }); } _onReceiverState(state) { this._state = state; const fmAudioActive = localSdrFmAudioActive(state); // Local FM just started: it takes over from internet radio. if (fmAudioActive && !this._fmAudioActive) this._stopInternetRadio(); this._fmAudioActive = fmAudioActive; this._scheduleRender(); } _onRadioState(state) { this._radioState = state; const radioAudioActive = internetRadioAudioActive(state); // Internet radio just started: it takes over from local FM. if (radioAudioActive && !this._radioAudioActive && this._fmAudioActive) { void this.receiver.stop({ message: t('Local FM stopped: internet radio started'), }); } this._radioAudioActive = radioAudioActive; } _scheduleRender() { if (this._destroyed || this._renderFrame !== null) return; const schedule = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (callback) => setTimeout(callback, 16); this._renderFrame = schedule(() => { this._renderFrame = null; if (!this._destroyed) this._render(); }); } _publish(view) { this._view = view; this.store?.publishSdr(view); } _render() { const view = localSdrCardView(this._state); const feedLine = localSdrFeedLine(this.feeds?.getState?.() || null); // The card leaves a focused field, and the FM frequency outside FM, as // they are. this._publish({ ...view, gain: view.gainValue, frequency: view.frequencyValue, volume: view.volumePercent, volumeText: `${view.volumePercent}%`, feedLine, }); // Only structural changes move the rail; per-block text updates do not. const layoutKey = `${view.statsHidden}|${view.connectionActive}|${feedLine === null}`; if (layoutKey !== this._layoutKey) { this._layoutKey = layoutKey; this.actions.scheduleLayout?.(); } } /** Release listeners and subscriptions; the receiver session is not closed. */ destroy() { this._destroyed = true; this._listeners.abort(); this._receiverUnsubscribe?.(); this._radioUnsubscribe?.(); this._feedsUnsubscribe?.(); this._feedsUnsubscribe = null; this._receiverUnsubscribe = null; this._radioUnsubscribe = null; if (this._renderFrame !== null) { if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this._renderFrame); else clearTimeout(this._renderFrame); this._renderFrame = null; } } }