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), component compiler; the other groups follow one owner at a time),
Context and Radio (in progress: `RadioPanel` renders the directory row, Context and Radio (in progress: `RadioPanel` renders the directory row,
station card, transport, volume and playback line from the `gev-radio` station card, transport, volume and playback line from the `gev-radio`
store that RadioControls publishes to; the tuner, the local SDR card and store that RadioControls publishes to, and `SdrCard` the local RTL-SDR
the Cockpit radio controls keep their owners' element bindings), the card that LocalSdrControls publishes beside it; fields a person edits take
command dock, 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 the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead. directly gain a slot instead.
+3 -1
View File
@@ -25,6 +25,7 @@
"src/ui/views/CommandDockView.vue", "src/ui/views/CommandDockView.vue",
"src/ui/views/ContextView.vue", "src/ui/views/ContextView.vue",
"src/ui/views/panels/RadioPanel.vue", "src/ui/views/panels/RadioPanel.vue",
"src/ui/views/panels/SdrCard.vue",
"src/ui/views/DisplayControlsView.vue", "src/ui/views/DisplayControlsView.vue",
"src/ui/views/LayerPanelsView.vue", "src/ui/views/LayerPanelsView.vue",
"src/ui/views/ProviderSettingsView.vue", "src/ui/views/ProviderSettingsView.vue",
@@ -57,7 +58,8 @@
"src/ui/views/panels/DisplayPanel.vue", "src/ui/views/panels/DisplayPanel.vue",
"src/ui/stores/display.js", "src/ui/stores/display.js",
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/rail/classToggle.js" "src/ui/rail/classToggle.js",
"src/ui/rail/fieldValue.js"
], ],
"external": ["vue", "pinia"] "external": ["vue", "pinia"]
}, },
+25 -67
View File
@@ -6,6 +6,8 @@ import {
localSdrFmAudioActive, localSdrFmAudioActive,
localSdrGainOptionLabel, localSdrGainOptionLabel,
} from './localSdrPresentation.js'; } from './localSdrPresentation.js';
import { useRadioStore } from './stores/radio.js';
import { applicationPinia } from './vueRuntime.js';
const ELEMENT_IDS = Object.freeze({ const ELEMENT_IDS = Object.freeze({
card: 'sdr-radio-card', card: 'sdr-radio-card',
@@ -31,15 +33,12 @@ const ELEMENT_IDS = Object.freeze({
feedStatus: 'sdr-feed-status', feedStatus: 'sdr-feed-status',
}); });
function setText(element, text) {
if (element && element.textContent !== text) element.textContent = text;
}
/** /**
* Local RTL-SDR card in the Radio panel. * Local RTL-SDR card in the Radio panel.
* *
* Owns the card's listeners and its receiver/radio subscriptions. The Radio * Owns the card's listeners and its receiver/radio subscriptions; the Radio
* panel is one listening surface: starting local FM stops internet radio, and * 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. * starting internet radio stops local FM.
*/ */
export class LocalSdrControls { export class LocalSdrControls {
@@ -53,6 +52,7 @@ export class LocalSdrControls {
* @param {object} [options.radio] Internet-radio port (subscribe, stopPlayback). * @param {object} [options.radio] Internet-radio port (subscribe, stopPlayback).
* @param {object} [options.actions] Application actions: * @param {object} [options.actions] Application actions:
* setLocalAdsbEnabled(enabled), isLocalAdsbEnabled(), scheduleLayout(). * setLocalAdsbEnabled(enabled), isLocalAdsbEnabled(), scheduleLayout().
* @param {object} [options.store] Store the card's view is published to.
*/ */
constructor({ constructor({
document: doc, document: doc,
@@ -60,7 +60,9 @@ export class LocalSdrControls {
feeds = null, feeds = null,
radio = null, radio = null,
actions = {}, actions = {},
store = useRadioStore(applicationPinia()),
}) { }) {
this.store = store;
this.receiver = receiver; this.receiver = receiver;
this.feeds = feeds; this.feeds = feeds;
this.radio = radio; this.radio = radio;
@@ -78,6 +80,7 @@ export class LocalSdrControls {
this._radioAudioActive = false; this._radioAudioActive = false;
this._renderFrame = null; this._renderFrame = null;
this._destroyed = false; this._destroyed = false;
this._view = null;
if (!this.elements.card) return; if (!this.elements.card) return;
this._labelGainOptions(); this._labelGainOptions();
this._bind(); this._bind();
@@ -180,7 +183,7 @@ export class LocalSdrControls {
this._listen(el.volume, 'input', () => { this._listen(el.volume, 'input', () => {
const value = Number(el.volume.value); const value = Number(el.volume.value);
this.receiver.setVolume(value / 100); 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() { _render() {
const el = this.elements;
const view = localSdrCardView(this._state); 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); const feedLine = localSdrFeedLine(this.feeds?.getState?.() || null);
if (el.feedStatus) { // The card leaves a focused field, and the FM frequency outside FM, as
el.feedStatus.hidden = feedLine === null; // they are.
setText(el.feedStatus, feedLine || ''); this._publish({
} ...view,
el.card gain: view.gainValue,
?.closest?.('#radio-panel') frequency: view.frequencyValue,
?.classList.toggle('sdr-connected', view.connectionActive); volume: view.volumePercent,
volumeText: `${view.volumePercent}%`,
feedLine,
});
// Only structural changes move the rail; per-block text updates do not. // Only structural changes move the rail; per-block text updates do not.
const layoutKey = `${view.statsHidden}|${view.connectionActive}|${feedLine === null}`; const layoutKey = `${view.statsHidden}|${view.connectionActive}|${feedLine === null}`;
if (layoutKey !== this._layoutKey) { 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); for (const listener of listeners) listener(state);
}, },
}; };
let shown = null;
const controls = new LocalSdrControls({ const controls = new LocalSdrControls({
document: doc, document: doc,
receiver: stubReceiver(), receiver: stubReceiver(),
feeds, feeds,
store: { publishSdr: (view) => (shown = view) },
}); });
t.after(() => controls.destroy()); t.after(() => controls.destroy());
const line = doc.getElementById('sdr-feed-status');
assert.equal(probes, 1); assert.equal(probes, 1);
assert.equal(line.hidden, true); assert.equal(shown.feedLine, null);
feeds.set({ feeds.set({
configured: true, configured: true,
polling: 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)); await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(line.hidden, false); assert.equal(shown.feedLine, 'Decoder feeds: 1090 live · 978 live');
assert.equal(line.textContent, 'Decoder feeds: 1090 live · 978 live');
}); });
+146
View File
@@ -13,6 +13,7 @@ import {
showRadioVolume, showRadioVolume,
} from './radioPresentation.js'; } from './radioPresentation.js';
import { setLocale } from '../i18n/index.js'; import { setLocale } from '../i18n/index.js';
import { LocalSdrControls } from './localSdrControls.js';
function fixture(t) { function fixture(t) {
document.body.replaceChildren(); 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-busy'), 'true', 'the next render reports it');
assert.equal(enable.getAttribute('aria-disabled'), 'true'); 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; this._cockpitRadioPlayBtn.title = playbackAction;
} }
// A slider the person is dragging keeps the value they are setting. // A slider the person is dragging keeps the value they are setting.
if (document.activeElement !== this._radioVolume) this._radioVolumeShown =
this._radioVolumeShown = Math.round(state.volume * 100); this._radioVolume && document.activeElement === this._radioVolume
else this._radioVolumeShown ??= Number(this._radioVolume?.value ?? 80); ? Number(this._radioVolume.value)
: Math.round(state.volume * 100);
if ( if (
this._cockpitRadioVolume && this._cockpitRadioVolume &&
document.activeElement !== 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 * The Radio panel as its view renders it. RadioControls presents the
* directory row, station card, transport, volume and playback line * directory row, station card, transport, volume and playback line
* (`renderRadioState()`) and publishes them here; it keeps its listeners, * (`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', () => { export const useRadioStore = defineStore('gev-radio', () => {
/** The presented panel; null until the controls first render. */ /** The presented panel; null until the controls first render. */
@@ -16,5 +17,13 @@ export const useRadioStore = defineStore('gev-radio', () => {
view.value = markRaw(next); 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> <script setup>
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import { vClassToggle } from '../../rail/classToggle.js'; import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js';
import { vRangeValue } from '../../rail/rangeValue.js'; import { vRangeValue } from '../../rail/rangeValue.js';
import { useRadioStore } from '../../stores/radio.js'; import { useRadioStore } from '../../stores/radio.js';
import SdrCard from './SdrCard.vue';
// The Radio panel inside Context. RadioControls presents the directory row, // The Radio panel inside Context. RadioControls presents the directory row,
// station card, transport, volume and playback line and publishes them; // station card, transport, volume and playback line and publishes them;
// until it does, the panel shows its initial markup. The tuner and the // until it does, the panel shows its initial markup. The tuner keeps its
// local SDR card keep their owners' element bindings. // owner's element bindings; SdrCard renders the local RTL-SDR card.
const { view: radio } = storeToRefs(useRadioStore()); 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> </script>
<template> <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="radio-panel-inner">
<div class="panel-header"> <div class="panel-header">
<span class="panel-title" data-i18n>RADIO</span> <span class="panel-title" data-i18n>RADIO</span>
@@ -57,7 +63,7 @@ const { view: radio } = storeToRefs(useRadioStore());
</div> </div>
<label class="radio-volume-row" for="radio-volume"> <label class="radio-volume-row" for="radio-volume">
<span data-i18n>VOLUME</span> <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> <output id="radio-volume-value" class="gev-slider-value" for="radio-volume">{{ radio ? `${radio.volume.value}%` : '80%' }}</output>
</label> </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> <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> <a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer" data-i18n>DIRECTORY: RADIO BROWSER</a>
</div> </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> <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> <SdrCard />
<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>
</div> </div>
</section> </section>
</template> </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>