mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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.
267 lines
8.6 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|