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:
Sameh Khamis committed 2026-10-31 00:43:27 -07:00
1 parent ccb7edd0d1
commit b6c7c130f6
11 files changed
+402 -158

No files matched your search

+5 -3
View File
@@ -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.
+3 -1
View File
@@ -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
View File
@@ -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) {
+4 -4
View File
@@ -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');
});
+146
View File
@@ -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');
});
+4 -3
View File
@@ -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
+23
View File
@@ -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,
};
+81
View File
@@ -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
View File
@@ -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 };
});
+12 -78
View File
@@ -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>
+88
View File
@@ -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>