From a55450e2e70b012890e38867be532ff72afaa86b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 16:37:36 +0400 Subject: [PATCH] Trim the Radio Next/Previous change to the HAL-722 scope - Selections take the playback fallback as a `playFallback` option and set it after releasing a stale stream, instead of callers arming state that the release then had to save and restore. - Move the tuner `station-unavailable` release and the Pause label while a stream connects out of this branch: both were wrong on main already and go in a separate change. - Share the Radio layer test setup in src/testSupport/radioLayerHarness.mjs and fold overlapping Next/Previous tests (13 to 7). - One-line comments and a two-line changelog entry. --- changelog.d/1474-radio-next-no-autoplay.md | 5 +- docs/CURRENT-STATE.md | 3 +- src/data/radio.test.mjs | 83 +------ src/data/radioNextState.test.mjs | 264 +++------------------ src/layers/radio/playback.js | 6 +- src/layers/radio/selection.js | 38 +-- src/layers/radio/tuning.js | 5 +- src/testSupport/radioLayerHarness.mjs | 101 ++++++++ src/ui/radioBindings.js | 3 +- src/ui/radioPresentation.js | 4 +- 10 files changed, 172 insertions(+), 340 deletions(-) create mode 100644 src/testSupport/radioLayerHarness.mjs diff --git a/changelog.d/1474-radio-next-no-autoplay.md b/changelog.d/1474-radio-next-no-autoplay.md index b4298bacf..a4ca6d96b 100644 --- a/changelog.d/1474-radio-next-no-autoplay.md +++ b/changelog.d/1474-radio-next-no-autoplay.md @@ -1,5 +1,2 @@ - Radio Next and Previous no longer start a broadcaster stream before Play is - pressed: they only select a station, and once a stream is playing it follows - the new station. Stepping away from a paused or failed stream releases it, the - Play button offers Pause while a stream connects, and the privacy note names - every action that connects to a broadcaster (gekh, #1474). + pressed; while a stream plays, it follows the new station (gekh, #1474). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index abf8094eb..2aafd7ebd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4774,8 +4774,7 @@ result reads enabled state from the manager and audio state from the Radio module. Voice Pause is a playback-only no-op while the layer is disabled; it never enables Radio and reports a fulfilled-false pause as failure. A newer-response or user-origin Stop still cancels stale handoff work, as do a -user Pause, a user Previous/Next that only selects another station, and a tuner -release onto a station the directory no longer has. +user Pause and a user Previous/Next that only selects another station. Only a newer user turn or session teardown aborts the complete active-tool set. A cancelled turn cannot publish a late Radio playback request. Dedicated Radio controls and generic layer-visibility commands both forward cancellation. diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 364abbb0f..260facff9 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -51,6 +51,7 @@ import { radioViewIsGlobal, stationMatchesRadioCategory, } from './radio.js'; +import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs'; const stations = [ { id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] }, @@ -883,83 +884,24 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele }); test('failed exact tuner release cannot consume a stale playback fallback', async () => { - const originalFetch = globalThis.fetch; - const originalAudio = globalThis.Audio; - const playUrls = []; const stationRows = [ - { + radioStationRow({ id: '00000000-0000-4000-8000-000000000071', name: 'Exact target', - lat: 30, - lon: -97, streamUrl: 'https://radio.example.com/exact.mp3', - homepage: null, - tags: ['news'], - languages: ['English'], - state: 'Texas', - country: 'United States', - countryCode: 'US', - metadataTrust: 'untrusted-community', - codec: 'MP3', - bitrate: 128, - }, - { + }), + radioStationRow({ id: '00000000-0000-4000-8000-000000000072', name: 'Stale fallback', lat: 31, lon: -98, streamUrl: 'https://radio.example.com/fallback.mp3', - homepage: null, - tags: ['news'], - languages: ['English'], - state: 'Texas', - country: 'United States', - countryCode: 'US', - metadataTrust: 'untrusted-community', - codec: 'MP3', - bitrate: 128, - }, + }), ]; - globalThis.Audio = class FakeAudio { - constructor() { - this.volume = 0.8; - this.src = ''; - } - - addEventListener() {} - pause() {} - play() { - playUrls.push(this.src); - if (this.src === stationRows[0].streamUrl) { - return Promise.reject(new DOMException('Exact target unavailable', 'NotSupportedError')); - } - return Promise.resolve(); - } - removeAttribute() { this.src = ''; } - load() {} - }; - globalThis.fetch = async (url) => String(url).startsWith('/api/radio/click/') - ? { ok: true } - : new Response(JSON.stringify({ - stations: stationRows, - stale: false, - degraded: false, - acceptedGeneration: 1, - catalogInstance: 'qa-instance-a', - updatedAt: new Date().toISOString(), - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - const viewer = { - camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, - scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, - dataSources: { add() {}, remove() {} }, - entities: { add(entity) { return entity; }, remove() {} }, - }; - - radioLayer.destroy(); - try { - radioLayer.init(viewer); - radioLayer.enable(); - await radioLayer.update(); + await withRadioLayer({ + stations: stationRows, + failingUrls: [stationRows[0].streamUrl], + }, async ({ playUrls }) => { assert.equal(radioLayer.selectStation(stationRows[0].id, { autoplay: false }), true); assert.equal(radioLayer.cycleStation(1, { stationIds: stationRows.map(({ id }) => id), @@ -976,12 +918,7 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn assert.equal(state.audioState, 'error'); assert.equal(state.tuningStatic, true); assert.equal(state.tuningAwaitingStationId, stationRows[0].id); - } finally { - globalThis.fetch = originalFetch; - if (originalAudio === undefined) delete globalThis.Audio; - else globalThis.Audio = originalAudio; - radioLayer.destroy(); - } + }); }); test('tuner cancellation restores the frozen start marker after catalog removal', async () => { diff --git a/src/data/radioNextState.test.mjs b/src/data/radioNextState.test.mjs index ff8563003..eeaf2e0d6 100644 --- a/src/data/radioNextState.test.mjs +++ b/src/data/radioNextState.test.mjs @@ -5,79 +5,18 @@ import test from 'node:test'; import { radioLayer } from './radio.js'; import { setLocale } from '../i18n/index.js'; import { renderRadioState } from '../ui/radioPresentation.js'; +import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs'; const READY = 'Ready — playback starts only from your action'; const ids = [1, 2, 3].map((n) => `00000000-0000-4000-8000-00000000009${n}`); -const stationRows = ids.map((id, index) => ({ +const stations = ids.map((id, index) => radioStationRow({ id, name: `Station ${'ABC'[index]}`, lat: 30 + index, lon: -97 - index, streamUrl: `https://radio.example.com/${'abc'[index]}.mp3`, - homepage: null, - tags: ['news'], - languages: ['English'], - state: 'Texas', - country: 'United States', - countryCode: 'US', - metadataTrust: 'untrusted-community', - codec: 'MP3', - bitrate: 128, })); - -async function withLayer(failingUrls, body) { - const originalFetch = globalThis.fetch; - const originalAudio = globalThis.Audio; - const playUrls = []; - const catalog = { rows: stationRows, generation: 1 }; - globalThis.Audio = class FakeAudio { - constructor() { - this.volume = 0.8; - this.src = ''; - } - - addEventListener() {} - pause() {} - play() { - playUrls.push(this.src); - if (failingUrls.includes(this.src)) { - return Promise.reject(new DOMException('unavailable', 'NotSupportedError')); - } - return Promise.resolve(); - } - removeAttribute() { this.src = ''; } - load() {} - }; - globalThis.fetch = async (url) => String(url).startsWith('/api/radio/click/') - ? { ok: true } - : new Response(JSON.stringify({ - stations: catalog.rows, - stale: false, - degraded: false, - acceptedGeneration: catalog.generation, - catalogInstance: 'qa-instance-a', - updatedAt: new Date().toISOString(), - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - const viewer = { - camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, - scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, - dataSources: { add() {}, remove() {} }, - entities: { add(entity) { return entity; }, remove() {} }, - }; - radioLayer.destroy(); - try { - radioLayer.init(viewer); - radioLayer.enable(); - await radioLayer.update(); - await body(playUrls, catalog); - } finally { - globalThis.fetch = originalFetch; - if (originalAudio === undefined) delete globalThis.Audio; - else globalThis.Audio = originalAudio; - radioLayer.destroy(); - } -} - +const withLayer = (failingUrls, body) => withRadioLayer({ stations, failingUrls }, body); const settle = () => new Promise((resolve) => setTimeout(resolve)); // What the Radio panel and the Cockpit player show for a state. @@ -125,24 +64,27 @@ function render(state) { } } -test('Next before Play selects without starting a stream, and Play starts the new station', async () => { - await withLayer([], async (playUrls) => { +const readyToPlay = { status: READY, error: false, button: 'PLAY', stopDisabled: true, cockpit: '▶' }; + +test('Next before Play only selects, and Play tries just that station', async () => { + await withLayer([stations[1].streamUrl], async ({ playUrls }) => { radioLayer.selectStation(ids[0], { autoplay: false }); assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true); await settle(); assert.deepEqual(playUrls, []); assert.equal(radioLayer.getUIState().audioState, 'stopped'); await radioLayer.togglePlayback({ origin: 'user' }); + await settle(); const state = radioLayer.getUIState(); - assert.deepEqual(playUrls, [stationRows[1].streamUrl]); - assert.equal(state.audioState, 'playing'); - assert.equal(state.playingStationId, ids[1]); + assert.deepEqual(playUrls, [stations[1].streamUrl], 'no fallback picked by the silent step'); + assert.equal(state.selected?.id, ids[1]); + assert.equal(state.audioState, 'error'); }); }); test('Next after a failed stream leaves the new station ready to play, without the old error', async () => { setLocale('en'); - await withLayer([stationRows[0].streamUrl], async (playUrls) => { + await withLayer([stations[0].streamUrl], async ({ playUrls }) => { radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' }); await settle(); assert.equal(radioLayer.getUIState().audioState, 'error'); @@ -151,22 +93,16 @@ test('Next after a failed stream leaves the new station ready to play, without t assert.equal(state.selected?.id, ids[1]); assert.equal(state.audioState, 'stopped'); assert.equal(state.playingStationId, null); - assert.deepEqual(render(state), { - status: READY, - error: false, - button: 'PLAY', - stopDisabled: true, - cockpit: '▶', - }); + assert.deepEqual(render(state), readyToPlay); await radioLayer.togglePlayback({ origin: 'user' }); - assert.deepEqual(playUrls, [stationRows[0].streamUrl, stationRows[1].streamUrl]); + assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]); assert.equal(radioLayer.getUIState().playingStationId, ids[1]); }); }); test('Next while paused releases the paused stream, and Play starts the new station', async () => { setLocale('en'); - await withLayer([], async (playUrls) => { + await withLayer([], async ({ playUrls }) => { radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' }); await settle(); await radioLayer.togglePlayback({ origin: 'user' }); @@ -176,15 +112,9 @@ test('Next while paused releases the paused stream, and Play starts the new stat assert.equal(state.selected?.id, ids[1]); assert.equal(state.audioState, 'stopped'); assert.equal(state.playingStationId, null); - assert.deepEqual(render(state), { - status: READY, - error: false, - button: 'PLAY', - stopDisabled: true, - cockpit: '▶', - }); + assert.deepEqual(render(state), readyToPlay); await radioLayer.togglePlayback({ origin: 'user' }); - assert.deepEqual(playUrls, [stationRows[0].streamUrl, stationRows[1].streamUrl]); + assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]); assert.equal(radioLayer.getUIState().audioState, 'playing'); assert.equal(radioLayer.getUIState().playingStationId, ids[1]); }); @@ -192,12 +122,12 @@ test('Next while paused releases the paused stream, and Play starts the new stat test('a catalog refresh after a paused Next cannot bring back Resume for an unnamed station', async () => { setLocale('en'); - await withLayer([], async (_playUrls, catalog) => { + await withLayer([], async ({ catalog }) => { radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' }); await settle(); await radioLayer.togglePlayback({ origin: 'user' }); radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }); - catalog.rows = stationRows.filter(({ id }) => id !== ids[1]); + catalog.rows = stations.filter(({ id }) => id !== ids[1]); catalog.generation = 2; await radioLayer.update(); const state = radioLayer.getUIState(); @@ -209,71 +139,9 @@ test('a catalog refresh after a paused Next cannot bring back Resume for an unna }); }); -test('Play after a non-playing Next cannot consume a fallback picked by that step', async () => { - await withLayer([stationRows[1].streamUrl], async (playUrls) => { - radioLayer.selectStation(ids[0], { autoplay: false }); - assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true); - assert.deepEqual(playUrls, []); - await radioLayer.togglePlayback({ origin: 'user' }); - await settle(); - const state = radioLayer.getUIState(); - assert.deepEqual(playUrls, [stationRows[1].streamUrl]); - assert.equal(state.selected?.id, ids[1]); - assert.equal(state.audioState, 'error'); - }); -}); - -test('a voice step keeps its broadcaster fallback for the playback voice starts', async () => { - await withLayer([stationRows[1].streamUrl], async (playUrls) => { - radioLayer.selectStation(ids[0], { autoplay: false }); - // gevActions prepares the station silently; the voice handoff then plays it. - radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' }); - await radioLayer.play({ origin: 'voice' }); - await settle(); - await settle(); - const state = radioLayer.getUIState(); - assert.deepEqual(playUrls, [stationRows[1].streamUrl, stationRows[2].streamUrl]); - assert.equal(state.selected?.id, ids[2]); - assert.equal(state.audioState, 'playing'); - }); -}); - -test('a cancelled voice step leaves no fallback for a later Play from the panel', async () => { - await withLayer([stationRows[1].streamUrl], async (playUrls) => { - radioLayer.selectStation(ids[0], { autoplay: false }); - radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' }); - // The handoff is cancelled (barge-in), so voice never starts playback. - await settle(); - await radioLayer.togglePlayback({ origin: 'user' }); - await settle(); - await settle(); - const state = radioLayer.getUIState(); - assert.deepEqual(playUrls, [stationRows[1].streamUrl]); - assert.equal(state.selected?.id, ids[1]); - assert.equal(state.audioState, 'error'); - }); -}); - -test('while a stream connects the transport buttons offer Pause, because a click pauses', () => { - setLocale('en'); - const shown = render({ - enabled: true, - audioState: 'loading', - audioError: null, - selected: { id: 'a', name: 'Station A' }, - selectedIndex: 0, - filteredCount: 1, - stationCount: 1, - volume: 1, - }); - assert.equal(shown.status, 'Connecting directly to broadcaster…'); - assert.equal(shown.button, 'PAUSE'); - assert.equal(shown.cockpit, 'Ⅱ'); -}); - test('a voice station request after a voice pause keeps the fallback of that request', async () => { const failing = []; - await withLayer(failing, async (playUrls) => { + await withLayer(failing, async ({ playUrls }) => { radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' }); await settle(); radioLayer.pause({ origin: 'voice-duck' }); @@ -293,98 +161,34 @@ test('a voice station request after a voice pause keeps the fallback of that req }); }); -test('an unavailable tuner release stops the paused stream, and Play still falls back', async () => { - setLocale('en'); - const failing = []; - await withLayer(failing, async (playUrls, catalog) => { - radioLayer.selectStation(ids[2], { autoplay: true, origin: 'user' }); +test('a cancelled voice step leaves no fallback for a later Play from the panel', async () => { + await withLayer([stations[1].streamUrl], async ({ playUrls }) => { + radioLayer.selectStation(ids[0], { autoplay: false }); + radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' }); + // The handoff is cancelled (barge-in), so voice never starts playback. + await settle(); + await radioLayer.togglePlayback({ origin: 'user' }); + await settle(); await settle(); - assert.equal(radioLayer.beginTuning(), true); - radioLayer.previewTuningStation(ids[1], { rotate: false }); - // A directory refresh changes the station under the needle mid-drag. - catalog.rows = stationRows.map((row) => row.id === ids[1] - ? { ...row, streamUrl: 'https://radio.example.com/b2.mp3' } - : row); - catalog.generation = 2; - await radioLayer.update(); - assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).reason, 'station-unavailable'); const state = radioLayer.getUIState(); - assert.equal(state.selected, null); - assert.equal(state.audioState, 'stopped'); - assert.equal(state.playingStationId, null); - assert.equal(render(state).button, 'PLAY'); - playUrls.length = 0; - // Play picks the nearest station; its broadcaster fails. - const play = globalThis.Audio.prototype.play; - globalThis.Audio.prototype.play = function playFirstFails() { - if (!failing.length) failing.push(this.src); - return play.call(this); - }; - try { - await radioLayer.togglePlayback({ origin: 'user' }); - await settle(); - await settle(); - } finally { - globalThis.Audio.prototype.play = play; - } - assert.equal(playUrls.length, 2); - assert.equal(radioLayer.getUIState().audioState, 'playing'); + assert.deepEqual(playUrls, [stations[1].streamUrl]); + assert.equal(state.selected?.id, ids[1]); + assert.equal(state.audioState, 'error'); }); }); -test('a user step that does not play tells voice to drop its prepared playback', async () => { +test('only a user step to another station tells voice to drop its prepared playback', async () => { await withLayer([], async () => { const controls = []; const unsubscribe = radioLayer.subscribePlaybackControls((event) => controls.push(event)); try { radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' }); radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }); + radioLayer.cycleStation(1, { stationIds: [ids[1]], autoplay: false, origin: 'user' }); } finally { unsubscribe(); } + assert.equal(radioLayer.getUIState().selected?.id, ids[1]); assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['select', 'user']]); }); }); - -test('an unavailable tuner release publishes one final state and stops a voice-prepared playback', async () => { - await withLayer([], async (_playUrls, catalog) => { - radioLayer.selectStation(ids[2], { autoplay: false }); - assert.equal(radioLayer.beginTuning(), true); - radioLayer.previewTuningStation(ids[1], { rotate: false }); - catalog.rows = stationRows.map((row) => row.id === ids[1] - ? { ...row, streamUrl: 'https://radio.example.com/b2.mp3' } - : row); - catalog.generation = 2; - await radioLayer.update(); - const states = []; - const controls = []; - const unsubscribe = radioLayer.subscribe((state) => states.push(state)); - const unsubscribeControls = radioLayer.subscribePlaybackControls((event) => controls.push(event)); - states.length = 0; - try { - assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).reason, 'station-unavailable'); - } finally { - unsubscribe(); - unsubscribeControls(); - } - assert.equal(states.length, 1); - assert.equal(states[0].tuningUnavailableStationId, ids[1]); - assert.equal(states[0].audioState, 'stopped'); - assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['stop', 'user']]); - }); -}); - -test('a user step that keeps the same station leaves voice playback alone', async () => { - await withLayer([], async () => { - radioLayer.selectStation(ids[0], { autoplay: false }); - const controls = []; - const unsubscribe = radioLayer.subscribePlaybackControls((event) => controls.push(event)); - try { - assert.equal(radioLayer.cycleStation(1, { stationIds: [ids[0]], autoplay: false, origin: 'user' }), true); - } finally { - unsubscribe(); - } - assert.equal(radioLayer.getUIState().selected?.id, ids[0]); - assert.deepEqual(controls, []); - }); -}); diff --git a/src/layers/radio/playback.js b/src/layers/radio/playback.js index 9f60e4850..5d865bad5 100644 --- a/src/layers/radio/playback.js +++ b/src/layers/radio/playback.js @@ -345,18 +345,16 @@ export function createPlayback({ state: layerState, services, parts, source }) { if (!parts.interaction.radioPresentationAllowed()) return Promise.resolve(false); if (layerState._selectedId) { - // An explicit Play owns its attempt; a fallback left by an earlier step - // (a cancelled voice handoff) belongs to a playback that never started. + // Drop a fallback left by a voice step whose playback never started. layerState._playFallbackId = null; layerState._playFallbackFocus = null; } else { const ranked = parts.queries.rankedVisibleStations(); if (!ranked.length) return Promise.resolve(false); - layerState._playFallbackId = ranked[1]?.id || null; - layerState._playFallbackFocus = false; parts.selection.selectRadioStation(ranked[0].id, { autoplay: false, focus: false, + playFallback: { id: ranked[1]?.id || null, focus: false }, }); } return playSelectedRadio({ origin }); diff --git a/src/layers/radio/selection.js b/src/layers/radio/selection.js index dbfecf8ec..9359cb3e4 100644 --- a/src/layers/radio/selection.js +++ b/src/layers/radio/selection.js @@ -73,22 +73,14 @@ export function createSelection({ return { filter: layerState._filter, volume: layerState._userVolume }; } - // Selecting another station without playing retires a paused or failed - // stream, so no control can resume or report a broadcaster that is no longer - // the selection. Stop clears fallbacks; the caller may have armed one for - // this selection, so it survives. + // A paused or failed stream of another station would outlive its selection. function releaseStaleRadioStream(stationId) { if ( - !layerState._audioStationId || - layerState._audioStationId === stationId || - !['paused', 'error'].includes(layerState._audioState) + layerState._audioStationId && + layerState._audioStationId !== stationId && + ['paused', 'error'].includes(layerState._audioState) ) - return; - const fallbackId = layerState._playFallbackId; - const fallbackFocus = layerState._playFallbackFocus; - parts.playback.stopRadioPlayback(); - layerState._playFallbackId = fallbackId; - layerState._playFallbackFocus = fallbackFocus; + parts.playback.stopRadioPlayback(); } /** Select a station. Playback occurs only when autoplay is explicitly true. */ @@ -101,12 +93,18 @@ export function createSelection({ origin = 'programmatic', attemptId = null, cameraNavigation = null, + playFallback = null, } = {}, ) { if (!parts.interaction.radioPresentationAllowed()) return false; const station = layerState._stationById.get(String(id)); if (!station) return false; if (!autoplay) releaseStaleRadioStream(station.id); + // Set after the release, which clears fallbacks. + if (playFallback) { + layerState._playFallbackId = playFallback.id; + layerState._playFallbackFocus = playFallback.focus; + } layerState._tuningUnavailableStationId = null; if ( !parts.navigation.radioCameraNavigationOwnsSelection( @@ -184,7 +182,7 @@ export function createSelection({ ? 0 : (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length; // Voice defers playback, so its silent step needs a fallback; UI steps do not. - layerState._playFallbackId = + const fallbackId = fallback && ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null; @@ -194,7 +192,7 @@ export function createSelection({ const rotationNavigation = rotate ? parts.navigation.beginRadioCameraNavigation(rotationCameraState) : null; - layerState._playFallbackFocus = + const fallbackFocus = fallback && rotate ? (fallbackStation) => { parts.navigation.rotateRadioStationIntoView( @@ -219,6 +217,7 @@ export function createSelection({ focus: false, origin, cameraNavigation: rotationNavigation, + playFallback: { id: fallbackId, focus: fallbackFocus }, }); } @@ -245,9 +244,12 @@ export function createSelection({ }, ); if (!ranked.length) return null; - layerState._playFallbackId = ranked[1]?.id || null; - layerState._playFallbackFocus = false; - selectRadioStation(ranked[0].id, { autoplay, focus: false, origin }); + selectRadioStation(ranked[0].id, { + autoplay, + focus: false, + origin, + playFallback: { id: ranked[1]?.id || null, focus: false }, + }); return ranked[0]; } diff --git a/src/layers/radio/tuning.js b/src/layers/radio/tuning.js index ed344d2c0..ab5c56bb5 100644 --- a/src/layers/radio/tuning.js +++ b/src/layers/radio/tuning.js @@ -179,10 +179,7 @@ export function createTuning({ state: layerState, services, parts, source }) { layerState._selectionGeneration += 1; parts.tuningNoise.clearRadioTuningNoise({ emit: false }); layerState._tuningUnavailableStationId = stationId; - // The drag paused the previous stream; with no selection left, Resume - // could only start the nearest station, so release it. Stop publishes - // the state, and as the user's gesture it cancels a voice handoff. - parts.playback.stopRadioPlayback({ origin }); + parts.presentation.emitState(); return Object.freeze({ ok: false, reason: 'station-unavailable', diff --git a/src/testSupport/radioLayerHarness.mjs b/src/testSupport/radioLayerHarness.mjs new file mode 100644 index 000000000..30b9e2a86 --- /dev/null +++ b/src/testSupport/radioLayerHarness.mjs @@ -0,0 +1,101 @@ +/** Shared Radio layer test stand-ins: a station row, fake Audio, directory fetch and viewer. */ +import { radioLayer } from '../data/radio.js'; + +/** One accepted Radio Browser directory row. */ +export function radioStationRow(overrides = {}) { + return { + id: '00000000-0000-4000-8000-000000000001', + name: 'Station', + lat: 30, + lon: -97, + streamUrl: 'https://radio.example.com/station.mp3', + homepage: null, + tags: ['news'], + languages: ['English'], + state: 'Texas', + country: 'United States', + countryCode: 'US', + metadataTrust: 'untrusted-community', + codec: 'MP3', + bitrate: 128, + ...overrides, + }; +} + +/** + * Run `body` against an enabled `radioLayer` that serves `stations`. + * Streams listed in `failingUrls` (mutable) reject play(); `body` receives the + * played URLs and the catalog, whose rows and generation the next update() serves. + */ +export async function withRadioLayer({ stations, failingUrls = [] }, body) { + const originalFetch = globalThis.fetch; + const originalAudio = globalThis.Audio; + const playUrls = []; + const catalog = { rows: stations, generation: 1 }; + globalThis.Audio = class FakeAudio { + constructor() { + this.volume = 0.8; + this.src = ''; + } + + addEventListener() {} + pause() {} + play() { + playUrls.push(this.src); + if (failingUrls.includes(this.src)) { + return Promise.reject( + new DOMException('unavailable', 'NotSupportedError'), + ); + } + return Promise.resolve(); + } + removeAttribute() { + this.src = ''; + } + load() {} + }; + globalThis.fetch = async (url) => + String(url).startsWith('/api/radio/click/') + ? { ok: true } + : new Response( + JSON.stringify({ + stations: catalog.rows, + stale: false, + degraded: false, + acceptedGeneration: catalog.generation, + catalogInstance: 'qa-instance-a', + updatedAt: new Date().toISOString(), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + const viewer = { + camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, + scene: { + canvas: { + disableRootEvents: true, + onwheel: null, + addEventListener() {}, + removeEventListener() {}, + }, + }, + dataSources: { add() {}, remove() {} }, + entities: { + add(entity) { + return entity; + }, + remove() {}, + }, + }; + radioLayer.destroy(); + try { + radioLayer.init(viewer); + radioLayer.enable(); + await radioLayer.update(); + await body({ playUrls, catalog }); + } finally { + globalThis.fetch = originalFetch; + if (originalAudio === undefined) delete globalThis.Audio; + else globalThis.Audio = originalAudio; + radioLayer.destroy(); + } +} diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js index 229003341..ac80e5ed8 100644 --- a/src/ui/radioBindings.js +++ b/src/ui/radioBindings.js @@ -328,8 +328,7 @@ export function bindRadioControls() { const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations; - // Keep the step silent unless a stream is active. An active stream follows - // the selection, so the station label matches the broadcaster. + // Silent unless a stream is active; an active stream follows the selection. const { audioState } = this.radio.getUIState(); const cycled = this.radio.cycleStation(direction, { rotate, diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index 225d86495..1cdabe69a 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -20,9 +20,7 @@ export function renderRadioState(state) { const interactive = enabled && !transitioning && !uncertain; const selected = state.selected || null; const hasStations = state.filteredCount > 0; - const activePlayback = ['loading', 'buffering', 'playing'].includes( - state.audioState, - ); + const activePlayback = ['playing', 'buffering'].includes(state.audioState); document .getElementById('title-bar') ?.classList.toggle('radio-broadcasting', state.audioState === 'playing');