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