From 7f7e66e7939cb93cef3315841dad7822906da90d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 11:02:39 -0800 Subject: [PATCH] feat(radio): answer the internet radio controls through the radio store The Radio panel's and Cockpit's Enable, launcher, station tag, transport and volume controls call RadioControls through the radio store instead of DOM listeners. A refused station tag gets the kept tag back from act(), which now returns what the answering action returned. The Cockpit's station steps keep rotate: false. The unused element fields go from the shell. --- CHANGELOG.md | 7 +- docs/VUE-MIGRATION.md | 15 ++- scripts/format-scope.json | 1 + src/cockpitMarkup.test.mjs | 27 +++-- src/contextSessionOrdering.test.mjs | 4 +- src/contextTabKeyboard.test.mjs | 2 +- src/radioMarkup.test.mjs | 2 +- src/ui/applicationShell.js | 9 -- src/ui/cockpitUtilityControls.test.mjs | 52 ++++++++- src/ui/radioBindings.js | 105 +++++++----------- src/ui/radioControls.js | 1 + src/ui/radioControls.test.mjs | 20 ++-- src/ui/radioPanelActions.test.mjs | 80 +++++++++++++ src/ui/shellElements.js | 9 -- src/ui/stores/owners.js | 3 +- src/ui/stores/owners.test.mjs | 7 ++ .../views/panels/CockpitUtilityControls.vue | 21 ++-- src/ui/views/panels/RadioPanel.vue | 25 +++-- 18 files changed, 249 insertions(+), 141 deletions(-) create mode 100644 src/ui/radioPanelActions.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 327d9f26e..384a88168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,9 +58,10 @@ 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`. 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 + `onSetMode`. The Radio panel's and Cockpit's internet radio controls call + `RadioControls`, and the local RTL-SDR card's controls `LocalSdrControls`, + through the radio store's new `connect`/`act`; the card labels its gain + options itself. `act` returns what the answering action returned. `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 b131c584c..91f91b904 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -146,10 +146,12 @@ a feature supplying only its accent. 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, and `SdrCard` the local RTL-SDR - 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; + store that RadioControls publishes to and whose controls it answers + through the store's `connect`/`act` (the shared `createOwners()`; a + refused station tag gets the kept tag back from `act`), and `SdrCard` the + local RTL-SDR card that LocalSdrControls publishes beside it and answers + the same way; 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, and the component lays out the tick tape against the dial's measured @@ -266,8 +268,9 @@ a feature supplying only its accent. visibility when it lays them out. `CockpitUtilityControls` renders the Display and Radio launchers, which popover is open and the compact Radio - controls from the `gev-radio` store that RadioControls publishes to; the - Display popover's slots stay static for the Cockpit display portal). + controls from the `gev-radio` store that RadioControls publishes to and + answers through; the Display popover's slots stay static for the Cockpit + display portal). Layers that write into the right rail directly gain a slot instead. 4. **Cleanup.** The build-time template expansion is retired: `index.html` diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6f908552a..f427a64a3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -914,6 +914,7 @@ "src/layers/localAdsb/index.test.mjs", "src/ui/localSdrControls.test.mjs", "src/ui/sdrCardActions.test.mjs", + "src/ui/radioPanelActions.test.mjs", "src/ui/localSdrMarkup.test.mjs", "src/ui/localSdrPresentation.test.mjs", "src/data/localReceiversProxy.test.mjs", diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 5845ca9c2..7cc4a73ac 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -885,21 +885,24 @@ test('Cockpit Radio station changes preserve first-person camera ownership', () ); assert.ok(cycleHelper, 'shared Radio cycle helper is missing'); assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/); + assert.match(radioBindings, /\n cycleRadio,\n/); + const panel = fs.readFileSync( + new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), + 'utf8', + ); + const cockpit = fs.readFileSync( + new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), + 'utf8', + ); + assert.match(panel, /id="radio-prev-btn" @click="act\('cycleRadio', -1\)"/); + assert.match(panel, /id="radio-next-btn" @click="act\('cycleRadio', 1\)"/); assert.match( - radioBindings, - /listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/, + cockpit, + /id="cockpit-radio-prev-btn" @click="act\('cycleRadio', -1, \{ rotate: false \}\)"/, ); assert.match( - radioBindings, - /listen\(this\._radioNextBtn, 'click', \(\) => cycleRadio\(1\)\)/, - ); - assert.match( - radioBindings, - /listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/, - ); - assert.match( - radioBindings, - /listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/, + cockpit, + /id="cockpit-radio-next-btn" @click="act\('cycleRadio', 1, \{ rotate: false \}\)"/, ); }); diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index d7127e8d5..cb0854ff4 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -168,7 +168,7 @@ test('every user-facing Context exit route settles through the failure surface', test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => { const radioControls = radioBindings.slice( radioBindings.indexOf('const toggleRadio = async (trigger) => {'), - radioBindings.indexOf("this.listen(this._radioFilter, 'change'"), + radioBindings.indexOf('this._storeToken = this.store?.connect?.('), ); assert.match(radioControls, /await this\.actions\.runUserAction\(/); assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/); @@ -178,7 +178,7 @@ test('the Radio chip catches lifecycle rejection and semantic false through the test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => { const radioControls = radioBindings.slice( radioBindings.indexOf('const toggleRadio = async (trigger) => {'), - radioBindings.indexOf("this.listen(this._radioFilter, 'change'"), + radioBindings.indexOf('this._storeToken = this.store?.connect?.('), ); assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/); assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/); diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index e3b5add68..59ecf569d 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -65,7 +65,7 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs', test('Context async action buttons remain focused while busy', () => { const init = _initGlobalContextPanel.toString(); - const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._cockpitRadioToggleBtn,')); + const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this._storeToken = this.store?.connect?.(')); const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8'); const clear = clearSelectedLayers.toString(); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 97285dd23..c2acba8c7 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -357,7 +357,7 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb const enableMethod = radioBindings; assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i); assert.doesNotMatch(enableMethod, /contextRadio/); - assert.match(enableMethod, /toggleRadio\(this\._radioEnableBtn\)/); + assert.match(enableMethod, /toggleRadio: \(where\) =>[\s\S]*?: this\._radioEnableBtn,/); // The collapse buttons render aria-expanded from the state the chrome presents. assert.match(ui, /this\._presentPanel\(panelEl, \{ collapsed: nextCollapsed, auto: null \}\)/); assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 61cbdafbe..17c387d13 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1055,25 +1055,16 @@ export class StyleManager extends ShellFacade { elements: { _cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn, _cockpitRadioEnableBtn: this._cockpitRadioEnableBtn, - _cockpitRadioNextBtn: this._cockpitRadioNextBtn, - _cockpitRadioPlayBtn: this._cockpitRadioPlayBtn, - _cockpitRadioPrevBtn: this._cockpitRadioPrevBtn, _cockpitRadioToggleBtn: this._cockpitRadioToggleBtn, - _cockpitRadioVolume: this._cockpitRadioVolume, _cockpitUtilityControls: this._cockpitUtilityControls, _radioEnableBtn: this._radioEnableBtn, - _radioFilter: this._radioFilter, _radioLayerState: this._radioLayerState, - _radioNextBtn: this._radioNextBtn, _radioPanel: this._radioPanel, - _radioPlayBtn: this._radioPlayBtn, _radioPlaybackState: this._radioPlaybackState, - _radioPrevBtn: this._radioPrevBtn, _radioStationHomepage: this._radioStationHomepage, _radioStationMeta: this._radioStationMeta, _radioStationName: this._radioStationName, _radioStationTags: this._radioStationTags, - _radioStopBtn: this._radioStopBtn, _radioTunerSlider: this._radioTunerSlider, _radioVolume: this._radioVolume, _radioVolumeValue: this._radioVolumeValue, diff --git a/src/ui/cockpitUtilityControls.test.mjs b/src/ui/cockpitUtilityControls.test.mjs index f807d71b5..99f2fbb6d 100644 --- a/src/ui/cockpitUtilityControls.test.mjs +++ b/src/ui/cockpitUtilityControls.test.mjs @@ -25,6 +25,7 @@ function fixture(t) { let enabled = false; let toggles = 0; let finishToggle = null; + const ui = { presentationActive: true, filter: 'all' }; const actions = { isRegistered: () => true, isEnabled: () => enabled, @@ -38,7 +39,10 @@ function fixture(t) { }; }); }, - setParams: (params) => calls.push(['params', params]), + setParams: (params) => { + calls.push(['params', params]); + return params.filter !== 'refused'; + }, setPanelCollapsed() {}, setSignalCollapsed: (collapsed) => calls.push(['signal', collapsed]), revealStyleParameters: () => calls.push(['reveal']), @@ -60,15 +64,15 @@ function fixture(t) { _cockpitDisplayToggleBtn: $('cockpit-display-toggle-btn'), _cockpitRadioToggleBtn: $('cockpit-radio-toggle-btn'), _cockpitRadioEnableBtn: $('cockpit-radio-enable-btn'), - _cockpitRadioPrevBtn: $('cockpit-radio-prev-btn'), - _cockpitRadioPlayBtn: $('cockpit-radio-play-btn'), - _cockpitRadioNextBtn: $('cockpit-radio-next-btn'), - _cockpitRadioVolume: $('cockpit-radio-volume'), }, radio: { subscribe: () => () => {}, endTuning() {}, getTunerStations: () => [], + getUIState: () => ui, + cycleStation: (direction, { rotate }) => + calls.push(['cycle', direction, rotate]), + togglePlayback: () => calls.push(['playback']), }, actions, canvas: document.createElement('canvas'), @@ -95,6 +99,7 @@ function fixture(t) { $, controls, calls, + ui, render, outside, finishToggle: () => finishToggle(), @@ -236,6 +241,43 @@ test('dragging the volume shows the value at once and a render leaves the held s assert.equal(volume.value, '42'); }); +test('the compact transport steps stations without rotating the camera', (t) => { + const { $, calls } = fixture(t); + for (const id of [ + 'cockpit-radio-prev-btn', + 'cockpit-radio-play-btn', + 'cockpit-radio-next-btn', + ]) { + $(id).disabled = false; + $(id).click(); + } + assert.deepEqual(calls, [ + ['cycle', -1, false], + ['playback'], + ['cycle', 1, false], + ]); +}); + +test('the station filter answers with the tag the directory keeps', (t) => { + const { controls, calls, ui } = fixture(t); + assert.equal(controls.store.act('setRadioFilter', 'jazz'), 'jazz'); + assert.equal(controls.store.act('setRadioFilter', 'refused'), 'all'); + ui.presentationActive = false; + assert.equal(controls.store.act('setRadioFilter', 'rock'), 'all'); + assert.deepEqual(calls, [ + ['params', { filter: 'jazz' }], + ['params', { filter: 'refused' }], + ]); +}); + +test('a destroyed RadioControls answers no controls', (t) => { + const { controls, calls } = fixture(t); + controls.destroy(); + assert.equal(controls.store.act('setRadioFilter', 'jazz'), undefined); + controls.store.act('cycleRadio', 1); + assert.deepEqual(calls, []); +}); + test('the Cockpit Enable button is marked busy while its own toggle runs', async (t) => { const { $, render, finishToggle, toggles } = fixture(t); await render({ enabled: false }); diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js index 02c9bdd94..3fd03519a 100644 --- a/src/ui/radioBindings.js +++ b/src/ui/radioBindings.js @@ -312,19 +312,6 @@ export function bindRadioControls() { } } }; - this.listen( - this._radioEnableBtn, - 'click', - () => void toggleRadio(this._radioEnableBtn), - ); - this.listen( - this._cockpitRadioEnableBtn, - 'click', - () => void toggleRadio(this._cockpitRadioEnableBtn), - ); - this.listen(this._cockpitRadioToggleBtn, 'click', () => { - setCockpitDisclosure('radio', !this._cockpitDisclosureOpen('radio')); - }); this.listen( document, 'pointerdown', @@ -395,61 +382,45 @@ export function bindRadioControls() { }, tunerListenerOptions, ); - this.listen(this._radioFilter, 'change', () => { - const presentation = this.radio.getUIState(); - if (!presentation.presentationActive) { - this._radioFilter.value = presentation.filter; - return; - } - if (this._radioTunerDragging) finishTuner(false); - if ( - !this.actions.setParams( - { - filter: this._radioFilter.value, - }, - { origin: 'user' }, - ) - ) { - this._radioFilter.value = this.radio.getUIState().filter; - return; - } - this._radioTunerBandPinnedForNavigation = false; - this._radioTunerPool = []; - refreshTunerBand({ force: true }); - }); - this.listen(this._radioPrevBtn, 'click', () => cycleRadio(-1)); - this.listen(this._radioNextBtn, 'click', () => cycleRadio(1)); - this.listen( - this._radioPlayBtn, - 'click', - () => void this.radio.togglePlayback({ origin: 'user' }), - ); - this.listen(this._radioStopBtn, 'click', () => - this.radio.stopPlayback({ origin: 'user' }), - ); - this.listen(this._radioVolume, 'input', () => { - const value = Number(this._radioVolume.value); - showRadioVolume.call(this, value); - this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); - }); + // The Radio panel's and Cockpit's controls call these through the store. // Cockpit owns the Cesium camera even though it intentionally clears - // viewer.trackedEntity. Station changes must never start the map-view - // rotation/fallback flights that would compete with its preUpdate pose. - this.listen(this._cockpitRadioPrevBtn, 'click', () => - cycleRadio(-1, { rotate: false }), - ); - this.listen(this._cockpitRadioNextBtn, 'click', () => - cycleRadio(1, { rotate: false }), - ); - this.listen( - this._cockpitRadioPlayBtn, - 'click', - () => void this.radio.togglePlayback({ origin: 'user' }), - ); - this.listen(this._cockpitRadioVolume, 'input', () => { - const value = Number(this._cockpitRadioVolume.value); - showRadioVolume.call(this, value); - this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); + // viewer.trackedEntity, so its station steps pass `rotate: false`: they + // must never start the map-view rotation/fallback flights that would + // compete with its preUpdate pose. + this._storeToken = this.store?.connect?.({ + /** @param {'panel'|'cockpit'} where Which Enable button was pressed. */ + toggleRadio: (where) => + void toggleRadio( + where === 'cockpit' + ? this._cockpitRadioEnableBtn + : this._radioEnableBtn, + ), + toggleCockpitRadio: () => + setCockpitDisclosure('radio', !this._cockpitDisclosureOpen('radio')), + /** + * @param {string} filter + * @returns {string} The station tag the filter shows afterwards. + */ + setRadioFilter: (filter) => { + if (!this.radio.getUIState().presentationActive) + return this.radio.getUIState().filter; + if (this._radioTunerDragging) finishTuner(false); + if (!this.actions.setParams({ filter }, { origin: 'user' })) + return this.radio.getUIState().filter; + this._radioTunerBandPinnedForNavigation = false; + this._radioTunerPool = []; + refreshTunerBand({ force: true }); + return filter; + }, + cycleRadio, + toggleRadioPlayback: () => + void this.radio.togglePlayback({ origin: 'user' }), + stopRadio: () => this.radio.stopPlayback({ origin: 'user' }), + setRadioVolume: (percent) => { + const value = Number(percent); + showRadioVolume.call(this, value); + this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); + }, }); const updateTunerFromPointer = (event) => { const rect = this._radioTunerSlider?.getBoundingClientRect(); diff --git a/src/ui/radioControls.js b/src/ui/radioControls.js index 527182ea0..028e171ae 100644 --- a/src/ui/radioControls.js +++ b/src/ui/radioControls.js @@ -134,6 +134,7 @@ export class RadioControls { if (this.destroyed) return; this.destroyed = true; this.listeners.abort(); + if (this._storeToken) this.store.detach(this._storeToken); this._radioUnsubscribe?.(); this._radioUnsubscribe = null; const pointer = this._radioTunerPointerId; diff --git a/src/ui/radioControls.test.mjs b/src/ui/radioControls.test.mjs index 54c699498..2fa3a65bd 100644 --- a/src/ui/radioControls.test.mjs +++ b/src/ui/radioControls.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { RadioControls } from './radioControls.js'; +import { createOwners } from './stores/owners.js'; class Element extends EventTarget { constructor() { @@ -39,7 +40,12 @@ function fixture() { globalThis.window = new EventTarget(); const panel = new Element(); const enable = new Element(); - const next = new Element(); + const store = { + ...createOwners(), + publish() {}, + publishUtility() {}, + publishTuner() {}, + }; const calls = []; const subscribers = []; const radio = { @@ -72,16 +78,16 @@ function fixture() { elements: { _radioPanel: panel, _radioEnableBtn: enable, - _radioNextBtn: next, }, radio, actions, canvas: new Element(), + store, }); return { controls, enable, - next, + store, actions, calls, subscribers, @@ -93,17 +99,17 @@ function fixture() { }; } -test('Radio listeners and subscriptions are revoked once before tuning teardown', () => { +test('Radio listeners, actions and subscriptions are revoked once before tuning teardown', () => { const f = fixture(); try { f.controls.connect(); - f.next.dispatchEvent(new Event('click')); + f.store.act('cycleRadio', 1); assert.deepEqual(f.calls, ['subscribe', ['cycle', 1]]); f.controls.destroy(); f.controls.destroy(); const snapshot = structuredClone(f.calls); f.controls._setCockpitDisclosure('display', true); - f.next.dispatchEvent(new Event('click')); + f.store.act('cycleRadio', 1); f.document.dispatchEvent(new Event('gev:radio-selected')); f.subscribers[0]({ enabled: true }); f.controls.connect(); @@ -133,7 +139,7 @@ test('a delayed Radio enable cannot reveal or refocus controls after destruction }); try { f.actions.setEnabled = () => pending; - f.enable.dispatchEvent(new Event('click')); + f.store.act('toggleRadio', 'panel'); assert.equal(f.enable.getAttribute('aria-busy'), 'true'); f.controls.destroy(); const attrs = new Map(f.enable.attrs); diff --git a/src/ui/radioPanelActions.test.mjs b/src/ui/radioPanelActions.test.mjs new file mode 100644 index 000000000..73b30c246 --- /dev/null +++ b/src/ui/radioPanelActions.test.mjs @@ -0,0 +1,80 @@ +// src/ui/radioPanelActions.test.mjs — the Radio panel's controls call +// RadioControls through the radio store, and a refused station tag puts the +// select back on the tag the directory keeps. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import { useRadioStore } from './stores/radio.js'; +import RadioPanel from './views/panels/RadioPanel.vue'; + +function mount(t, { filter = (tag) => tag } = {}) { + document.body.replaceChildren(); + const pinia = createPinia(); + const calls = []; + useRadioStore(pinia).connect( + new Proxy( + {}, + { + get: + (_, name) => + (...args) => { + calls.push([name, ...args]); + return name === 'setRadioFilter' ? filter(...args) : undefined; + }, + }, + ), + ); + const app = createApp(RadioPanel).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + return calls; +} +const $ = (id) => document.getElementById(id); +const pickTag = (tag) => { + const select = $('radio-filter'); + select.disabled = false; + const option = document.createElement('option'); + option.value = tag; + select.append(option); + select.value = tag; + select.dispatchEvent(new Event('change', { bubbles: true })); + return select.value; +}; + +test('the panel’s Enable, transport and volume call RadioControls', (t) => { + const calls = mount(t); + for (const id of [ + 'radio-enable-btn', + 'radio-prev-btn', + 'radio-play-btn', + 'radio-next-btn', + 'radio-stop-btn', + ]) { + $(id).disabled = false; + $(id).click(); + } + $('radio-volume').disabled = false; + $('radio-volume').value = '35'; + $('radio-volume').dispatchEvent(new Event('input', { bubbles: true })); + assert.deepEqual(calls, [ + ['toggleRadio', 'panel'], + ['cycleRadio', -1], + ['toggleRadioPlayback'], + ['cycleRadio', 1], + ['stopRadio'], + ['setRadioVolume', '35'], + ]); +}); + +test('an accepted station tag stays picked', (t) => { + const calls = mount(t); + assert.equal(pickTag('jazz'), 'jazz'); + assert.deepEqual(calls, [['setRadioFilter', 'jazz']]); +}); + +test('a refused station tag puts the select back on the kept tag', (t) => { + mount(t, { filter: () => 'all' }); + assert.equal(pickTag('jazz'), 'all'); +}); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 9ff12e75f..e34fd0b69 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -23,21 +23,12 @@ export function readShellElements(document = globalThis.document) { _cockpitDisplayPanel: document.getElementById('cockpit-display-panel'), _cockpitRadioToggleBtn: document.getElementById('cockpit-radio-toggle-btn'), _cockpitRadioEnableBtn: document.getElementById('cockpit-radio-enable-btn'), - _cockpitRadioPrevBtn: document.getElementById('cockpit-radio-prev-btn'), - _cockpitRadioPlayBtn: document.getElementById('cockpit-radio-play-btn'), - _cockpitRadioNextBtn: document.getElementById('cockpit-radio-next-btn'), - _cockpitRadioVolume: document.getElementById('cockpit-radio-volume'), _radioLayerState: document.getElementById('radio-layer-state'), _radioEnableBtn: document.getElementById('radio-enable-btn'), - _radioFilter: document.getElementById('radio-filter'), _radioStationName: document.getElementById('radio-station-name'), _radioStationMeta: document.getElementById('radio-station-meta'), _radioStationTags: document.getElementById('radio-station-tags'), _radioTunerSlider: document.getElementById('radio-tuner-slider'), - _radioPrevBtn: document.getElementById('radio-prev-btn'), - _radioPlayBtn: document.getElementById('radio-play-btn'), - _radioNextBtn: document.getElementById('radio-next-btn'), - _radioStopBtn: document.getElementById('radio-stop-btn'), _radioVolume: document.getElementById('radio-volume'), _radioVolumeValue: document.getElementById('radio-volume-value'), _radioPlaybackState: document.getElementById('radio-playback-state'), diff --git a/src/ui/stores/owners.js b/src/ui/stores/owners.js index bd2b61f66..579d9bc60 100644 --- a/src/ui/stores/owners.js +++ b/src/ui/stores/owners.js @@ -31,11 +31,12 @@ export function createOwners() { * name, if any. * @param {string} name * @param {...unknown} args + * @returns {unknown} What the action returned, if one answered. */ 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); + if (action) return action(...args); } } diff --git a/src/ui/stores/owners.test.mjs b/src/ui/stores/owners.test.mjs index 0fd637dfe..83cef8b99 100644 --- a/src/ui/stores/owners.test.mjs +++ b/src/ui/stores/owners.test.mjs @@ -26,3 +26,10 @@ test('the newest owner with an action answers it, and detach hands it back', () ['first.play', 2], ]); }); + +test('act returns what the answering action returned', () => { + const { connect, act } = createOwners(); + connect({ pick: (tag) => `kept ${tag}` }); + assert.equal(act('pick', 'jazz'), 'kept jazz'); + assert.equal(act('missing'), undefined); +}); diff --git a/src/ui/views/panels/CockpitUtilityControls.vue b/src/ui/views/panels/CockpitUtilityControls.vue index 4c4d2f780..d74985c70 100644 --- a/src/ui/views/panels/CockpitUtilityControls.vue +++ b/src/ui/views/panels/CockpitUtilityControls.vue @@ -9,9 +9,12 @@ import { useRadioStore } from '../../stores/radio.js'; // The Cockpit's Display and Radio launchers and their popovers. RadioControls // presents which popover is open and the compact Radio controls; until it // does, both are closed and the controls show their initial state. The -// controls keep its listeners. The Display popover's slots are static: the -// Cockpit display portal moves the real Display controls into them. -const { view: radio, utility } = storeToRefs(useRadioStore()); +// controls call RadioControls through the store. The Display popover's slots +// are static: the Cockpit display portal moves the real Display controls into +// them. +const store = useRadioStore(); +const { act } = store; +const { view: radio, utility } = storeToRefs(store); const open = computed(() => utility.value ?? { display: false, radio: false }); const cockpit = computed(() => radio.value?.cockpit ?? null); const displayLabel = computed(() => @@ -45,19 +48,19 @@ const radioLabel = computed(() =>
RADIO - +
{{ cockpit ? cockpit.station : t('READY') }} - +
- - - + + +
diff --git a/src/ui/views/panels/RadioPanel.vue b/src/ui/views/panels/RadioPanel.vue index 3f9f32871..8d82365a5 100644 --- a/src/ui/views/panels/RadioPanel.vue +++ b/src/ui/views/panels/RadioPanel.vue @@ -13,8 +13,15 @@ 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. RadioTuner renders the -// directory tuner and SdrCard the local RTL-SDR card. -const { view: radio, sdr } = storeToRefs(useRadioStore()); +// directory tuner and SdrCard the local RTL-SDR card. Its controls call +// RadioControls through the store. +const store = useRadioStore(); +const { act } = store; +const { view: radio, sdr } = storeToRefs(store); +// A refused station tag puts the select back on the tag the filter keeps. +const setFilter = (select) => { + select.value = act('setRadioFilter', select.value) ?? select.value; +}; // Only whether the receiver is connected reaches the panel; the card renders // its own frames. const sdrConnected = computed(() => sdr.value?.connectionActive ?? null); @@ -31,9 +38,9 @@ const radioWords = {
- + -
@@ -44,14 +51,14 @@ const radioWords = {
- - - - + + + +
{{ radio ? radio.playback.text : t('Radio off') }}