From 1072df325e6d8879395c9bb16721b82f255edf36 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 17:15:05 -0700 Subject: [PATCH] feat(ui): render the Radio directory tuner from a store RadioTuner renders the band label, readouts, slider and needle from the Radio store. RadioControls keeps the drag and its slot and publishes what the tuner shows; the component lays out the tick tape against the dial's measured width, drawing no ticks before it has one, and lays out the snapped needle before it drops the drag class so the needle does not animate from where the drag let go. --- docs/VUE-MIGRATION.md | 10 +- scripts/package-boundaries.json | 2 + scripts/qa-radio.mjs | 13 +- src/i18n/zh-CN-display.test.mjs | 2 +- src/radioMarkup.test.mjs | 2 +- src/ui/applicationShell.js | 5 - src/ui/cockpitUtilityControls.test.mjs | 3 +- src/ui/radioBindings.js | 159 +++----- src/ui/radioControls.js | 9 +- src/ui/radioPresentation.js | 55 ++- .../radioPresentation.playbackError.test.mjs | 2 +- src/ui/radioPresentationTransition.test.mjs | 2 +- src/ui/radioTuner.test.mjs | 358 ++++++++++++++++++ src/ui/shellElements.js | 5 - src/ui/stores/radio.js | 27 +- src/ui/views/panels/RadioPanel.vue | 22 +- src/ui/views/panels/RadioTuner.vue | 78 ++++ 17 files changed, 595 insertions(+), 159 deletions(-) create mode 100644 src/ui/radioTuner.test.mjs create mode 100644 src/ui/views/panels/RadioTuner.vue diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index c65ddeb34..9db7fcbfc 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -107,8 +107,11 @@ They contain no application state. store that RadioControls publishes to, and `SdrCard` the local RTL-SDR card that LocalSdrControls publishes beside it; fields a person edits take their value through `v-field-value`, which never writes a focused field; - the tuner and the Cockpit radio controls keep their owners' element - bindings; `ContextView` renders the mode switch and the nearby sites + `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 + width and lays out the snapped needle before it drops the drag class; + `ContextView` renders the mode switch and the nearby sites search from the `gev-context` store, which ContextControls reaches only through its `publish` option, so `./ui/context` needs no Vue; `AwarenessPanel` renders the Contacts panel that the awareness layer @@ -147,8 +150,7 @@ They contain no application state. stay with the controller. `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. The - Radio tuner follows). + 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 and the attribute diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c14e65c7c..54e15584a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -26,6 +26,7 @@ "src/ui/views/ContextView.vue", "src/ui/views/panels/RadioPanel.vue", "src/ui/views/panels/SdrCard.vue", + "src/ui/views/panels/RadioTuner.vue", "src/ui/views/panels/PhoneTabBar.vue", "src/ui/views/panels/CockpitBriefing.vue", "src/ui/views/panels/CockpitContext.vue", @@ -37,6 +38,7 @@ "src/ui/cockpitContextState.js", "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", + "src/ui/radioTunerModel.js", "src/ui/views/panels/MissionDetail.vue", "src/ui/views/panels/MissionRoster.vue", "src/ui/views/panels/AwarenessPanel.vue", diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 91da8bafc..a03d956c0 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -2704,14 +2704,20 @@ async function main() { }; const playsBefore = window.__qaRadioPlayCalls.length; const frozenSignature = style._radioControls._radioTunerBandSignature; + // The tuner renders from the Radio store; let Vue patch before reading. + const rendered = () => Promise.resolve(); pointer('pointerdown', xFor(0)); + await rendered(); const left = snapshot(); const centerCoordinate = (count - 1) / 2; pointer('pointermove', xFor(centerCoordinate)); + await rendered(); const center = snapshot(); pointer('pointermove', xFor(centerCoordinate + 2)); + await rendered(); const shifted = snapshot(); pointer('pointermove', xFor(count - 1)); + await rendered(); const right = snapshot(); const shiftedByIndex = new Map(shifted.ticks.map((tick) => [tick.index, tick.x])); const sharedTick = center.ticks.find((tick) => shiftedByIndex.has(tick.index)); @@ -2768,7 +2774,7 @@ async function main() { tapeDelta: absoluteTuner.tapeDelta, }), ); - const tunerCancelRestore = await page.evaluate(() => { + const tunerCancelRestore = await page.evaluate(async () => { const gev = window.__godsEyeView; const style = gev.styleManager; const radio = gev.dataManager.layers.get('radio').module; @@ -2801,11 +2807,14 @@ async function main() { const targetIndex = before.slot < beforeStations.length - 2 ? before.slot + 2 : before.slot - 2; pointer('pointerdown', before.slot); pointer('pointermove', targetIndex); + // The tuner renders from the Radio store; let Vue patch before reading. + await Promise.resolve(); const shiftedSlot = Number(slider.value); const previewBeforeCancel = gev.viewer.entities.values .find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null; const flyToCallsBeforeCancel = flyToCalls.length; pointer('pointercancel', targetIndex); + await Promise.resolve(); const afterStations = style._radioControls._radioTunerStations; const afterState = radio.getUIState(); const after = { @@ -3033,6 +3042,8 @@ async function main() { pointer('pointermove', targetIndex); const previewPlays = window.__qaRadioPlayCalls.length; pointer('pointerup', targetIndex); + // The tuner renders from the Radio store; sample once Vue has patched. + await Promise.resolve(); const samples = [needle.getBoundingClientRect().left]; await new Promise((resolve) => requestAnimationFrame(resolve)); samples.push(needle.getBoundingClientRect().left); diff --git a/src/i18n/zh-CN-display.test.mjs b/src/i18n/zh-CN-display.test.mjs index 929f3a95c..2eb392ff6 100644 --- a/src/i18n/zh-CN-display.test.mjs +++ b/src/i18n/zh-CN-display.test.mjs @@ -92,7 +92,7 @@ test('radio option text and accessible name use the same localized parentheses', let shown = null; const host = { _radioPanel: element(), - store: { publish: (view) => (shown = view) }, + store: { publish: (view) => (shown = view), publishTuner() {} }, actions: { getLifecycle: () => null, scheduleLayout() {} }, }; renderRadioState.call(host, { diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index e51b2d44d..41babb96a 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -303,7 +303,7 @@ test('Radio is nested inside Context with its own disclosure and power controls' assert.match(html, /id="radio-tuner-slider"[^>]*type="range"/); assert.match(html, /id="radio-tuner-needle"[^>]*aria-hidden="true"/); assert.match(html, /SNAPS TO AVAILABLE STATIONS/); - assert.match(html, /class="radio-tuner-scale" aria-hidden="true"><\/div>/); + assert.match(html, /class="radio-tuner-scale" aria-hidden="true"[^>]*><\/div>/); assert.match(html, /DIRECTORY: RADIO BROWSER/); assert.match(html, /Audio connects directly to the broadcaster/); assert.doesNotMatch(html, /radio-(?:favicon|visualizer|spectrum)/i); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 51dee44ef..937106347 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1067,12 +1067,7 @@ export class StyleManager extends ShellFacade { _radioStationName: this._radioStationName, _radioStationTags: this._radioStationTags, _radioStopBtn: this._radioStopBtn, - _radioTuner: this._radioTuner, - _radioTunerBandLabel: this._radioTunerBandLabel, - _radioTunerNeedle: this._radioTunerNeedle, _radioTunerSlider: this._radioTunerSlider, - _radioTunerStation: this._radioTunerStation, - _radioTunerValue: this._radioTunerValue, _radioVolume: this._radioVolume, _radioVolumeValue: this._radioVolumeValue, }, diff --git a/src/ui/cockpitUtilityControls.test.mjs b/src/ui/cockpitUtilityControls.test.mjs index de5f05ac7..f807d71b5 100644 --- a/src/ui/cockpitUtilityControls.test.mjs +++ b/src/ui/cockpitUtilityControls.test.mjs @@ -52,8 +52,7 @@ function fixture(t) { const controls = new RadioControls({ elements: { _radioPanel: document.createElement('section'), - // A bare tuner: the panel's tuner has its own tests. - _radioTuner: document.createElement('div'), + // A bare tuner slider: the panel's tuner has its own tests. _radioTunerSlider: Object.assign(document.createElement('input'), { type: 'range', }), diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js index ced54a622..02c9bdd94 100644 --- a/src/ui/radioBindings.js +++ b/src/ui/radioBindings.js @@ -1,10 +1,13 @@ import { t, msg } from '../i18n/index.js'; -import { showRadioEnableBusy, showRadioVolume } from './radioPresentation.js'; +import { + showRadioEnableBusy, + showRadioTuner, + showRadioVolume, +} from './radioPresentation.js'; import { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, - buildRadioTunerTicks, } from './radioTunerModel.js'; /** Bind Radio input using a RadioControls receiver and supplied actions. */ @@ -43,39 +46,13 @@ export function bindRadioControls() { /** @param {'display'|'radio'} kind @returns {boolean} Whether it is open. */ this._cockpitDisclosureOpen = (kind) => Boolean(this._cockpitDisclosure?.[kind]); - const syncTunerTape = (coordinate) => { - const scale = this._radioTuner?.querySelector('.radio-tuner-scale'); - const dial = this._radioTuner?.querySelector('.radio-tuner-dial'); - if (!scale || !dial) return null; - const model = buildRadioTunerTicks( - coordinate, - this._radioTunerStations.length, - dial.getBoundingClientRect().width, - ); - while (scale.children.length < model.ticks.length) { - const tick = document.createElement('span'); - tick.className = 'radio-tuner-tick'; - scale.append(tick); - } - while (scale.children.length > model.ticks.length) - scale.lastElementChild?.remove(); - model.ticks.forEach((entry, index) => { - const tick = scale.children[index]; - tick.style.left = `${entry.xPx}px`; - tick.textContent = entry.label; - tick.dataset.stationIndex = String(entry.stationIndex); - tick.classList.toggle('is-current', entry.current); - }); - scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`); - return model; - }; const tunerPreview = ({ coordinate = this._radioTunerCoordinate, syncStatic = true, rotate = true, } = {}) => { const slot = radioTunerSlot( - this._radioTunerSlider?.value, + this._radioTunerView.slot, this._radioTunerStations.length, ); const station = slot.locked @@ -93,35 +70,26 @@ export function bindRadioControls() { ? 0.5 : resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1); this._radioTunerCoordinate = resolvedCoordinate; - this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio)); - this._radioTuner?.classList.toggle( - 'is-static', - syncStatic ? false : Boolean(this._radioState?.tuningStatic), - ); - syncTunerTape(resolvedCoordinate); - if (this._radioTunerValue) { - this._radioTunerValue.textContent = station + showRadioTuner.call(this, { + ratio, + static: syncStatic ? false : Boolean(this._radioState?.tuningStatic), + coordinate: resolvedCoordinate, + count: this._radioTunerStations.length, + value: station ? t('CH {index} / {total}', { index: String(slot.stationIndex + 1).padStart(2, '0'), total: String(this._radioTunerStations.length).padStart(2, '0'), }) - : t('NO STATIONS'); - } - if (this._radioTunerStation) - this._radioTunerStation.textContent = - station?.name || t('NO STATION AVAILABLE'); - if (this._radioTunerSlider) { - this._radioTunerSlider.setAttribute( - 'aria-valuetext', - station - ? t('{name}, station {index} of {total}', { - name: station.name, - index: slot.stationIndex + 1, - total: this._radioTunerStations.length, - }) - : t('No station available'), - ); - } + : t('NO STATIONS'), + station: station?.name || t('NO STATION AVAILABLE'), + valuetext: station + ? t('{name}, station {index} of {total}', { + name: station.name, + index: slot.stationIndex + 1, + total: this._radioTunerStations.length, + }) + : t('No station available'), + }); if (syncStatic) this.radio.previewTuningStation(station?.id || null, { rotate }); return station; @@ -132,20 +100,12 @@ export function bindRadioControls() { this._radioTunerBandSignature = this._radioTunerStations .map((station) => station.id) .join('|'); - if (this._radioTunerSlider) { - this._radioTunerSlider.min = '0'; - this._radioTunerSlider.max = String( - Math.max(0, this._radioTunerStations.length - 1), - ); - this._radioTunerSlider.step = '1'; - } + showRadioTuner.call(this, { + max: Math.max(0, this._radioTunerStations.length - 1), + }); }; const refreshTunerBand = ({ force = false } = {}) => { - if ( - this._radioTunerDragging || - this._radioTuner?.hidden || - this._radioTunerSlider?.disabled - ) + if (this._radioTunerDragging || !this._radioTunerView.available) return false; const selectedId = this._radioState?.selected?.id || null; const pool = this.radio.getTunerStations(750); @@ -168,14 +128,14 @@ export function bindRadioControls() { selectedPoolIndex >= 0 ? selectedPoolIndex : 0, this._radioTunerStations.length, ); - this._radioTunerSlider.value = String(slot.slot); + showRadioTuner.call(this, { slot: slot.slot }); this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false }); return true; }; this._refreshRadioTunerBand = refreshTunerBand; const beginTuner = () => { - if (this._radioTunerDragging || this._radioTunerSlider?.disabled) + if (this._radioTunerDragging || !this._radioTunerView.available) return false; refreshTunerBand(); if (!this._radioTunerStations.length || !this.radio.beginTuning()) @@ -184,7 +144,7 @@ export function bindRadioControls() { // Only an explicit globe pointer/wheel gesture releases camera pinning. this._radioTunerBandPinnedForNavigation = true; this._radioTunerDragging = true; - this._radioTuner?.classList.add('is-dragging'); + showRadioTuner.call(this, { dragging: true }); const selectedIndex = this._radioTunerStations.findIndex( (station) => station.id === this._radioState?.selected?.id, ); @@ -192,7 +152,7 @@ export function bindRadioControls() { selectedIndex >= 0 ? selectedIndex : 0, this._radioTunerStations.length, ); - this._radioTunerSlider.value = String(slot.slot); + showRadioTuner.call(this, { slot: slot.slot }); this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; this._radioTunerDragStartSlot = slot.slot; this._radioTunerDragSnapshot = { @@ -215,31 +175,28 @@ export function bindRadioControls() { this._radioTunerPool = [...dragSnapshot.stations]; this._radioTunerBandSignature = dragSnapshot.bandSignature; this._radioTunerSelectedId = dragSnapshot.selectedId; - if (this._radioTunerSlider) { - const startSlot = radioTunerSlot( - dragSnapshot.slot, - this._radioTunerStations.length, - ); - this._radioTunerSlider.max = String(startSlot.max); - this._radioTunerSlider.value = String(startSlot.slot); - } + const startSlot = radioTunerSlot( + dragSnapshot.slot, + this._radioTunerStations.length, + ); + showRadioTuner.call(this, { max: startSlot.max, slot: startSlot.slot }); this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate) ? dragSnapshot.coordinate : dragSnapshot.slot; - } else if (!commit && this._radioTunerSlider) { - this._radioTunerSlider.value = String(this._radioTunerDragStartSlot); + } else if (!commit) { + showRadioTuner.call(this, { slot: this._radioTunerDragStartSlot }); this._radioTunerCoordinate = this._radioTunerDragStartSlot; } let station = tunerPreview({ coordinate: this._radioTunerCoordinate, rotate: commit, }); - if (commit && !station && this._radioTunerSlider) { + if (commit && !station) { const snapped = radioTunerCommitSlot( - this._radioTunerSlider.value, + this._radioTunerView.slot, this._radioTunerStations.length, ); - this._radioTunerSlider.value = String(snapped.slot); + showRadioTuner.call(this, { slot: snapped.slot }); this._radioTunerCoordinate = snapped.stationIndex; station = tunerPreview({ coordinate: this._radioTunerCoordinate }); } @@ -262,21 +219,18 @@ export function bindRadioControls() { this._radioTunerKeyboardKey = null; if (commit && (!result || result.ok)) refreshTunerBand(); this._radioTunerDragSnapshot = null; - // Flush the snapped position while transitions are still disabled so - // removing the drag class cannot interpolate from the released gap. - void this._radioTunerNeedle?.offsetLeft; - this._radioTuner?.classList.remove('is-dragging'); + // The tuner lays out the snapped position while transitions are still + // disabled before it drops the drag class, so the needle cannot + // interpolate from the released gap. + showRadioTuner.call(this, { dragging: false }); if (result && !result.ok) { this._radioTunerBandPinnedForNavigation = false; if (result.reason === 'station-unavailable') { - if (this._radioTunerValue) - this._radioTunerValue.textContent = t('OFF AIR'); - if (this._radioTunerStation) - this._radioTunerStation.textContent = t('STATION UNAVAILABLE'); - this._radioTunerSlider?.setAttribute( - 'aria-valuetext', - t('Station unavailable after directory refresh'), - ); + showRadioTuner.call(this, { + value: t('OFF AIR'), + station: t('STATION UNAVAILABLE'), + valuetext: t('Station unavailable after directory refresh'), + }); } } }; @@ -512,7 +466,7 @@ export function bindRadioControls() { this._radioTunerDragDirection = -1; this._radioTunerLastSlot = position.stationIndex; this._radioTunerCoordinate = position.coordinate; - this._radioTunerSlider.value = String(position.stationIndex); + showRadioTuner.call(this, { slot: position.stationIndex }); tunerPreview({ coordinate: position.coordinate }); return true; }; @@ -553,9 +507,14 @@ export function bindRadioControls() { () => { if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) return; - if (!this._radioTunerDragging && !beginTuner()) return; + // A drag the input starts begins from the selected station's slot. + if (this._radioTunerDragging) + showRadioTuner.call(this, { + slot: Number(this._radioTunerSlider.value), + }); + else if (!beginTuner()) return; const inputSlot = radioTunerSlot( - this._radioTunerSlider.value, + this._radioTunerView.slot, this._radioTunerStations.length, ); if (inputSlot.slot > this._radioTunerLastSlot) @@ -648,7 +607,7 @@ export function bindRadioControls() { this._radioTunerKeyboardKey = event.key; const max = Math.max(0, this._radioTunerStations.length - 1); const current = radioTunerSlot( - this._radioTunerSlider.value, + this._radioTunerView.slot, this._radioTunerStations.length, ).slot; const page = Math.max(1, Math.round(max / 10)); @@ -669,7 +628,7 @@ export function bindRadioControls() { this._radioTunerDragDirection = -1; this._radioTunerLastSlot = slot.slot; this._radioTunerCoordinate = slot.stationIndex; - this._radioTunerSlider.value = String(slot.slot); + showRadioTuner.call(this, { slot: slot.slot }); tunerPreview({ coordinate: this._radioTunerCoordinate }); }, tunerListenerOptions, diff --git a/src/ui/radioControls.js b/src/ui/radioControls.js index 05a491f91..527182ea0 100644 --- a/src/ui/radioControls.js +++ b/src/ui/radioControls.js @@ -1,5 +1,9 @@ import { bindRadioControls } from './radioBindings.js'; -import { renderRadioState } from './radioPresentation.js'; +import { + createRadioTunerState, + renderRadioState, + showRadioTuner, +} from './radioPresentation.js'; import { useRadioStore } from './stores/radio.js'; import { applicationPinia } from './vueRuntime.js'; @@ -30,6 +34,7 @@ export class RadioControls { this._radioFilterOptions = null; this._radioVolumeShown = null; this._cockpitDisclosure = { display: false, radio: false }; + this._radioTunerView = createRadioTunerState(); this._radioTunerStations = []; this._radioTunerPool = []; this._radioTunerDragging = false; @@ -145,7 +150,7 @@ export class RadioControls { } catch { /* capture already released */ } - this._radioTuner?.classList.remove('is-dragging', 'is-static'); + showRadioTuner.call(this, { dragging: false, static: false }); document .getElementById('title-bar') ?.classList.remove('radio-broadcasting'); diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index 8f48d84b0..ea9c2c253 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -1,8 +1,8 @@ /** * Present Radio state without making playback or Context decisions: the * panel view renders the directory row, station card, transport, volume and - * playback line, and the Cockpit's compact radio controls, from what this - * publishes (`gev-radio` store); the tuner is still written here. + * playback line, the Cockpit's compact radio controls and the directory + * tuner from what this publishes (`gev-radio` store). */ import { t } from '../i18n/index.js'; @@ -73,28 +73,27 @@ export function renderRadioState(state) { categories.find((category) => category.id === filter)?.color || ''; const tunerAvailable = interactive && state.filteredCount > 0; - if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable; - if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable; - if (this._radioTunerBandLabel) { - const activeCategory = state.categories.find( - (category) => category.id === state.filter, - ); - this._radioTunerBandLabel.textContent = + const activeCategory = categories.find( + (category) => category.id === state.filter, + ); + showRadioTuner.call(this, { + available: tunerAvailable, + band: state.filter === 'all' ? t('DIRECTORY BAND') : t('{category} BAND', { category: String( activeCategory?.label || state.filter, ).toUpperCase(), - }); - } - this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic)); + }), + static: Boolean(state.tuningStatic), + }); if (tunerAvailable) this._refreshRadioTunerBand?.(); if (!tunerAvailable && this._radioTunerDragging) { this._radioTunerDragging = false; this._radioTunerDragSnapshot = null; this._radioTunerStations = []; - this._radioTuner?.classList.remove('is-static', 'is-dragging'); + showRadioTuner.call(this, { static: false, dragging: false }); } if (!tunerAvailable) { this._radioTunerStations = []; @@ -278,6 +277,36 @@ export function showRadioVolume(value) { this.store?.publish(this._radioView); } +/** The tuner before RadioControls presents it; null texts keep the markup's. */ +export function createRadioTunerState() { + return { + available: false, + band: null, + value: null, + station: null, + valuetext: null, + // The slider's range and value. + max: 0, + slot: 0, + // The needle's place across the dial, 0–1; null before the first preview. + ratio: null, + // The tick tape: the fractional station under the needle, and how many. + coordinate: 0, + count: 0, + static: false, + dragging: false, + }; +} + +/** + * Change what the tuner shows and publish it. + * @param {Partial>} changes + */ +export function showRadioTuner(changes) { + this._radioTunerView = { ...this._radioTunerView, ...changes }; + this.store?.publishTuner({ ...this._radioTunerView }); +} + /** * Mark an Enable button busy while its own toggle runs, as a render would * until the next one reports the lifecycle. diff --git a/src/ui/radioPresentation.playbackError.test.mjs b/src/ui/radioPresentation.playbackError.test.mjs index 02a893495..986a5bf53 100644 --- a/src/ui/radioPresentation.playbackError.test.mjs +++ b/src/ui/radioPresentation.playbackError.test.mjs @@ -22,7 +22,7 @@ function render(audioError) { const host = { destroyed: false, _radioPanel: el(), - store: { publish: (view) => (shown = view) }, + store: { publish: (view) => (shown = view), publishTuner() {} }, actions: { getLifecycle: () => null, preservePanelStateDuringClear: () => false, diff --git a/src/ui/radioPresentationTransition.test.mjs b/src/ui/radioPresentationTransition.test.mjs index 57bd9ea72..9be0e2cbc 100644 --- a/src/ui/radioPresentationTransition.test.mjs +++ b/src/ui/radioPresentationTransition.test.mjs @@ -26,7 +26,7 @@ for (const lifecycleState of ['enabling', 'disabling']) { const ctx = { destroyed: false, _radioPanel: el(), - store: { publish: (view) => (shown = view) }, + store: { publish: (view) => (shown = view), publishTuner() {} }, actions: { getLifecycle: () => ({ lifecycleState, diff --git a/src/ui/radioTuner.test.mjs b/src/ui/radioTuner.test.mjs new file mode 100644 index 000000000..a288caeae --- /dev/null +++ b/src/ui/radioTuner.test.mjs @@ -0,0 +1,358 @@ +// src/ui/radioTuner.test.mjs — the Radio directory tuner renders what +// RadioControls presents, over happy-dom with the real controls driving it. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { createApp, nextTick } from 'vue'; +import { RadioControls } from './radioControls.js'; +import { buildRadioTunerTicks } from './radioTunerModel.js'; +import { renderRadioState } from './radioPresentation.js'; +import { useRadioStore } from './stores/radio.js'; +import RadioTuner from './views/panels/RadioTuner.vue'; + +const STATIONS = [ + { id: 's1', name: 'One' }, + { id: 's2', name: 'Two' }, + { id: 's3', name: 'Three' }, +]; + +function fixture(t, { commitResult = { ok: true }, width = 300 } = {}) { + document.body.replaceChildren(); + // The dial and slider are laid out `layout.width` wide; happy-dom lays + // nothing out. Resizes are reported through the stub observer. + const layout = { width, observers: [] }; + const rect = HTMLElement.prototype.getBoundingClientRect; + HTMLElement.prototype.getBoundingClientRect = function () { + const { width } = layout; + return this.classList.contains('radio-tuner-dial') || + this.id === 'radio-tuner-slider' + ? { left: 0, top: 0, width, height: 20, right: width, bottom: 20 } + : rect.call(this); + }; + const ResizeObserverBefore = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(callback) { + this.callback = callback; + layout.observers.push(this); + } + observe() {} + disconnect() { + layout.observers = layout.observers.filter((entry) => entry !== this); + } + }; + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp(RadioTuner).use(pinia); + app.mount(host); + const $ = (id) => document.getElementById(id); + const calls = []; + let controls = null; + let render = null; + const radio = { + subscribe: () => () => {}, + getTunerStations: () => STATIONS, + beginTuning: () => (calls.push(['begin']), true), + previewTuningStation: (id) => calls.push(['preview', id]), + commitTuningStation: (id) => { + calls.push(['commit', id]); + // Radio reports the new selection synchronously, as the real layer does. + if (commitResult.ok) + void render({ + selected: STATIONS.find((station) => station.id === id), + }); + return commitResult; + }, + cancelTuning: () => calls.push(['cancel']), + endTuning: () => calls.push(['end']), + }; + controls = new RadioControls({ + elements: { + _radioPanel: document.createElement('section'), + _radioTunerSlider: $('radio-tuner-slider'), + }, + radio, + actions: { + isRegistered: () => true, + isEnabled: () => true, + setPanelCollapsed() {}, + setSignalCollapsed() {}, + layoutCockpit() {}, + isCockpitActive: () => false, + signalUserCollapsed: () => false, + scheduleLayout() {}, + getLifecycle: () => null, + preservePanelStateDuringClear: () => true, + }, + canvas: document.createElement('canvas'), + store: useRadioStore(pinia), + }); + t.after(() => { + controls.destroy(); + app.unmount(); + HTMLElement.prototype.getBoundingClientRect = rect; + globalThis.ResizeObserver = ResizeObserverBefore; + }); + render = async (state) => { + renderRadioState.call(controls, { + enabled: true, + categories: [ + { id: 'all', label: 'All', count: 3, color: '' }, + { id: 'news', label: 'News', count: 3, color: '' }, + ], + filter: 'all', + filteredCount: 3, + stationCount: 3, + volume: 0.8, + audioState: 'stopped', + selected: STATIONS[1], + ...state, + }); + await nextTick(); + }; + const key = async (type, keyName) => { + $('radio-tuner-slider').dispatchEvent( + new KeyboardEvent(type, { key: keyName, bubbles: true }), + ); + await nextTick(); + }; + const resize = async (width) => { + layout.width = width; + for (const observer of layout.observers) observer.callback([]); + await nextTick(); + }; + const pointer = async (type, clientX) => { + $('radio-tuner-slider').dispatchEvent( + new PointerEvent(type, { bubbles: true, pointerId: 4, clientX }), + ); + await nextTick(); + }; + return { $, calls, controls, render, key, resize, pointer }; +} + +const ratio = (el) => el.style.getPropertyValue('--radio-tuner-ratio'); + +test('before RadioControls presents, the tuner is hidden with its initial readouts', (t) => { + const { $ } = fixture(t); + assert.equal($('radio-tuner').hidden, true); + const slider = $('radio-tuner-slider'); + assert.equal(slider.disabled, true); + assert.equal(slider.max, '0'); + assert.equal(slider.value, '0'); + assert.equal(slider.getAttribute('aria-valuetext'), 'No station available'); + assert.equal($('radio-tuner-band-label').textContent, 'DIRECTORY BAND'); + assert.equal($('radio-tuner-value').textContent, 'DRAG TO TUNE'); + assert.equal($('radio-tuner-station').textContent, 'ALL · DRAG THE NEEDLE'); + assert.equal(document.querySelectorAll('.radio-tuner-tick').length, 0); + assert.equal(ratio($('radio-tuner')), ''); +}); + +test('a render shows the band at the selected station with its tick tape', async (t) => { + const { $, render } = fixture(t); + await render(); + const tuner = $('radio-tuner'); + const slider = $('radio-tuner-slider'); + assert.equal(tuner.hidden, false); + assert.equal(slider.disabled, false); + assert.equal(slider.max, '2'); + assert.equal(slider.value, '1'); + assert.equal(slider.getAttribute('aria-valuetext'), 'Two, station 2 of 3'); + assert.equal($('radio-tuner-value').textContent, 'CH 02 / 03'); + assert.equal($('radio-tuner-station').textContent, 'Two'); + assert.equal(ratio(tuner), '0.5'); + const ticks = [...document.querySelectorAll('.radio-tuner-tick')]; + assert.ok(ticks.length > 0); + const current = ticks.filter((tick) => tick.classList.contains('is-current')); + assert.equal(current.length, 1); + assert.equal(current[0].dataset.stationIndex, '1'); + assert.match(current[0].style.left, /px$/); + assert.match( + tuner + .querySelector('.radio-tuner-scale') + .style.getPropertyValue('--radio-tuner-tick-pitch'), + /px$/, + ); + await render({ filter: 'news' }); + assert.equal($('radio-tuner-band-label').textContent, 'NEWS BAND'); + await render({ enabled: false }); + assert.equal(tuner.hidden, true); + assert.equal(slider.disabled, true); +}); + +test('a keyboard tune drags, previews and commits the next station', async (t) => { + const { $, calls, render, key } = fixture(t); + await render(); + const tuner = $('radio-tuner'); + const slider = $('radio-tuner-slider'); + slider.focus(); + await key('keydown', 'ArrowRight'); + assert.equal(tuner.classList.contains('is-dragging'), true); + assert.equal(slider.value, '2'); + assert.equal(ratio(tuner), '1'); + assert.equal($('radio-tuner-station').textContent, 'Three'); + assert.deepEqual(calls.at(-1), ['preview', 's3']); + // The drag class comes off only after the needle has been laid out. + const needle = $('radio-tuner-needle'); + let atLayout = null; + Object.defineProperty(needle, 'offsetLeft', { + configurable: true, + get: () => { + atLayout = { + dragging: tuner.classList.contains('is-dragging'), + ratio: ratio(tuner), + }; + return 0; + }, + }); + await key('keyup', 'ArrowRight'); + assert.ok(calls.some(([name, id]) => name === 'commit' && id === 's3')); + assert.equal(tuner.classList.contains('is-dragging'), false); + assert.deepEqual({ ...atLayout }, { dragging: true, ratio: '1' }); +}); + +test('Escape cancels a drag back to the slot it started from', async (t) => { + const { $, calls, render, key } = fixture(t); + await render(); + const slider = $('radio-tuner-slider'); + slider.focus(); + await key('keydown', 'Home'); + assert.equal(slider.value, '0'); + await key('keydown', 'Escape'); + assert.deepEqual(calls.at(-1), ['cancel']); + assert.equal(slider.value, '1'); + assert.equal(ratio($('radio-tuner')), '0.5'); + assert.equal($('radio-tuner').classList.contains('is-dragging'), false); +}); + +test('a station that went off air during the drag says so', async (t) => { + const { $, render, key } = fixture(t, { + commitResult: { ok: false, reason: 'station-unavailable' }, + }); + await render(); + $('radio-tuner-slider').focus(); + await key('keydown', 'ArrowLeft'); + await key('keyup', 'ArrowLeft'); + assert.equal($('radio-tuner-value').textContent, 'OFF AIR'); + assert.equal($('radio-tuner-station').textContent, 'STATION UNAVAILABLE'); + assert.equal( + $('radio-tuner-slider').getAttribute('aria-valuetext'), + 'Station unavailable after directory refresh', + ); +}); + +test('a native slider input starts from the selected slot, then follows the slider', async (t) => { + const { $, calls, render } = fixture(t); + await render(); + const slider = $('radio-tuner-slider'); + const input = async (value) => { + slider.value = value; + slider.dispatchEvent(new Event('input', { bubbles: true })); + await nextTick(); + }; + await input('0'); + assert.ok(calls.some(([name]) => name === 'begin')); + assert.deepEqual(calls.at(-1), ['preview', 's2']); + assert.equal(slider.value, '1'); + await input('2'); + assert.deepEqual(calls.at(-1), ['preview', 's3']); + assert.equal(slider.value, '2'); + slider.dispatchEvent(new Event('change', { bubbles: true })); + await nextTick(); + assert.ok(calls.some(([name, id]) => name === 'commit' && id === 's3')); +}); + +const ticksShown = () => + [...document.querySelectorAll('.radio-tuner-tick')].map((tick) => ({ + left: tick.style.left, + label: tick.textContent, + stationIndex: Number(tick.dataset.stationIndex), + current: tick.classList.contains('is-current'), + })); +const ticksModelled = (coordinate, count, width) => + buildRadioTunerTicks(coordinate, count, width).ticks.map((tick) => ({ + left: `${tick.xPx}px`, + label: tick.label, + stationIndex: tick.stationIndex, + current: tick.current, + })); + +test('the tick tape is the model at the dial width, and follows a resize', async (t) => { + const { render, resize } = fixture(t); + await render(); + assert.deepEqual(ticksShown(), ticksModelled(1, 3, 300)); + await resize(180); + assert.deepEqual(ticksShown(), ticksModelled(1, 3, 180)); +}); + +test('a pointer released between stations snaps the needle before it may animate', async (t) => { + const { $, calls, render, pointer } = fixture(t); + await render(); + const tuner = $('radio-tuner'); + await pointer('pointerdown', 190); + await pointer('pointermove', 230); + assert.equal(tuner.classList.contains('is-dragging'), true); + const dragRatio = Number(ratio(tuner)); + assert.ok(dragRatio > 0.5 && dragRatio < 1, `fractional, got ${dragRatio}`); + let atLayout = null; + Object.defineProperty($('radio-tuner-needle'), 'offsetLeft', { + configurable: true, + get: () => { + atLayout = { + dragging: tuner.classList.contains('is-dragging'), + ratio: ratio(tuner), + }; + return 0; + }, + }); + await pointer('pointerup', 230); + assert.deepEqual( + calls.filter(([name]) => name === 'commit'), + [['commit', 's3']], + ); + assert.deepEqual({ ...atLayout }, { dragging: true, ratio: '1' }); + assert.equal(tuner.classList.contains('is-dragging'), false); + assert.equal($('radio-tuner-slider').value, '2'); +}); + +test('a cancelled pointer drag returns the needle to where it started', async (t) => { + const { $, calls, render, pointer } = fixture(t); + await render(); + await pointer('pointerdown', 20); + assert.equal($('radio-tuner-slider').value, '0'); + await pointer('pointercancel', 20); + assert.deepEqual(calls.at(-1), ['cancel']); + assert.equal($('radio-tuner-slider').value, '1'); + assert.equal(ratio($('radio-tuner')), '0.5'); + assert.equal($('radio-tuner').classList.contains('is-dragging'), false); +}); + +test('leaving the slider commits a keyboard tune; destroying the controls ends a drag', async (t) => { + const { $, calls, controls, render, key } = fixture(t); + await render(); + const slider = $('radio-tuner-slider'); + slider.focus(); + await key('keydown', 'ArrowRight'); + slider.dispatchEvent(new Event('blur')); + await nextTick(); + assert.ok(calls.some(([name, id]) => name === 'commit' && id === 's3')); + assert.equal($('radio-tuner').classList.contains('is-dragging'), false); + slider.focus(); + await key('keydown', 'ArrowLeft'); + assert.equal($('radio-tuner').classList.contains('is-dragging'), true); + controls.destroy(); + await nextTick(); + assert.equal($('radio-tuner').classList.contains('is-dragging'), false); + assert.deepEqual(calls.at(-1), ['end']); +}); + +test('a dial first measured hidden draws its ticks in place once it has a width', async (t) => { + const { render, resize } = fixture(t, { width: 0 }); + await render(); + // Ticks laid out for no width would slide into place when it appears. + assert.deepEqual(ticksShown(), []); + await resize(300); + assert.deepEqual(ticksShown(), ticksModelled(1, 3, 300)); + // Hiding it again keeps the tape it had. + await resize(0); + assert.deepEqual(ticksShown(), ticksModelled(1, 3, 300)); +}); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index bcd02ed46..f60213841 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -57,12 +57,7 @@ export function readShellElements(document = globalThis.document) { _radioStationName: document.getElementById('radio-station-name'), _radioStationMeta: document.getElementById('radio-station-meta'), _radioStationTags: document.getElementById('radio-station-tags'), - _radioTuner: document.getElementById('radio-tuner'), _radioTunerSlider: document.getElementById('radio-tuner-slider'), - _radioTunerNeedle: document.getElementById('radio-tuner-needle'), - _radioTunerBandLabel: document.getElementById('radio-tuner-band-label'), - _radioTunerValue: document.getElementById('radio-tuner-value'), - _radioTunerStation: document.getElementById('radio-tuner-station'), _radioPrevBtn: document.getElementById('radio-prev-btn'), _radioPlayBtn: document.getElementById('radio-play-btn'), _radioNextBtn: document.getElementById('radio-next-btn'), diff --git a/src/ui/stores/radio.js b/src/ui/stores/radio.js index f737b79e6..40df4d981 100644 --- a/src/ui/stores/radio.js +++ b/src/ui/stores/radio.js @@ -4,10 +4,10 @@ import { markRaw, shallowRef } from 'vue'; /** * The Radio panel as its view renders it. RadioControls presents the * directory row, station card, transport, volume and playback line and the - * Cockpit's compact radio controls (`renderRadioState()`), and which Cockpit - * utility popover is open, and publishes them here; it keeps its listeners - * and the tuner. LocalSdrControls publishes the local RTL-SDR card beside - * it. + * 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. */ export const useRadioStore = defineStore('gev-radio', () => { /** The presented panel; null until the controls first render. */ @@ -34,5 +34,22 @@ export const useRadioStore = defineStore('gev-radio', () => { utility.value = markRaw(next); } - return { view, publish, sdr, publishSdr, utility, publishUtility }; + /** The directory tuner; null until the controls first present it. */ + const tuner = shallowRef(null); + + /** @param {object} next A fresh presentation of the tuner. */ + function publishTuner(next) { + tuner.value = markRaw(next); + } + + return { + view, + publish, + sdr, + publishSdr, + utility, + publishUtility, + tuner, + publishTuner, + }; }); diff --git a/src/ui/views/panels/RadioPanel.vue b/src/ui/views/panels/RadioPanel.vue index 58c4f9bb4..76cfc0afb 100644 --- a/src/ui/views/panels/RadioPanel.vue +++ b/src/ui/views/panels/RadioPanel.vue @@ -6,12 +6,13 @@ import { vClassToggle } from '../../rail/classToggle.js'; import { vFieldValue } from '../../rail/fieldValue.js'; import { vRangeValue } from '../../rail/rangeValue.js'; import { useRadioStore } from '../../stores/radio.js'; +import RadioTuner from './RadioTuner.vue'; import SdrCard from './SdrCard.vue'; // The Radio panel inside Context. RadioControls presents the directory row, // station card, transport, volume and playback line and publishes them; -// until it does, the panel shows its initial markup. The tuner keeps its -// owner's element bindings; SdrCard renders the local RTL-SDR card. +// 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()); // Only whether the receiver is connected reaches the panel; the card renders // its own frames. @@ -39,22 +40,7 @@ const sdrConnected = computed(() => sdr.value?.connectionActive ?? null); {{ radio ? radio.station.meta : t('Enable Radio, then choose a globe marker or use next.') }} {{ radio?.station.tags ?? '' }} - +
diff --git a/src/ui/views/panels/RadioTuner.vue b/src/ui/views/panels/RadioTuner.vue new file mode 100644 index 000000000..0a200246e --- /dev/null +++ b/src/ui/views/panels/RadioTuner.vue @@ -0,0 +1,78 @@ + + +