Files
gods-eye-view/src/ui/localSdrControls.js
T
Sameh Khamis b6c7c130f6 feat(ui): render the local RTL-SDR card from the store
LocalSdrControls publishes the card to the radio store and SdrCard renders it. The new v-field-value directive never overwrites a focused field, including one a render disables.
2026-10-31 00:43:27 -07:00

267 lines
8.6 KiB
JavaScript

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;
}
}
}