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 @@