From 61d11ce369e87ba50391fce9fe46d5dbd3a9ec7f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 10:52:50 -0800 Subject: [PATCH] feat(radio): answer the RTL-SDR card through the radio store SdrCard calls LocalSdrControls through radio store actions instead of DOM listeners, and renders its gain options from the receiver's own R820T gain steps with page-language labels. The display and radio stores share one owner list (createOwners): the newest connected owner with an action answers it. --- CHANGELOG.md | 4 +- docs/VUE-MIGRATION.md | 3 +- scripts/format-scope.json | 3 + scripts/package-boundaries.json | 11 +- src/ui/localSdrControls.js | 167 +++++++++++-------------------- src/ui/localSdrControls.test.mjs | 52 ++++++++-- src/ui/sdrCardActions.test.mjs | 97 ++++++++++++++++++ src/ui/stores/display.js | 42 +------- src/ui/stores/owners.js | 43 ++++++++ src/ui/stores/owners.test.mjs | 28 ++++++ src/ui/stores/radio.js | 9 +- src/ui/views/panels/SdrCard.vue | 67 +++++-------- 12 files changed, 320 insertions(+), 206 deletions(-) create mode 100644 src/ui/sdrCardActions.test.mjs create mode 100644 src/ui/stores/owners.js create mode 100644 src/ui/stores/owners.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b06ef58d..327d9f26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,9 @@ list renders. Street Level's fit and panorama filters and Recent Imagery's mode are `UiSegmented` too: the Street Level store's owner answers `setPano` and `setRenderMode`, and `RecentImageryReadout` takes - `onSetMode`. `syncScopeControls` (`./ui/display`) and + `onSetMode`. The local RTL-SDR card's controls call `LocalSdrControls` + through the radio store's new `connect`/`act`, and the card labels its gain + options itself. `syncScopeControls` (`./ui/display`) and `shouldHideCollapsedRightPanels` (`./ui/layout`) are deprecated: the Display view and the rail now own what they did. `./ui/input` imports Vue (samehkhamis). diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 91db14fa7..b131c584c 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -147,7 +147,8 @@ a feature supplying only its accent. 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, and `SdrCard` the local RTL-SDR - card that LocalSdrControls publishes beside it; fields a person edits take + card that LocalSdrControls publishes beside it and answers through the + store's `connect`/`act` (the shared `createOwners()`); fields a person edits take their value through `v-field-value`, which never writes a focused field; `RadioTuner` renders the directory tuner, where RadioControls keeps the drag and its slot and publishes the band, readouts, slider and needle, diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ed08a6430..6f908552a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -913,6 +913,7 @@ "src/sources/adsbRecords.test.mjs", "src/layers/localAdsb/index.test.mjs", "src/ui/localSdrControls.test.mjs", + "src/ui/sdrCardActions.test.mjs", "src/ui/localSdrMarkup.test.mjs", "src/ui/localSdrPresentation.test.mjs", "src/data/localReceiversProxy.test.mjs", @@ -1131,6 +1132,8 @@ "build/vueLoader.js", "build/registerVue.js", "src/ui/panelNavigation.test.mjs", + "src/ui/stores/owners.js", + "src/ui/stores/owners.test.mjs", "src/ui/stores/panels.test.mjs", "src/ui/stores/status.js", "src/ui/statusChipsView.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d62687ac8..87a960a4e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -88,6 +88,10 @@ "src/ui/views/panels/DisplayPanel.vue", "src/ui/views/panels/GeoImport.vue", "src/ui/stores/display.js", + "src/ui/localSdrPresentation.js", + "src/ui/unitLabels.js", + "src/layers/localAdsb/feedNames.js", + "src/ui/stores/owners.js", "src/ui/rail/rangeValue.js", "src/ui/rail/classToggle.js", "src/ui/rail/focusSafeChildren.js", @@ -128,7 +132,8 @@ "src/ui/kit/UiPanelToggle.vue", "src/ui/stores/panelChrome.js", "src/ui/rail/PanelCollapseButton.vue", - "src/ui/rail/panelState.js" + "src/ui/rail/panelState.js", + "src/sdr/gain.js" ], "external": ["vue", "pinia"] }, @@ -696,6 +701,7 @@ "src/ui/radioPresentation.js", "src/ui/vueRuntime.js", "src/ui/stores/radio.js", + "src/ui/stores/owners.js", "src/ui/radioTunerModel.js", "src/i18n/index.js", "src/i18n/locale.js", @@ -1046,6 +1052,7 @@ "src/ui/stores/voice.js", "src/ui/stores/location.js", "src/ui/stores/display.js", + "src/ui/stores/owners.js", "src/ui/stores/status.js", "src/ui/toast.js", "src/ui/stores/missionReplay.js", @@ -3042,6 +3049,7 @@ "src/ui/stores/voice.js", "src/ui/stores/location.js", "src/ui/stores/display.js", + "src/ui/stores/owners.js", "src/ui/stores/imagerySplit.js", "src/ui/stores/status.js", "src/ui/toast.js", @@ -3818,6 +3826,7 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/ui/stores/display.js", + "src/ui/stores/owners.js", "src/ui/vueRuntime.js", "src/i18n/marker.js", "src/i18n/index.js", diff --git a/src/ui/localSdrControls.js b/src/ui/localSdrControls.js index 1d805e271..25f3666c9 100644 --- a/src/ui/localSdrControls.js +++ b/src/ui/localSdrControls.js @@ -4,40 +4,16 @@ import { localSdrCardView, localSdrFeedLine, localSdrFmAudioActive, - localSdrGainOptionLabel, } from './localSdrPresentation.js'; import { useRadioStore } from './stores/radio.js'; import { applicationPinia } from './vueRuntime.js'; -const ELEMENT_IDS = Object.freeze({ - card: 'sdr-radio-card', - connectionState: 'sdr-connection-state', - connect: 'sdr-connect-btn', - locate: 'sdr-locate-btn', - changeDevice: 'sdr-change-device-btn', - modeFm: 'sdr-mode-fm-btn', - modeAdsb: 'sdr-mode-adsb-btn', - gain: 'sdr-gain-select', - stats: 'sdr-adsb-stats', - statRate: 'sdr-stat-rate', - statHeard: 'sdr-stat-heard', - statPositioned: 'sdr-stat-positioned', - statIq: 'sdr-stat-iq', - frequency: 'sdr-frequency-input', - tune: 'sdr-tune-btn', - seekBack: 'sdr-seek-back-btn', - seekForward: 'sdr-seek-forward-btn', - volume: 'sdr-volume', - volumeValue: 'sdr-volume-value', - status: 'sdr-status', - feedStatus: 'sdr-feed-status', -}); - /** * Local RTL-SDR card in the Radio panel. * - * Owns the card's listeners and its receiver/radio subscriptions; the Radio - * view renders the card from what this publishes (`gev-radio` store). The + * Answers the card's controls and owns its receiver/radio subscriptions; the + * Radio view renders the card from what this publishes (`gev-radio` store) + * and sends its controls here through the store's act(). The * Radio panel is one listening surface: starting local FM stops internet radio, and * starting internet radio stops local FM. */ @@ -67,13 +43,6 @@ export class LocalSdrControls { this.feeds = feeds; this.radio = radio; this.actions = actions; - this.elements = Object.fromEntries( - Object.entries(ELEMENT_IDS).map(([key, id]) => [ - key, - doc?.getElementById?.(id) || null, - ]), - ); - this._listeners = new AbortController(); this._state = receiver.getState(); this._radioState = null; this._fmAudioActive = localSdrFmAudioActive(this._state); @@ -81,9 +50,9 @@ export class LocalSdrControls { this._renderFrame = null; this._destroyed = false; this._view = null; - if (!this.elements.card) return; - this._labelGainOptions(); - this._bind(); + // A page without the card (the Radio view omitted) has no controls. + if (!doc?.getElementById?.('sdr-radio-card')) return; + this._storeToken = store.connect(this._actions()); this._receiverUnsubscribe = receiver.subscribe((state) => this._onReceiverState(state), ); @@ -101,20 +70,6 @@ export class LocalSdrControls { return Boolean(this._state?.connected); } - /** Numeric gain options read in the page language; values stay numeric. */ - _labelGainOptions() { - for (const option of this.elements.gain?.options || []) { - if (option.value !== 'auto') - option.textContent = localSdrGainOptionLabel(option.value); - } - } - - _listen(element, type, handler) { - element?.addEventListener(type, handler, { - signal: this._listeners.signal, - }); - } - _stopInternetRadio() { if (internetRadioAudioActive(this._radioState)) this.radio?.stopPlayback?.({ origin: 'user' }); @@ -126,65 +81,56 @@ export class LocalSdrControls { await this.actions.setLocalAdsbEnabled?.(true); } - _bind() { - const el = this.elements; - this._listen(el.connect, 'click', async () => { - const before = this.receiver.getState(); - if (before.connected) { - await this.receiver.stop(); - return; - } - if (before.mode === 'fm') this._stopInternetRadio(); - if (await this.receiver.connect(before.mode)) - await this._enableLayerWhenReceiving(); - }); - this._listen(el.locate, 'click', () => { - void this.receiver.requestReceiverLocation(); - }); - this._listen(el.changeDevice, 'click', async () => { - if (this.receiver.getState().mode === 'fm') this._stopInternetRadio(); - if (await this.receiver.changeDevice()) - await this._enableLayerWhenReceiving(); - }); - this._listen(el.modeFm, 'click', async () => { - this._stopInternetRadio(); - if (this.actions.isLocalAdsbEnabled?.()) - await this.actions.setLocalAdsbEnabled?.(false); - await this.receiver.setMode('fm'); - }); - this._listen(el.modeAdsb, 'click', async () => { - if (await this.receiver.setMode('adsb')) - await this._enableLayerWhenReceiving(); - }); - this._listen(el.gain, 'change', () => { - void this.receiver.setGain(el.gain.value); - }); - const tune = () => { - const mhz = Number(el.frequency?.value); - if (!Number.isFinite(mhz)) return; - this._stopInternetRadio(); - void this.receiver.tuneFm(mhz * 1_000_000); - }; - this._listen(el.tune, 'click', tune); - this._listen(el.frequency, 'keydown', (event) => { - if (event.key !== 'Enter') return; - event.preventDefault(); - tune(); - }); - for (const [button, direction] of [ - [el.seekBack, -1], - [el.seekForward, 1], - ]) { - this._listen(button, 'click', () => { + /** What the card's controls ask for, by the names the view acts with. */ + _actions() { + return { + toggleSdrConnection: async () => { + const before = this.receiver.getState(); + if (before.connected) { + await this.receiver.stop(); + return; + } + if (before.mode === 'fm') this._stopInternetRadio(); + if (await this.receiver.connect(before.mode)) + await this._enableLayerWhenReceiving(); + }, + locateSdr: () => { + void this.receiver.requestReceiverLocation(); + }, + changeSdrDevice: async () => { + if (this.receiver.getState().mode === 'fm') this._stopInternetRadio(); + if (await this.receiver.changeDevice()) + await this._enableLayerWhenReceiving(); + }, + setSdrMode: async (mode) => { + if (mode === 'fm') { + this._stopInternetRadio(); + if (this.actions.isLocalAdsbEnabled?.()) + await this.actions.setLocalAdsbEnabled?.(false); + await this.receiver.setMode('fm'); + } else if (await this.receiver.setMode('adsb')) + await this._enableLayerWhenReceiving(); + }, + setSdrGain: (value) => { + void this.receiver.setGain(value); + }, + tuneSdr: (value) => { + const mhz = Number(value); + if (!Number.isFinite(mhz)) return; + this._stopInternetRadio(); + void this.receiver.tuneFm(mhz * 1_000_000); + }, + seekSdr: (direction) => { this._stopInternetRadio(); void this.receiver.seekFm(direction); - }); - } - this._listen(el.volume, 'input', () => { - const value = Number(el.volume.value); - this.receiver.setVolume(value / 100); - if (this._view) this._publish({ ...this._view, volumeText: `${value}%` }); - }); + }, + setSdrVolume: (percent) => { + const value = Number(percent); + this.receiver.setVolume(value / 100); + if (this._view) + this._publish({ ...this._view, volumeText: `${value}%` }); + }, + }; } _onReceiverState(state) { @@ -246,10 +192,11 @@ export class LocalSdrControls { } } - /** Release listeners and subscriptions; the receiver session is not closed. */ + /** Release the controls and subscriptions; the receiver session is not closed. */ destroy() { this._destroyed = true; - this._listeners.abort(); + if (this._storeToken) this.store.detach(this._storeToken); + this._storeToken = null; this._receiverUnsubscribe?.(); this._radioUnsubscribe?.(); this._feedsUnsubscribe?.(); diff --git a/src/ui/localSdrControls.test.mjs b/src/ui/localSdrControls.test.mjs index 4d8ad17ee..decec3066 100644 --- a/src/ui/localSdrControls.test.mjs +++ b/src/ui/localSdrControls.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { LocalSdrControls } from './localSdrControls.js'; +import { createOwners } from './stores/owners.js'; function stubDocument() { const elements = new Map(); @@ -9,7 +10,6 @@ function stubDocument() { activeElement: null, getElementById(id) { if (!elements.has(id)) { - const listeners = new Map(); elements.set(id, { id, value: '', @@ -20,10 +20,6 @@ function stubDocument() { classList: { toggle() {} }, setAttribute() {}, closest: () => null, - addEventListener(type, handler) { - listeners.set(type, handler); - }, - fire: (type, event = {}) => listeners.get(type)?.(event), }); } return elements.get(id); @@ -79,6 +75,12 @@ function stubReceiver() { }; } +// The radio store as the card's owner sees it: published views and the +// card's controls, which act() calls. +function stubStore(onPublish = () => {}) { + return { ...createOwners(), publishSdr: onPublish }; +} + function stubRadio() { const listeners = new Set(); const calls = []; @@ -141,28 +143,56 @@ test('card controls drive gain, FM mode and the Local ADS-B layer', async (t) => const doc = stubDocument(); const receiver = stubReceiver(); const layerCalls = []; + const store = stubStore(); const controls = new LocalSdrControls({ document: doc, receiver, + store, actions: { isLocalAdsbEnabled: () => true, setLocalAdsbEnabled: async (enabled) => layerCalls.push(enabled), }, }); t.after(() => controls.destroy()); - const gain = doc.getElementById('sdr-gain-select'); - gain.value = '20.7'; - gain.fire('change'); + store.act('setSdrGain', '20.7'); assert.deepEqual(receiver.calls.at(-1), ['setGain', '20.7']); receiver.emit({ connected: true, status: 'streaming', mode: 'adsb' }); - await doc.getElementById('sdr-mode-fm-btn').fire('click'); + store.act('setSdrMode', 'fm'); + await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(layerCalls, [false]); assert.deepEqual(receiver.calls.at(-1), ['setMode', 'fm']); - await doc.getElementById('sdr-mode-adsb-btn').fire('click'); + store.act('setSdrMode', 'adsb'); + await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(layerCalls, [false, true]); assert.equal(controls.isActive(), true); + + store.act('tuneSdr', '101.1'); + assert.deepEqual(receiver.calls.at(-1), ['tuneFm', 101_100_000]); + store.act('tuneSdr', 'abc'); + assert.deepEqual( + receiver.calls.at(-1), + ['tuneFm', 101_100_000], + 'not a number', + ); + + controls.destroy(); + store.act('setSdrGain', '0.0'); + assert.deepEqual(receiver.calls.at(-1), ['tuneFm', 101_100_000], 'destroyed'); +}); + +test('a page without the card connects nothing', (t) => { + const store = stubStore(); + const receiver = stubReceiver(); + const controls = new LocalSdrControls({ + document: { getElementById: () => null }, + receiver, + store, + }); + t.after(() => controls.destroy()); + store.act('setSdrGain', '0.0'); + assert.equal(receiver.calls.length, 0); }); test('the card shows one read-only decoder-feed line after a single probe', async (t) => { @@ -189,7 +219,7 @@ test('the card shows one read-only decoder-feed line after a single probe', asyn document: doc, receiver: stubReceiver(), feeds, - store: { publishSdr: (view) => (shown = view) }, + store: stubStore((view) => (shown = view)), }); t.after(() => controls.destroy()); assert.equal(probes, 1); diff --git a/src/ui/sdrCardActions.test.mjs b/src/ui/sdrCardActions.test.mjs new file mode 100644 index 000000000..b0af93895 --- /dev/null +++ b/src/ui/sdrCardActions.test.mjs @@ -0,0 +1,97 @@ +// src/ui/sdrCardActions.test.mjs — the local RTL-SDR card's controls call +// LocalSdrControls through the radio store, and its gain options read in the +// page language with numeric values. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import { localSdrGainOptionLabel } from './localSdrPresentation.js'; +import { useRadioStore } from './stores/radio.js'; +import SdrCard from './views/panels/SdrCard.vue'; + +function mount(t) { + document.body.replaceChildren(); + const pinia = createPinia(); + const store = useRadioStore(pinia); + const calls = []; + store.connect( + new Proxy( + {}, + { + get: + (_, name) => + (...args) => + calls.push([name, ...args]), + }, + ), + ); + const app = createApp(SdrCard).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + return calls; +} +const $ = (id) => document.getElementById(id); +const fire = (el, type, init) => + el.dispatchEvent( + type === 'keydown' + ? new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }) + : new Event(type, { bubbles: true }), + ); + +test('the card’s buttons, gain, tuning and volume call LocalSdrControls', (t) => { + const calls = mount(t); + for (const id of [ + 'sdr-connect-btn', + 'sdr-locate-btn', + 'sdr-change-device-btn', + 'sdr-mode-fm-btn', + 'sdr-mode-adsb-btn', + ]) + $(id).click(); + $('sdr-gain-select').value = '20.7'; + fire($('sdr-gain-select'), 'change'); + $('sdr-frequency-input').value = '101.1'; + $('sdr-tune-btn').click(); + $('sdr-frequency-input').value = '95.3'; + const enter = fire($('sdr-frequency-input'), 'keydown', { key: 'Enter' }); + fire($('sdr-frequency-input'), 'keydown', { key: 'a' }); + $('sdr-volume').value = '40'; + fire($('sdr-volume'), 'input'); + assert.equal(enter, false, 'Enter is taken by the tune'); + assert.deepEqual(calls, [ + ['toggleSdrConnection'], + ['locateSdr'], + ['changeSdrDevice'], + ['setSdrMode', 'fm'], + ['setSdrMode', 'adsb'], + ['setSdrGain', '20.7'], + ['tuneSdr', '101.1'], + ['tuneSdr', '95.3'], + ['setSdrVolume', '40'], + ]); +}); + +test('the seek buttons step down and up', (t) => { + const calls = mount(t); + for (const id of ['sdr-seek-back-btn', 'sdr-seek-forward-btn']) { + $(id).disabled = false; + $(id).click(); + } + assert.deepEqual(calls, [ + ['seekSdr', -1], + ['seekSdr', 1], + ]); +}); + +test('gain options keep numeric values and read as decibels', (t) => { + mount(t); + const options = [...$('sdr-gain-select').options]; + assert.equal(options.length, 30); + assert.equal(options[0].value, 'auto'); + assert.equal(options[2].value, '0.9'); + assert.equal(options[2].textContent, localSdrGainOptionLabel('0.9')); + assert.match(options[2].textContent, /^0\.9\sdB$/u); + assert.equal(options[2].getAttribute('translate'), 'no'); + assert.equal(options.at(-1).value, '49.6'); +}); diff --git a/src/ui/stores/display.js b/src/ui/stores/display.js index f5e991892..7feb99ec4 100644 --- a/src/ui/stores/display.js +++ b/src/ui/stores/display.js @@ -1,5 +1,6 @@ import { defineStore } from 'pinia'; import { markRaw, shallowRef } from 'vue'; +import { createOwners } from './owners.js'; /** * The Display panel as its view renders it. VisualSettings presents its @@ -67,44 +68,9 @@ export const useDisplayStore = defineStore('gev-display', () => { mapSourceControls.value = controls && markRaw(controls); } - /** - * The owners answering the panel's controls, oldest first: DisplayBindings - * the Display controls and the dock's style buttons, the draw tool and the - * file import their own. - * @type {Array<{actions: object}>} - */ - const owners = []; - - /** - * Answer the actions named in `actions`; for those names this owner - * replaces any earlier one. - * @param {Record} actions - * @returns {object} Token for detach(). - */ - function connect(actions) { - const token = { actions }; - owners.push(token); - return token; - } - - /** Stop answering for the owner holding `token`; the others stay. */ - function detach(token) { - const index = owners.indexOf(token); - if (index >= 0) owners.splice(index, 1); - } - - /** - * A control's change: call the newest connected owner's action of that - * name, if any. - * @param {string} name - * @param {...unknown} args - */ - function act(name, ...args) { - for (let index = owners.length - 1; index >= 0; index -= 1) { - const action = owners[index].actions[name]; - if (action) return void action(...args); - } - } + // DisplayBindings answers the Display controls and the dock's style + // buttons, the draw tool and the file import their own. + const { connect, detach, act } = createOwners(); /** The phone shell's tab bar; null until the shell first presents it. */ const phoneShell = shallowRef(null); diff --git a/src/ui/stores/owners.js b/src/ui/stores/owners.js new file mode 100644 index 000000000..bd2b61f66 --- /dev/null +++ b/src/ui/stores/owners.js @@ -0,0 +1,43 @@ +/** + * The owners answering a store's controls. A view calls `act(name, ...args)`; + * the newest connected owner with an action of that name answers it, so an + * owner that connects again replaces itself, and owners of different actions + * sit side by side. + * @returns {{connect: Function, detach: Function, act: Function}} + */ +export function createOwners() { + /** @type {Array<{actions: object}>} Oldest first. */ + const owners = []; + + /** + * Answer the actions named in `actions`. + * @param {Record} actions + * @returns {object} Token for detach(). + */ + function connect(actions) { + const token = { actions }; + owners.push(token); + return token; + } + + /** Stop answering for the owner holding `token`; the others stay. */ + function detach(token) { + const index = owners.indexOf(token); + if (index >= 0) owners.splice(index, 1); + } + + /** + * A control's change: call the newest connected owner's action of that + * name, if any. + * @param {string} name + * @param {...unknown} args + */ + function act(name, ...args) { + for (let index = owners.length - 1; index >= 0; index -= 1) { + const action = owners[index].actions[name]; + if (action) return void action(...args); + } + } + + return { connect, detach, act }; +} diff --git a/src/ui/stores/owners.test.mjs b/src/ui/stores/owners.test.mjs new file mode 100644 index 000000000..0fd637dfe --- /dev/null +++ b/src/ui/stores/owners.test.mjs @@ -0,0 +1,28 @@ +// src/ui/stores/owners.test.mjs — the newest connected owner with an action +// answers it; detaching hands the action back to the one before. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createOwners } from './owners.js'; + +test('the newest owner with an action answers it, and detach hands it back', () => { + const { connect, detach, act } = createOwners(); + const calls = []; + const first = connect({ + play: (n) => calls.push(['first.play', n]), + stop: () => calls.push(['first.stop']), + }); + const second = connect({ play: (n) => calls.push(['second.play', n]) }); + act('play', 1); + act('stop'); + act('missing'); + detach(second); + act('play', 2); + detach(second); + detach(first); + act('play', 3); + assert.deepEqual(calls, [ + ['second.play', 1], + ['first.stop'], + ['first.play', 2], + ]); +}); diff --git a/src/ui/stores/radio.js b/src/ui/stores/radio.js index 40df4d981..ded9817c7 100644 --- a/src/ui/stores/radio.js +++ b/src/ui/stores/radio.js @@ -1,5 +1,6 @@ import { defineStore } from 'pinia'; import { markRaw, shallowRef } from 'vue'; +import { createOwners } from './owners.js'; /** * The Radio panel as its view renders it. RadioControls presents the @@ -7,7 +8,7 @@ import { markRaw, shallowRef } from 'vue'; * Cockpit's compact radio controls (`renderRadioState()`), which Cockpit * utility popover is open, and the directory tuner, and publishes them here; * it keeps its listeners. LocalSdrControls publishes the local RTL-SDR card - * beside it. + * beside it, and the card's controls reach it through act(). */ export const useRadioStore = defineStore('gev-radio', () => { /** The presented panel; null until the controls first render. */ @@ -42,6 +43,9 @@ export const useRadioStore = defineStore('gev-radio', () => { tuner.value = markRaw(next); } + // LocalSdrControls answers the RTL-SDR card's controls. + const { connect, detach, act } = createOwners(); + return { view, publish, @@ -51,5 +55,8 @@ export const useRadioStore = defineStore('gev-radio', () => { publishUtility, tuner, publishTuner, + connect, + detach, + act, }; }); diff --git a/src/ui/views/panels/SdrCard.vue b/src/ui/views/panels/SdrCard.vue index a55327177..b58bbf12b 100644 --- a/src/ui/views/panels/SdrCard.vue +++ b/src/ui/views/panels/SdrCard.vue @@ -1,14 +1,23 @@