mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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.
This commit is contained in:
1 parent
ccb7edd0d1
commit
b6c7c130f6
11 files changed
+402
-158
No files matched your search
@@ -104,9 +104,11 @@ They contain no application state.
|
||||
component compiler; the other groups follow one owner at a time),
|
||||
Context and Radio (in progress: `RadioPanel` renders the directory row,
|
||||
station card, transport, volume and playback line from the `gev-radio`
|
||||
store that RadioControls publishes to; the tuner, the local SDR card and
|
||||
the Cockpit radio controls keep their owners' element bindings), the
|
||||
command dock,
|
||||
store that RadioControls publishes to, and `SdrCard` the local RTL-SDR
|
||||
card that LocalSdrControls publishes beside it; fields a person edits take
|
||||
their value through `v-field-value`, which never writes a focused field;
|
||||
the tuner and the Cockpit radio controls keep their owners' element
|
||||
bindings), the command dock,
|
||||
the phone shell, then Cockpit. Layers that write into the right rail
|
||||
directly gain a slot instead.
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"src/ui/views/CommandDockView.vue",
|
||||
"src/ui/views/ContextView.vue",
|
||||
"src/ui/views/panels/RadioPanel.vue",
|
||||
"src/ui/views/panels/SdrCard.vue",
|
||||
"src/ui/views/DisplayControlsView.vue",
|
||||
"src/ui/views/LayerPanelsView.vue",
|
||||
"src/ui/views/ProviderSettingsView.vue",
|
||||
@@ -57,7 +58,8 @@
|
||||
"src/ui/views/panels/DisplayPanel.vue",
|
||||
"src/ui/stores/display.js",
|
||||
"src/ui/rail/rangeValue.js",
|
||||
"src/ui/rail/classToggle.js"
|
||||
"src/ui/rail/classToggle.js",
|
||||
"src/ui/rail/fieldValue.js"
|
||||
],
|
||||
"external": ["vue", "pinia"]
|
||||
},
|
||||
|
||||
+25
-67
@@ -6,6 +6,8 @@ import {
|
||||
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',
|
||||
@@ -31,15 +33,12 @@ const ELEMENT_IDS = Object.freeze({
|
||||
feedStatus: 'sdr-feed-status',
|
||||
});
|
||||
|
||||
function setText(element, text) {
|
||||
if (element && element.textContent !== text) element.textContent = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local RTL-SDR card in the Radio panel.
|
||||
*
|
||||
* Owns the card's listeners and its receiver/radio subscriptions. The Radio
|
||||
* panel is one listening surface: starting local FM stops internet radio, and
|
||||
* 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 {
|
||||
@@ -53,6 +52,7 @@ export class LocalSdrControls {
|
||||
* @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,
|
||||
@@ -60,7 +60,9 @@ export class LocalSdrControls {
|
||||
feeds = null,
|
||||
radio = null,
|
||||
actions = {},
|
||||
store = useRadioStore(applicationPinia()),
|
||||
}) {
|
||||
this.store = store;
|
||||
this.receiver = receiver;
|
||||
this.feeds = feeds;
|
||||
this.radio = radio;
|
||||
@@ -78,6 +80,7 @@ export class LocalSdrControls {
|
||||
this._radioAudioActive = false;
|
||||
this._renderFrame = null;
|
||||
this._destroyed = false;
|
||||
this._view = null;
|
||||
if (!this.elements.card) return;
|
||||
this._labelGainOptions();
|
||||
this._bind();
|
||||
@@ -180,7 +183,7 @@ export class LocalSdrControls {
|
||||
this._listen(el.volume, 'input', () => {
|
||||
const value = Number(el.volume.value);
|
||||
this.receiver.setVolume(value / 100);
|
||||
setText(el.volumeValue, `${value}%`);
|
||||
if (this._view) this._publish({ ...this._view, volumeText: `${value}%` });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -217,69 +220,24 @@ export class LocalSdrControls {
|
||||
});
|
||||
}
|
||||
|
||||
_publish(view) {
|
||||
this._view = view;
|
||||
this.store?.publishSdr(view);
|
||||
}
|
||||
|
||||
_render() {
|
||||
const el = this.elements;
|
||||
const view = localSdrCardView(this._state);
|
||||
const doc = el.card?.ownerDocument;
|
||||
setText(el.connectionState, view.connectionLabel);
|
||||
el.connectionState?.classList.toggle('active', view.connectionActive);
|
||||
if (el.connect) {
|
||||
setText(el.connect, view.connectLabel);
|
||||
el.connect.disabled = view.connectDisabled;
|
||||
el.connect.classList.toggle('active', view.connectPressed);
|
||||
el.connect.setAttribute('aria-pressed', String(view.connectPressed));
|
||||
}
|
||||
for (const [button, active] of [
|
||||
[el.modeFm, view.fmActive],
|
||||
[el.modeAdsb, view.adsbActive],
|
||||
]) {
|
||||
if (!button) continue;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
button.disabled = view.modeDisabled;
|
||||
}
|
||||
if (el.locate) {
|
||||
setText(el.locate, view.locateLabel);
|
||||
el.locate.disabled = view.locateDisabled;
|
||||
el.locate.classList.toggle('active', view.locateActive);
|
||||
}
|
||||
if (el.changeDevice) el.changeDevice.disabled = view.changeDeviceDisabled;
|
||||
if (el.gain) {
|
||||
if (doc?.activeElement !== el.gain && el.gain.value !== view.gainValue)
|
||||
el.gain.value = view.gainValue;
|
||||
el.gain.disabled = view.gainDisabled;
|
||||
}
|
||||
if (el.stats) el.stats.hidden = view.statsHidden;
|
||||
setText(el.statRate, view.stats.rate);
|
||||
setText(el.statHeard, view.stats.heard);
|
||||
setText(el.statPositioned, view.stats.positioned);
|
||||
setText(el.statIq, view.stats.iq);
|
||||
if (el.frequency) {
|
||||
el.frequency.disabled = view.frequencyDisabled;
|
||||
if (doc?.activeElement !== el.frequency && view.frequencyValue !== null)
|
||||
el.frequency.value = view.frequencyValue;
|
||||
}
|
||||
if (el.tune) el.tune.disabled = view.tuneDisabled;
|
||||
if (el.seekBack) el.seekBack.disabled = view.seekDisabled;
|
||||
if (el.seekForward) el.seekForward.disabled = view.seekDisabled;
|
||||
if (el.volume) {
|
||||
if (doc?.activeElement !== el.volume)
|
||||
el.volume.value = String(view.volumePercent);
|
||||
el.volume.disabled = view.volumeDisabled;
|
||||
}
|
||||
setText(el.volumeValue, `${view.volumePercent}%`);
|
||||
if (el.status) {
|
||||
setText(el.status, view.statusText);
|
||||
el.status.classList.toggle('error', view.statusError);
|
||||
}
|
||||
const feedLine = localSdrFeedLine(this.feeds?.getState?.() || null);
|
||||
if (el.feedStatus) {
|
||||
el.feedStatus.hidden = feedLine === null;
|
||||
setText(el.feedStatus, feedLine || '');
|
||||
}
|
||||
el.card
|
||||
?.closest?.('#radio-panel')
|
||||
?.classList.toggle('sdr-connected', view.connectionActive);
|
||||
// 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) {
|
||||
|
||||
@@ -184,15 +184,16 @@ test('the card shows one read-only decoder-feed line after a single probe', asyn
|
||||
for (const listener of listeners) listener(state);
|
||||
},
|
||||
};
|
||||
let shown = null;
|
||||
const controls = new LocalSdrControls({
|
||||
document: doc,
|
||||
receiver: stubReceiver(),
|
||||
feeds,
|
||||
store: { publishSdr: (view) => (shown = view) },
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
const line = doc.getElementById('sdr-feed-status');
|
||||
assert.equal(probes, 1);
|
||||
assert.equal(line.hidden, true);
|
||||
assert.equal(shown.feedLine, null);
|
||||
feeds.set({
|
||||
configured: true,
|
||||
polling: true,
|
||||
@@ -202,6 +203,5 @@ test('the card shows one read-only decoder-feed line after a single probe', asyn
|
||||
],
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(line.hidden, false);
|
||||
assert.equal(line.textContent, 'Decoder feeds: 1090 live · 978 live');
|
||||
assert.equal(shown.feedLine, 'Decoder feeds: 1090 live · 978 live');
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
showRadioVolume,
|
||||
} from './radioPresentation.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { LocalSdrControls } from './localSdrControls.js';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
@@ -217,3 +218,148 @@ test('a toggle that ends mid-transition does not leave Enable marked idle', asyn
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'true', 'the next render reports it');
|
||||
assert.equal(enable.getAttribute('aria-disabled'), 'true');
|
||||
});
|
||||
|
||||
function sdrReceiver() {
|
||||
const listeners = new Set();
|
||||
let state = {
|
||||
webUsbSupported: true,
|
||||
connected: false,
|
||||
status: 'idle',
|
||||
message: 'Connect an RTL-SDR to begin.',
|
||||
mode: 'fm',
|
||||
frequencyHz: 98_500_000,
|
||||
volume: 0.8,
|
||||
gain: 'auto',
|
||||
locationStatus: 'unknown',
|
||||
};
|
||||
return {
|
||||
volumes: [],
|
||||
getState: () => ({ ...state }),
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
emit(patch) {
|
||||
state = { ...state, ...patch };
|
||||
for (const listener of listeners) listener({ ...state });
|
||||
},
|
||||
setVolume(value) {
|
||||
this.volumes.push(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the RTL-SDR card renders what LocalSdrControls presents', async (t) => {
|
||||
const { $, owner } = fixture(t);
|
||||
const receiver = sdrReceiver();
|
||||
const controls = new LocalSdrControls({ document, receiver, store: owner.store });
|
||||
t.after(() => controls.destroy());
|
||||
const frame = () => new Promise((resolve) => setTimeout(resolve, 40));
|
||||
await nextTick();
|
||||
assert.equal($('sdr-connection-state').getAttribute('class'), null);
|
||||
assert.equal($('sdr-connect-btn').textContent, 'CONNECT');
|
||||
assert.equal($('sdr-connect-btn').disabled, false);
|
||||
assert.equal($('sdr-mode-fm-btn').className, 'active');
|
||||
assert.equal($('sdr-mode-adsb-btn').getAttribute('aria-pressed'), 'false');
|
||||
assert.equal($('sdr-adsb-stats').hidden, true);
|
||||
assert.equal($('sdr-frequency-input').value, '98.5');
|
||||
assert.equal($('sdr-feed-status').hidden, true);
|
||||
|
||||
receiver.emit({
|
||||
connected: true,
|
||||
status: 'streaming',
|
||||
mode: 'adsb',
|
||||
gain: 20.7,
|
||||
aircraftHeard: 3,
|
||||
aircraftPositioned: 2,
|
||||
messagesPerSecond: 41.25,
|
||||
});
|
||||
await frame();
|
||||
const panel = $('radio-panel');
|
||||
assert.equal(panel.classList.contains('sdr-connected'), true);
|
||||
assert.equal(panel.classList.contains('collapsed'), true, 'chrome keeps its class');
|
||||
assert.equal($('sdr-connection-state').className, 'active');
|
||||
assert.equal($('sdr-connect-btn').textContent, 'DISCONNECT');
|
||||
assert.equal($('sdr-connect-btn').className, 'panel-layer-toggle active');
|
||||
assert.equal($('sdr-mode-fm-btn').className, '');
|
||||
assert.equal($('sdr-mode-adsb-btn').className, 'active');
|
||||
assert.equal($('sdr-adsb-stats').hidden, false);
|
||||
assert.equal($('sdr-stat-heard').textContent, '3');
|
||||
assert.equal($('sdr-stat-positioned').textContent, '2');
|
||||
assert.equal($('sdr-gain-select').value, '20.7');
|
||||
assert.equal($('sdr-frequency-input').disabled, true);
|
||||
|
||||
// A focused field keeps the value the person is setting (FM volume).
|
||||
receiver.emit({ mode: 'fm' });
|
||||
await frame();
|
||||
const volume = $('sdr-volume');
|
||||
assert.equal(volume.disabled, false);
|
||||
volume.focus();
|
||||
volume.value = '35';
|
||||
volume.dispatchEvent(new Event('input'));
|
||||
await nextTick();
|
||||
assert.deepEqual(receiver.volumes, [0.35]);
|
||||
assert.equal($('sdr-volume-value').textContent, '35%');
|
||||
receiver.emit({ volume: 0.6 });
|
||||
await frame();
|
||||
assert.equal(volume.value, '35');
|
||||
volume.blur();
|
||||
receiver.emit({ volume: 0.6, connected: false, status: 'idle' });
|
||||
await frame();
|
||||
assert.equal(volume.value, '60');
|
||||
assert.equal($('sdr-volume-value').textContent, '60%');
|
||||
assert.equal(panel.classList.contains('sdr-connected'), false);
|
||||
});
|
||||
|
||||
test('focused RTL-SDR fields keep what the person typed or picked', async (t) => {
|
||||
const { $, owner, render } = fixture(t);
|
||||
const receiver = sdrReceiver();
|
||||
let feeds = { configured: null, polling: false, feeds: [] };
|
||||
const feedListeners = new Set();
|
||||
const controls = new LocalSdrControls({
|
||||
document,
|
||||
receiver,
|
||||
store: owner.store,
|
||||
feeds: {
|
||||
getState: () => feeds,
|
||||
subscribe(listener) {
|
||||
feedListeners.add(listener);
|
||||
return () => feedListeners.delete(listener);
|
||||
},
|
||||
probe: async () => {},
|
||||
},
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
const frame = () => new Promise((resolve) => setTimeout(resolve, 40));
|
||||
receiver.emit({ connected: true, status: 'streaming' });
|
||||
await frame();
|
||||
const frequency = $('sdr-frequency-input');
|
||||
frequency.focus();
|
||||
frequency.value = '101.1';
|
||||
receiver.emit({ frequencyHz: 98_700_000 });
|
||||
await frame();
|
||||
assert.equal(frequency.value, '101.1', 'typing is not undone');
|
||||
await render({});
|
||||
assert.equal(frequency.value, '101.1', 'nor by a Radio render');
|
||||
frequency.blur();
|
||||
receiver.emit({ frequencyHz: 98_700_000 });
|
||||
await frame();
|
||||
assert.equal(frequency.value, '98.7');
|
||||
|
||||
const gain = $('sdr-gain-select');
|
||||
gain.focus();
|
||||
gain.value = '20.7';
|
||||
receiver.emit({ gain: 'auto' });
|
||||
await frame();
|
||||
assert.equal(gain.value, '20.7', 'the pick stands while focused');
|
||||
|
||||
feeds = {
|
||||
configured: true,
|
||||
polling: true,
|
||||
feeds: [{ band: '1090', status: 'live' }],
|
||||
};
|
||||
for (const listener of feedListeners) listener(feeds);
|
||||
await frame();
|
||||
assert.equal($('sdr-feed-status').hidden, false);
|
||||
assert.equal($('sdr-feed-status').textContent, 'Decoder feeds: 1090 live');
|
||||
});
|
||||
@@ -164,9 +164,10 @@ export function renderRadioState(state) {
|
||||
this._cockpitRadioPlayBtn.title = playbackAction;
|
||||
}
|
||||
// A slider the person is dragging keeps the value they are setting.
|
||||
if (document.activeElement !== this._radioVolume)
|
||||
this._radioVolumeShown = Math.round(state.volume * 100);
|
||||
else this._radioVolumeShown ??= Number(this._radioVolume?.value ?? 80);
|
||||
this._radioVolumeShown =
|
||||
this._radioVolume && document.activeElement === this._radioVolume
|
||||
? Number(this._radioVolume.value)
|
||||
: Math.round(state.volume * 100);
|
||||
if (
|
||||
this._cockpitRadioVolume &&
|
||||
document.activeElement !== this._cockpitRadioVolume
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* `v-field-value`: a form field's live value, written only when it differs
|
||||
* and the field is not focused, so a render never overwrites what a person
|
||||
* is typing, picking or dragging. Focus is read before the render patches
|
||||
* the field too, since disabling it drops focus. A null value leaves the
|
||||
* field as it is. Unlike a `:value` binding, the value attribute is never
|
||||
* rewritten.
|
||||
*/
|
||||
const focused = (el) => el.ownerDocument?.activeElement === el;
|
||||
const apply = (el, { value }) => {
|
||||
const held = focused(el) || el.__fieldValueFocused;
|
||||
el.__fieldValueFocused = false;
|
||||
if (value == null || held) return;
|
||||
const next = String(value);
|
||||
if (el.value !== next) el.value = next;
|
||||
};
|
||||
export const vFieldValue = {
|
||||
mounted: apply,
|
||||
beforeUpdate(el) {
|
||||
el.__fieldValueFocused = focused(el);
|
||||
},
|
||||
updated: apply,
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
// src/ui/rail/fieldValue.test.mjs — v-field-value never writes over a field
|
||||
// that has focus, and leaves a field alone for a null value.
|
||||
import '../../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, h, nextTick, shallowRef, withDirectives } from 'vue';
|
||||
import { vFieldValue } from './fieldValue.js';
|
||||
|
||||
test('a render writes the value unless the field has focus or the value is null', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const value = shallowRef('98.5');
|
||||
const app = createApp({
|
||||
render: () =>
|
||||
withDirectives(h('input', { type: 'number', id: 'field' }), [
|
||||
[vFieldValue, value.value],
|
||||
]),
|
||||
});
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const field = document.getElementById('field');
|
||||
assert.equal(field.value, '98.5');
|
||||
field.focus();
|
||||
field.value = '101.1';
|
||||
value.value = '98.7';
|
||||
await nextTick();
|
||||
assert.equal(field.value, '101.1', 'typing stands');
|
||||
field.blur();
|
||||
value.value = null;
|
||||
await nextTick();
|
||||
assert.equal(field.value, '101.1', 'null leaves it');
|
||||
value.value = '98.9';
|
||||
await nextTick();
|
||||
assert.equal(field.value, '98.9');
|
||||
});
|
||||
|
||||
test('a render that disables a focused field leaves its value', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const state = shallowRef({ value: 'auto', disabled: false });
|
||||
const app = createApp({
|
||||
render: () =>
|
||||
withDirectives(
|
||||
h('select', { id: 'gain', disabled: state.value.disabled }, [
|
||||
h('option', { value: 'auto' }, 'AUTO'),
|
||||
h('option', { value: '20.7' }, '20.7 dB'),
|
||||
]),
|
||||
[[vFieldValue, state.value.value]],
|
||||
),
|
||||
});
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const gain = document.getElementById('gain');
|
||||
// Browsers drop focus from a field that becomes disabled; happy-dom does
|
||||
// not, so focus moves to the page here.
|
||||
const page = document.body.appendChild(document.createElement('button'));
|
||||
const drop = () => page.focus();
|
||||
const disabled = Object.getOwnPropertyDescriptor(
|
||||
Object.getPrototypeOf(gain),
|
||||
'disabled',
|
||||
);
|
||||
Object.defineProperty(gain, 'disabled', {
|
||||
get: () => disabled.get.call(gain),
|
||||
set: (on) => {
|
||||
disabled.set.call(gain, on);
|
||||
if (on) drop();
|
||||
},
|
||||
});
|
||||
const setAttribute = gain.setAttribute.bind(gain);
|
||||
gain.setAttribute = (name, value) => {
|
||||
setAttribute(name, value);
|
||||
if (name === 'disabled') drop();
|
||||
};
|
||||
gain.focus();
|
||||
gain.value = '20.7';
|
||||
state.value = { value: 'auto', disabled: true };
|
||||
await nextTick();
|
||||
assert.equal(gain.disabled, true);
|
||||
assert.equal(document.activeElement === gain, false, 'focus left the field');
|
||||
assert.equal(gain.value, '20.7');
|
||||
});
|
||||
+11
-2
@@ -5,7 +5,8 @@ import { markRaw, shallowRef } from 'vue';
|
||||
* The Radio panel as its view renders it. RadioControls presents the
|
||||
* directory row, station card, transport, volume and playback line
|
||||
* (`renderRadioState()`) and publishes them here; it keeps its listeners,
|
||||
* the tuner and the Cockpit radio controls.
|
||||
* the tuner and the Cockpit radio controls. LocalSdrControls publishes the
|
||||
* local RTL-SDR card beside it.
|
||||
*/
|
||||
export const useRadioStore = defineStore('gev-radio', () => {
|
||||
/** The presented panel; null until the controls first render. */
|
||||
@@ -16,5 +17,13 @@ export const useRadioStore = defineStore('gev-radio', () => {
|
||||
view.value = markRaw(next);
|
||||
}
|
||||
|
||||
return { view, publish };
|
||||
/** The local RTL-SDR card; null until its controls first render. */
|
||||
const sdr = shallowRef(null);
|
||||
|
||||
/** @param {object} next A fresh presentation of the RTL-SDR card. */
|
||||
function publishSdr(next) {
|
||||
sdr.value = markRaw(next);
|
||||
}
|
||||
|
||||
return { view, publish, sdr, publishSdr };
|
||||
});
|
||||
@@ -1,19 +1,25 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { vClassToggle } from '../../rail/classToggle.js';
|
||||
import { vFieldValue } from '../../rail/fieldValue.js';
|
||||
import { vRangeValue } from '../../rail/rangeValue.js';
|
||||
import { useRadioStore } from '../../stores/radio.js';
|
||||
import SdrCard from './SdrCard.vue';
|
||||
|
||||
// The Radio panel inside Context. RadioControls presents the directory row,
|
||||
// station card, transport, volume and playback line and publishes them;
|
||||
// until it does, the panel shows its initial markup. The tuner and the
|
||||
// local SDR card keep their owners' element bindings.
|
||||
const { view: radio } = storeToRefs(useRadioStore());
|
||||
// until it does, the panel shows its initial markup. The tuner keeps its
|
||||
// owner's element bindings; SdrCard renders the local RTL-SDR card.
|
||||
const { view: radio, sdr } = storeToRefs(useRadioStore());
|
||||
// Only whether the receiver is connected reaches the panel; the card renders
|
||||
// its own frames.
|
||||
const sdrConnected = computed(() => sdr.value?.connectionActive ?? null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="radio-panel" class="panel-collapsible collapsed" v-class-toggle="radio ? { 'radio-enabled': radio.enabled, 'lifecycle-uncertain': radio.uncertain } : null" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label>
|
||||
<section id="radio-panel" class="panel-collapsible collapsed" v-class-toggle="{ ...(radio && { 'radio-enabled': radio.enabled, 'lifecycle-uncertain': radio.uncertain }), ...(sdrConnected !== null && { 'sdr-connected': sdrConnected }) }" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label>
|
||||
<div class="radio-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>RADIO</span>
|
||||
@@ -57,7 +63,7 @@ const { view: radio } = storeToRefs(useRadioStore());
|
||||
</div>
|
||||
<label class="radio-volume-row" for="radio-volume">
|
||||
<span data-i18n>VOLUME</span>
|
||||
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" :disabled="radio?.volume.disabled" v-range-value="radio ? radio.volume.value : 80" data-i18n-aria-label />
|
||||
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" :disabled="radio?.volume.disabled" v-field-value="radio ? radio.volume.value : 80" data-i18n-aria-label />
|
||||
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">{{ radio ? `${radio.volume.value}%` : '80%' }}</output>
|
||||
</label>
|
||||
<div id="radio-playback-state" class="radio-playback-state" :class="{ error: radio?.playback.error }" role="status" aria-live="polite">{{ radio ? radio.playback.text : t('Radio off') }}</div>
|
||||
@@ -66,79 +72,7 @@ const { view: radio } = storeToRefs(useRadioStore());
|
||||
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer" data-i18n>DIRECTORY: RADIO BROWSER</a>
|
||||
</div>
|
||||
<p class="radio-privacy" data-i18n>Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver" data-i18n-aria-label>
|
||||
<div class="sdr-radio-heading">
|
||||
<strong data-i18n>LOCAL RTL-SDR</strong>
|
||||
<span id="sdr-connection-state" data-i18n-runtime>NO USB</span>
|
||||
</div>
|
||||
<p data-i18n>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
|
||||
<div class="sdr-radio-actions">
|
||||
<button id="sdr-connect-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>CONNECT</button>
|
||||
<button id="sdr-locate-btn" type="button" title="Use your location for faster ADS-B position decoding" data-i18n-title data-i18n>LOCATE</button>
|
||||
<button id="sdr-change-device-btn" type="button" title="Choose a different RTL-SDR or receiver channel" data-i18n-title data-i18n>CHANGE DEVICE</button>
|
||||
</div>
|
||||
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode" data-i18n-aria-label>
|
||||
<button id="sdr-mode-fm-btn" class="active" type="button" aria-pressed="true" translate="no">FM</button>
|
||||
<button id="sdr-mode-adsb-btn" type="button" aria-pressed="false" translate="no">ADS-B · 1090</button>
|
||||
</div>
|
||||
<div class="sdr-gain-row">
|
||||
<label for="sdr-gain-select" data-i18n>GAIN</label>
|
||||
<select class="gev-field" id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label>
|
||||
<option value="auto" data-i18n>AUTO</option>
|
||||
<option value="0.0" translate="no">0.0 dB</option>
|
||||
<option value="0.9" translate="no">0.9 dB</option>
|
||||
<option value="1.4" translate="no">1.4 dB</option>
|
||||
<option value="2.7" translate="no">2.7 dB</option>
|
||||
<option value="3.7" translate="no">3.7 dB</option>
|
||||
<option value="7.7" translate="no">7.7 dB</option>
|
||||
<option value="8.7" translate="no">8.7 dB</option>
|
||||
<option value="12.5" translate="no">12.5 dB</option>
|
||||
<option value="14.4" translate="no">14.4 dB</option>
|
||||
<option value="15.7" translate="no">15.7 dB</option>
|
||||
<option value="16.6" translate="no">16.6 dB</option>
|
||||
<option value="19.7" translate="no">19.7 dB</option>
|
||||
<option value="20.7" translate="no">20.7 dB</option>
|
||||
<option value="22.9" translate="no">22.9 dB</option>
|
||||
<option value="25.4" translate="no">25.4 dB</option>
|
||||
<option value="28.0" translate="no">28.0 dB</option>
|
||||
<option value="29.7" translate="no">29.7 dB</option>
|
||||
<option value="32.8" translate="no">32.8 dB</option>
|
||||
<option value="33.8" translate="no">33.8 dB</option>
|
||||
<option value="36.4" translate="no">36.4 dB</option>
|
||||
<option value="37.2" translate="no">37.2 dB</option>
|
||||
<option value="38.6" translate="no">38.6 dB</option>
|
||||
<option value="40.2" translate="no">40.2 dB</option>
|
||||
<option value="42.1" translate="no">42.1 dB</option>
|
||||
<option value="43.4" translate="no">43.4 dB</option>
|
||||
<option value="43.9" translate="no">43.9 dB</option>
|
||||
<option value="44.5" translate="no">44.5 dB</option>
|
||||
<option value="48.0" translate="no">48.0 dB</option>
|
||||
<option value="49.6" translate="no">49.6 dB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" hidden>
|
||||
<span><output id="sdr-stat-rate">--</output> <span data-i18n>MSG/S</span></span>
|
||||
<span><output id="sdr-stat-heard">0</output> <span data-i18n>HEARD</span></span>
|
||||
<span><output id="sdr-stat-positioned">0</output> <span data-i18n>POSITIONED</span></span>
|
||||
<span translate="no">IQ <output id="sdr-stat-iq">--</output></span>
|
||||
</div>
|
||||
<div class="sdr-frequency-row">
|
||||
<label for="sdr-frequency-input" data-i18n>FM MHZ</label>
|
||||
<input class="gev-field" id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
|
||||
<button id="sdr-tune-btn" type="button" data-i18n>TUNE</button>
|
||||
</div>
|
||||
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label>
|
||||
<button id="sdr-seek-back-btn" type="button" disabled data-i18n>◀ SEEK</button>
|
||||
<button id="sdr-seek-forward-btn" type="button" disabled data-i18n>SEEK ▶</button>
|
||||
</div>
|
||||
<label class="radio-volume-row" for="sdr-volume">
|
||||
<span data-i18n>SDR VOL</span>
|
||||
<input id="sdr-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" data-i18n-aria-label />
|
||||
<output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">80%</output>
|
||||
</label>
|
||||
<div id="sdr-status" class="radio-playback-state" role="status" aria-live="polite" data-i18n>Connect an RTL-SDR to begin.</div>
|
||||
<div id="sdr-feed-status" class="sdr-feed-status" role="status" aria-live="off" hidden></div>
|
||||
</div>
|
||||
<SdrCard />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { vClassToggle } from '../../rail/classToggle.js';
|
||||
import { vFieldValue } from '../../rail/fieldValue.js';
|
||||
import { useRadioStore } from '../../stores/radio.js';
|
||||
|
||||
// The local RTL-SDR card in the Radio panel. LocalSdrControls presents it and
|
||||
// publishes it; until it does, the card shows its initial markup. The
|
||||
// numeric gain options are labelled once by the controls.
|
||||
const { sdr } = storeToRefs(useRadioStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver" data-i18n-aria-label>
|
||||
<div class="sdr-radio-heading">
|
||||
<strong data-i18n>LOCAL RTL-SDR</strong>
|
||||
<span id="sdr-connection-state" v-class-toggle="{ active: sdr?.connectionActive }" data-i18n-runtime>{{ sdr ? sdr.connectionLabel : 'NO USB' }}</span>
|
||||
</div>
|
||||
<p data-i18n>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
|
||||
<div class="sdr-radio-actions">
|
||||
<button id="sdr-connect-btn" class="panel-layer-toggle" :class="{ active: sdr?.connectPressed }" type="button" :aria-pressed="String(sdr?.connectPressed ?? false)" :disabled="sdr?.connectDisabled">{{ sdr ? sdr.connectLabel : t('CONNECT') }}</button>
|
||||
<button id="sdr-locate-btn" type="button" v-class-toggle="{ active: sdr?.locateActive }" title="Use your location for faster ADS-B position decoding" :disabled="sdr?.locateDisabled" data-i18n-title>{{ sdr ? sdr.locateLabel : t('LOCATE') }}</button>
|
||||
<button id="sdr-change-device-btn" type="button" title="Choose a different RTL-SDR or receiver channel" :disabled="sdr?.changeDeviceDisabled" data-i18n-title data-i18n>CHANGE DEVICE</button>
|
||||
</div>
|
||||
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode" data-i18n-aria-label>
|
||||
<button id="sdr-mode-fm-btn" class="active" v-class-toggle="sdr ? { active: sdr.fmActive } : null" type="button" :aria-pressed="String(sdr ? sdr.fmActive : true)" :disabled="sdr?.modeDisabled" translate="no">FM</button>
|
||||
<button id="sdr-mode-adsb-btn" type="button" v-class-toggle="{ active: sdr?.adsbActive }" :aria-pressed="String(sdr?.adsbActive ?? false)" :disabled="sdr?.modeDisabled" translate="no">ADS-B · 1090</button>
|
||||
</div>
|
||||
<div class="sdr-gain-row">
|
||||
<label for="sdr-gain-select" data-i18n>GAIN</label>
|
||||
<select class="gev-field" id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" :disabled="sdr?.gainDisabled" v-field-value="sdr ? sdr.gain : 'auto'" data-i18n-aria-label>
|
||||
<option value="auto" data-i18n>AUTO</option>
|
||||
<option value="0.0" translate="no">0.0 dB</option>
|
||||
<option value="0.9" translate="no">0.9 dB</option>
|
||||
<option value="1.4" translate="no">1.4 dB</option>
|
||||
<option value="2.7" translate="no">2.7 dB</option>
|
||||
<option value="3.7" translate="no">3.7 dB</option>
|
||||
<option value="7.7" translate="no">7.7 dB</option>
|
||||
<option value="8.7" translate="no">8.7 dB</option>
|
||||
<option value="12.5" translate="no">12.5 dB</option>
|
||||
<option value="14.4" translate="no">14.4 dB</option>
|
||||
<option value="15.7" translate="no">15.7 dB</option>
|
||||
<option value="16.6" translate="no">16.6 dB</option>
|
||||
<option value="19.7" translate="no">19.7 dB</option>
|
||||
<option value="20.7" translate="no">20.7 dB</option>
|
||||
<option value="22.9" translate="no">22.9 dB</option>
|
||||
<option value="25.4" translate="no">25.4 dB</option>
|
||||
<option value="28.0" translate="no">28.0 dB</option>
|
||||
<option value="29.7" translate="no">29.7 dB</option>
|
||||
<option value="32.8" translate="no">32.8 dB</option>
|
||||
<option value="33.8" translate="no">33.8 dB</option>
|
||||
<option value="36.4" translate="no">36.4 dB</option>
|
||||
<option value="37.2" translate="no">37.2 dB</option>
|
||||
<option value="38.6" translate="no">38.6 dB</option>
|
||||
<option value="40.2" translate="no">40.2 dB</option>
|
||||
<option value="42.1" translate="no">42.1 dB</option>
|
||||
<option value="43.4" translate="no">43.4 dB</option>
|
||||
<option value="43.9" translate="no">43.9 dB</option>
|
||||
<option value="44.5" translate="no">44.5 dB</option>
|
||||
<option value="48.0" translate="no">48.0 dB</option>
|
||||
<option value="49.6" translate="no">49.6 dB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" :hidden="sdr ? sdr.statsHidden : true">
|
||||
<span><output id="sdr-stat-rate">{{ sdr ? sdr.stats.rate : '--' }}</output> <span data-i18n>MSG/S</span></span>
|
||||
<span><output id="sdr-stat-heard">{{ sdr ? sdr.stats.heard : '0' }}</output> <span data-i18n>HEARD</span></span>
|
||||
<span><output id="sdr-stat-positioned">{{ sdr ? sdr.stats.positioned : '0' }}</output> <span data-i18n>POSITIONED</span></span>
|
||||
<span translate="no">IQ <output id="sdr-stat-iq">{{ sdr ? sdr.stats.iq : '--' }}</output></span>
|
||||
</div>
|
||||
<div class="sdr-frequency-row">
|
||||
<label for="sdr-frequency-input" data-i18n>FM MHZ</label>
|
||||
<input class="gev-field" id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" :disabled="sdr?.frequencyDisabled" v-field-value="sdr ? sdr.frequency : '98.5'" />
|
||||
<button id="sdr-tune-btn" type="button" :disabled="sdr?.tuneDisabled" data-i18n>TUNE</button>
|
||||
</div>
|
||||
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label>
|
||||
<button id="sdr-seek-back-btn" type="button" :disabled="sdr ? sdr.seekDisabled : true" data-i18n>◀ SEEK</button>
|
||||
<button id="sdr-seek-forward-btn" type="button" :disabled="sdr ? sdr.seekDisabled : true" data-i18n>SEEK ▶</button>
|
||||
</div>
|
||||
<label class="radio-volume-row" for="sdr-volume">
|
||||
<span data-i18n>SDR VOL</span>
|
||||
<input id="sdr-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" :disabled="sdr?.volumeDisabled" v-field-value="sdr ? sdr.volume : 80" data-i18n-aria-label />
|
||||
<output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">{{ sdr ? sdr.volumeText : '80%' }}</output>
|
||||
</label>
|
||||
<div id="sdr-status" class="radio-playback-state" :class="{ error: sdr?.statusError }" role="status" aria-live="polite">{{ sdr ? sdr.statusText : t('Connect an RTL-SDR to begin.') }}</div>
|
||||
<div id="sdr-feed-status" class="sdr-feed-status" role="status" aria-live="off" :hidden="sdr ? sdr.feedLine === null : true">{{ sdr?.feedLine ?? '' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user