From 1ef9be22485b22012b630567cf0173054df3a971 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:36:02 +0400 Subject: [PATCH 1/9] Keep Radio Next and Previous from starting playback before Play The Next and Previous buttons cycled stations with the layer's default autoplay, so a first press started buffering a broadcaster stream even though the panel says audio connects only after Play. Stepping now only selects until a stream is loading, buffering or playing; once one is, playback follows the new station so the old broadcaster does not keep playing under the new label. The compact Next fallback scenario in qa-radio.mjs started from a stopped stream and relied on the old behaviour, so it now starts from a playing station. Fixes HAL-722 Co-Authored-By: Claude Opus 5.5 --- scripts/qa-radio.mjs | 10 ++++++++- src/ui/radioBindings.js | 5 +++++ src/ui/radioControls.test.mjs | 39 ++++++++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index d78c3dbcc..115a3c76d 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -3820,7 +3820,15 @@ async function main() { radio.stopPlayback(); Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations').clustering.enabled = true; - radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false }); + // Next only carries playback once a stream is active (HAL-722), so the + // fallback scenario starts from a playing station. + radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false }); + }); + await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); + await page.evaluate(() => { + const gev = window.__godsEyeView; + const camera = gev.viewer.camera; + const radio = gev.dataManager.layers.get('radio').module; const pool = gev.styleManager._radioControls._radioTunerPool; const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id); window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length]; diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js index 88756d5bc..8f48b9bc9 100644 --- a/src/ui/radioBindings.js +++ b/src/ui/radioBindings.js @@ -338,9 +338,14 @@ export function bindRadioControls() { const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations; + // Stepping only selects until the user has pressed Play; once a stream is + // active it follows the selection so the old broadcaster does not keep + // playing under a new station label. + const { audioState } = this.radio.getUIState(); const cycled = this.radio.cycleStation(direction, { rotate, stationIds: pool.map((station) => station.id), + autoplay: ['loading', 'buffering', 'playing'].includes(audioState), origin: 'user', }); if (!cycled) { diff --git a/src/ui/radioControls.test.mjs b/src/ui/radioControls.test.mjs index b99c96a18..83bbafd7c 100644 --- a/src/ui/radioControls.test.mjs +++ b/src/ui/radioControls.test.mjs @@ -40,8 +40,11 @@ function fixture() { const panel = new Element(); const enable = new Element(); const next = new Element(); + const prev = new Element(); const calls = []; + const cycleOptions = []; const subscribers = []; + const uiState = { audioState: 'stopped' }; const radio = { subscribe(callback) { subscribers.push(callback); @@ -51,10 +54,12 @@ function fixture() { endTuning() { calls.push('endTuning'); }, - cycleStation(direction) { + cycleStation(direction, options) { calls.push(['cycle', direction]); + cycleOptions.push(options); return true; }, + getUIState: () => uiState, }; const actions = { isRegistered: () => true, @@ -73,6 +78,7 @@ function fixture() { _radioPanel: panel, _radioEnableBtn: enable, _radioNextBtn: next, + _radioPrevBtn: prev, }, radio, actions, @@ -82,8 +88,11 @@ function fixture() { controls, enable, next, + prev, actions, calls, + cycleOptions, + uiState, subscribers, document, cleanup() { @@ -172,3 +181,31 @@ test('destruction releases an active tuner pointer after revoking listeners', () f.cleanup(); } }); + +test('Next and Previous select without playing until playback has started', () => { + const f = fixture(); + try { + f.controls.connect(); + for (const audioState of ['stopped', 'paused', 'error']) { + f.uiState.audioState = audioState; + f.next.dispatchEvent(new Event('click')); + f.prev.dispatchEvent(new Event('click')); + } + assert.deepEqual( + f.cycleOptions.map((options) => options.autoplay), + [false, false, false, false, false, false], + ); + f.cycleOptions.length = 0; + for (const audioState of ['loading', 'buffering', 'playing']) { + f.uiState.audioState = audioState; + f.next.dispatchEvent(new Event('click')); + f.prev.dispatchEvent(new Event('click')); + } + assert.deepEqual( + f.cycleOptions.map((options) => options.autoplay), + [true, true, true, true, true, true], + ); + } finally { + f.cleanup(); + } +}); From b775bfed70f71938c932576a55e701e2805c6fad Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:36:28 +0400 Subject: [PATCH 2/9] Add the changelog entry Co-Authored-By: Claude Opus 5.5 --- changelog.d/1474-radio-next-no-autoplay.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1474-radio-next-no-autoplay.md diff --git a/changelog.d/1474-radio-next-no-autoplay.md b/changelog.d/1474-radio-next-no-autoplay.md new file mode 100644 index 000000000..8dec2b665 --- /dev/null +++ b/changelog.d/1474-radio-next-no-autoplay.md @@ -0,0 +1,3 @@ +- 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 (gekh, #1474). From 4dd5a313b39877d354e81241e168d04801ca073e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 13:44:48 +0400 Subject: [PATCH 3/9] Keep Radio playback state consistent after a silent Next Review follow-ups for HAL-722. A Next or Previous that only selects a station now leaves nothing of the previous stream behind: - Selecting another station without playing stops a paused or failed stream, so the panel, Stop, voice summaries and the audio element agree on "stopped". A fallback the caller armed for the new selection is kept. - A silent UI step arms no broadcaster fallback, and a user Play of the selected station drops one left by a cancelled voice step. Voice steps pass `fallback: true` because voice starts the prepared playback later. - A user step to another station emits a `select` playback control, and the realtime voice handoff treats it like Pause and Stop. - A tuner release onto a station the directory no longer has stops the stream the drag paused, publishing one state with the gesture's origin, so the button reads PLAY and a voice handoff is cancelled. - The transport buttons offer Pause while a stream connects, matching what a click does. - The privacy note names every action that connects to a broadcaster (play, finishing tuning, choosing a station on the globe) in all locales. The Next/Previous layer tests live in src/data/radioNextState.test.mjs. --- changelog.d/1474-radio-next-no-autoplay.md | 4 +- docs/CURRENT-STATE.md | 11 +- src/data/radio.test.mjs | 3 +- src/data/radioNextState.test.mjs | 395 +++++++++++++++++++++ src/i18n/locales/es.json | 2 +- src/i18n/locales/ru.json | 2 +- src/i18n/locales/uk.json | 2 +- src/i18n/locales/zh-CN.json | 2 +- src/i18n/locales/zh-Hant.json | 2 +- src/layers/radio/playback.js | 7 +- src/layers/radio/selection.js | 52 ++- src/layers/radio/tuning.js | 5 +- src/ui/radioBindings.js | 5 +- src/ui/radioControls.test.mjs | 2 +- src/ui/radioPresentation.js | 4 +- src/ui/templates/context.html | 2 +- src/voice/gevActions.js | 7 +- src/voice/gevActions.test.mjs | 2 +- src/voice/gevRealtime.test.mjs | 83 +++-- src/voice/realtimeRadio.js | 2 +- 20 files changed, 523 insertions(+), 71 deletions(-) create mode 100644 src/data/radioNextState.test.mjs diff --git a/changelog.d/1474-radio-next-no-autoplay.md b/changelog.d/1474-radio-next-no-autoplay.md index 8dec2b665..b4298bacf 100644 --- a/changelog.d/1474-radio-next-no-autoplay.md +++ b/changelog.d/1474-radio-next-no-autoplay.md @@ -1,3 +1,5 @@ - 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 (gekh, #1474). + 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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c12205adf..37f5e499b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2955,9 +2955,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > take over under the Cockpit cap of 60. Loading/capped contacts remain 2D, > out-of-range contacts use rotation-free dots, and exit restores map treatment. > Its Radio popover exposes compact power, -> transport, station, and volume controls. Cockpit Previous/Next preserves -> selection, autoplay, and broadcaster fallback audio without starting the -> map-view station flights that compete with the first-person camera. These +> transport, station, and volume controls. Cockpit Previous/Next changes the +> selection without starting the map-view station flights that compete with +> the first-person camera; like the Context Previous/Next buttons, it plays +> (with broadcaster fallback) only while a stream is already active. These > are a Cockpit-only accordion: > each static header uses the left accordion's label and divider with a dedicated > directional-chevron disclosure button and no full-row hover treatment. Only one @@ -4760,7 +4761,9 @@ before the later control succeeds, only its playback handoff is suppressed; its 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. +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. 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 99963df16..364abbb0f 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -964,7 +964,8 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn assert.equal(radioLayer.cycleStation(1, { stationIds: stationRows.map(({ id }) => id), autoplay: false, - }), true, 'non-playing cycle creates the stale-fallback precondition'); + fallback: true, + }), true, 'a voice-style prepared cycle creates the stale-fallback precondition'); assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.previewTuningStation(stationRows[0].id, { rotate: false }), true); assert.equal(radioLayer.commitTuningStation(stationRows[0].id, { origin: 'user' }).ok, true); diff --git a/src/data/radioNextState.test.mjs b/src/data/radioNextState.test.mjs new file mode 100644 index 000000000..102ef3a42 --- /dev/null +++ b/src/data/radioNextState.test.mjs @@ -0,0 +1,395 @@ +// Radio Next/Previous that only select (autoplay: false), against the real layer +// and the panel that renders it. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { radioLayer } from './radio.js'; +import { setLocale } from '../i18n/index.js'; +import { renderRadioState } from '../ui/radioPresentation.js'; + +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) => ({ + 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 settle = () => new Promise((resolve) => setTimeout(resolve)); + +// What the Radio panel, the compact Context player and Cockpit show for a state. +function render(state) { + const el = () => { + const classes = new Set(); + return { + textContent: '', + disabled: false, + title: '', + setAttribute() {}, + getAttribute: () => null, + classList: { toggle(name, on) { on ? classes.add(name) : classes.delete(name); }, contains: (name) => classes.has(name) }, + }; + }; + const previousDocument = globalThis.document; + globalThis.document = { getElementById: () => null, activeElement: null }; + try { + const host = { + destroyed: false, + _radioPanel: el(), + _radioPlaybackState: el(), + _radioPlayBtn: el(), + _radioStopBtn: el(), + _contextRadioMiniPlayBtn: el(), + _cockpitRadioPlayBtn: el(), + _syncContextRadioLauncherState() {}, + actions: { + getLifecycle: () => null, + preservePanelStateDuringClear: () => false, + setPanelCollapsed() {}, + scheduleLayout() {}, + }, + }; + renderRadioState.call(host, state); + return { + status: host._radioPlaybackState.textContent, + error: host._radioPlaybackState.classList.contains('error'), + button: host._radioPlayBtn.textContent, + stopDisabled: host._radioStopBtn.disabled, + mini: host._contextRadioMiniPlayBtn.textContent, + cockpit: host._cockpitRadioPlayBtn.textContent, + }; + } finally { + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + } +} + +test('Next before Play selects without starting a stream, and Play starts the new station', async () => { + await withLayer([], 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' }); + const state = radioLayer.getUIState(); + assert.deepEqual(playUrls, [stationRows[1].streamUrl]); + assert.equal(state.audioState, 'playing'); + assert.equal(state.playingStationId, ids[1]); + }); +}); + +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) => { + radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' }); + await settle(); + assert.equal(radioLayer.getUIState().audioState, 'error'); + assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true); + const state = radioLayer.getUIState(); + 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, + mini: '▶', + cockpit: '▶', + }); + await radioLayer.togglePlayback({ origin: 'user' }); + assert.deepEqual(playUrls, [stationRows[0].streamUrl, stationRows[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) => { + radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' }); + await settle(); + await radioLayer.togglePlayback({ origin: 'user' }); + assert.equal(radioLayer.getUIState().audioState, 'paused'); + radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }); + const state = radioLayer.getUIState(); + 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, + mini: '▶', + cockpit: '▶', + }); + await radioLayer.togglePlayback({ origin: 'user' }); + assert.deepEqual(playUrls, [stationRows[0].streamUrl, stationRows[1].streamUrl]); + assert.equal(radioLayer.getUIState().audioState, 'playing'); + assert.equal(radioLayer.getUIState().playingStationId, ids[1]); + }); +}); + +test('a catalog refresh after a paused Next cannot bring back Resume for an unnamed station', async () => { + setLocale('en'); + await withLayer([], async (_playUrls, 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.generation = 2; + await radioLayer.update(); + const state = radioLayer.getUIState(); + assert.equal(state.selected, null); + assert.equal(state.audioState, 'stopped'); + const shown = render(state); + assert.equal(shown.status, READY); + assert.equal(shown.button, 'PLAY'); + }); +}); + +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.mini, 'Ⅱ'); + 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) => { + radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' }); + await settle(); + radioLayer.pause({ origin: 'voice-duck' }); + playUrls.length = 0; + // Anchor on the far station so the paused one is not the first match. + const first = radioLayer.selectRequestedStation( + { categoryId: 'all', anchor: { lat: 32, lon: -99 } }, + { autoplay: false }, + ); + failing.push(first.streamUrl); + await radioLayer.play({ origin: 'voice' }); + await settle(); + await settle(); + assert.equal(playUrls.length, 2); + assert.equal(playUrls[0], first.streamUrl); + assert.equal(radioLayer.getUIState().audioState, 'playing'); + }); +}); + +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' }); + 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'); + }); +}); + +test('a user step that does not play 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' }); + } finally { + unsubscribe(); + } + 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/i18n/locales/es.json b/src/i18n/locales/es.json index 696233f86..6dd869ebb 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -461,7 +461,7 @@ "Radio volume": "Volumen de Radio", "Radio off": "Radio desactivada", "STATION SITE": "SITIO DE LA EMISORA", - "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora después de pulsar ESCUCHAR. Tu IP es visible para esa emisora.", + "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora al pulsar ESCUCHAR, terminar de sintonizar o elegir una emisora en el globo. Tu IP es visible para esa emisora.", "LOADING": "CARGANDO", "DEGRADED": "DEGRADADO", "STALE": "DESACTUALIZADO", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 8764d9d06..64bfd9d47 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -735,7 +735,7 @@ "STATION TAG": "ТЕГ СТАНЦИИ", "DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ПРОСМОТР · ENTER / SPACE — ВЫБОР", - "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "После нажатия «Пуск» звук идёт напрямую от вещателя, и ему виден ваш IP-адрес.", + "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "Звук идёт напрямую от вещателя после нажатия «Пуск», завершения настройки или выбора станции на глобусе. Вещателю виден ваш IP-адрес.", "Internet radio and local SDR": "Интернет-радио и локальный SDR", "Filter stations by station tag": "Фильтровать станции по тегу", "CONTEXT": "КОНТЕКСТ", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index fe0f3ccac..673139fa8 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -2906,7 +2906,7 @@ "Radio volume": "Гучність радіо", "STATION SITE": "САЙТ СТАНЦІЇ", "DIRECTORY: RADIO BROWSER": "КАТАЛОГ: RADIO BROWSER", - "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Після натискання «Відтворити» звук надходить безпосередньо від мовника. Мовник бачить вашу IP-адресу.", + "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "Звук надходить безпосередньо від мовника після натискання «Відтворити», завершення налаштування або вибору станції на глобусі. Мовник бачить вашу IP-адресу.", "Local RTL-SDR receiver": "Локальний приймач RTL-SDR", "LOCAL RTL-SDR": "ЛОКАЛЬНИЙ RTL-SDR", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · один локальний тюнер для FM і шару «Локальний ADS-B».", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 2e7db6b8f..b2620d777 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2591,7 +2591,7 @@ "Radio volume": "电台音量", "STATION SITE": "电台网站", "DIRECTORY: RADIO BROWSER": "目录:RADIO BROWSER", - "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "点击播放后,音频将直接连接到广播方。广播方可以看到你的 IP。", + "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "点击播放、完成调谐或在地球上选择电台后,音频将直接连接到广播方。广播方可以看到你的 IP。", "Local RTL-SDR receiver": "本地 RTL-SDR 接收机", "LOCAL RTL-SDR": "本地 RTL-SDR", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一个本地调谐器由 FM 与本地 ADS-B 图层共享。", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 1477acd51..eeaef408c 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2579,7 +2579,7 @@ "Radio volume": "電臺音量", "STATION SITE": "電臺網站", "DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER", - "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "點擊播放後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。", + "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "點擊播放、完成調諧或在地球上選擇電臺後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。", "Local RTL-SDR receiver": "本地 RTL-SDR 接收機", "LOCAL RTL-SDR": "本地 RTL-SDR", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本地調諧器由 FM 與本地 ADS-B 圖層共享。", diff --git a/src/layers/radio/playback.js b/src/layers/radio/playback.js index 9cd22a917..9f60e4850 100644 --- a/src/layers/radio/playback.js +++ b/src/layers/radio/playback.js @@ -344,7 +344,12 @@ export function createPlayback({ state: layerState, services, parts, source }) { } if (!parts.interaction.radioPresentationAllowed()) return Promise.resolve(false); - if (!layerState._selectedId) { + 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. + 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; diff --git a/src/layers/radio/selection.js b/src/layers/radio/selection.js index bffc7de04..dbfecf8ec 100644 --- a/src/layers/radio/selection.js +++ b/src/layers/radio/selection.js @@ -73,6 +73,24 @@ 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. + function releaseStaleRadioStream(stationId) { + if ( + !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; + } + /** Select a station. Playback occurs only when autoplay is explicitly true. */ function selectRadioStation( @@ -88,6 +106,7 @@ export function createSelection({ if (!parts.interaction.radioPresentationAllowed()) return false; const station = layerState._stationById.get(String(id)); if (!station) return false; + if (!autoplay) releaseStaleRadioStream(station.id); layerState._tuningUnavailableStationId = null; if ( !parts.navigation.radioCameraNavigationOwnsSelection( @@ -104,6 +123,7 @@ export function createSelection({ ) parts.tuning.endRadioTuning(); layerState._cancelledTuningPresentationStation = null; + const changed = layerState._selectedId !== station.id; layerState._selectedId = station.id; const generation = ++layerState._selectionGeneration; const sessionGeneration = layerState._sessionGeneration; @@ -122,6 +142,9 @@ export function createSelection({ if (focus) parts.navigation.focusStation(station); parts.presentation.emitState(); if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId }); + // A user step to another station overrides a playback voice prepared. + else if (origin === 'user' && changed) + parts.presentation.emitPlaybackControl('select', origin); return true; } @@ -133,6 +156,7 @@ export function createSelection({ rotate = false, stationIds = null, autoplay = true, + fallback = autoplay, origin = 'programmatic', } = {}, ) { @@ -159,25 +183,29 @@ export function createSelection({ current < 0 ? 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 = - ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null; + fallback && ranked.length > 1 + ? ranked[(nextIndex + 1) % ranked.length].id + : null; const rotationCameraState = rotate ? parts.navigation.radioCameraState() : null; const rotationNavigation = rotate ? parts.navigation.beginRadioCameraNavigation(rotationCameraState) : null; - layerState._playFallbackFocus = rotate - ? (fallbackStation) => { - parts.navigation.rotateRadioStationIntoView( - fallbackStation, - 0.65, - rotationCameraState, - rotationNavigation, - ); - return rotationNavigation; - } - : false; + layerState._playFallbackFocus = + fallback && rotate + ? (fallbackStation) => { + parts.navigation.rotateRadioStationIntoView( + fallbackStation, + 0.65, + rotationCameraState, + rotationNavigation, + ); + return rotationNavigation; + } + : false; const station = ranked[nextIndex]; if (rotate) parts.navigation.rotateRadioStationIntoView( diff --git a/src/layers/radio/tuning.js b/src/layers/radio/tuning.js index ab5c56bb5..ed344d2c0 100644 --- a/src/layers/radio/tuning.js +++ b/src/layers/radio/tuning.js @@ -179,7 +179,10 @@ export function createTuning({ state: layerState, services, parts, source }) { layerState._selectionGeneration += 1; parts.tuningNoise.clearRadioTuningNoise({ emit: false }); layerState._tuningUnavailableStationId = stationId; - parts.presentation.emitState(); + // 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 }); return Object.freeze({ ok: false, reason: 'station-unavailable', diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js index 8f48b9bc9..8d5b162c4 100644 --- a/src/ui/radioBindings.js +++ b/src/ui/radioBindings.js @@ -338,9 +338,8 @@ export function bindRadioControls() { const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations; - // Stepping only selects until the user has pressed Play; once a stream is - // active it follows the selection so the old broadcaster does not keep - // playing under a new station label. + // Keep the step silent unless a stream is active. An active stream follows + // the selection, so the station label matches the broadcaster. const { audioState } = this.radio.getUIState(); const cycled = this.radio.cycleStation(direction, { rotate, diff --git a/src/ui/radioControls.test.mjs b/src/ui/radioControls.test.mjs index 83bbafd7c..dcc5eb192 100644 --- a/src/ui/radioControls.test.mjs +++ b/src/ui/radioControls.test.mjs @@ -182,7 +182,7 @@ test('destruction releases an active tuner pointer after revoking listeners', () } }); -test('Next and Previous select without playing until playback has started', () => { +test('Next and Previous play only while a stream is active', () => { const f = fixture(); try { f.controls.connect(); diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index 54ee379e8..bc56707dd 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -20,7 +20,9 @@ export function renderRadioState(state) { const interactive = enabled && !transitioning && !uncertain; const selected = state.selected || null; const hasStations = state.filteredCount > 0; - const activePlayback = ['playing', 'buffering'].includes(state.audioState); + const activePlayback = ['loading', 'buffering', 'playing'].includes( + state.audioState, + ); document .getElementById('title-bar') ?.classList.toggle('radio-broadcasting', state.audioState === 'playing'); diff --git a/src/ui/templates/context.html b/src/ui/templates/context.html index 9e88d20f4..d4eb723c6 100644 --- a/src/ui/templates/context.html +++ b/src/ui/templates/context.html @@ -162,7 +162,7 @@ DIRECTORY: RADIO BROWSER -

Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.

+

Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.

LOCAL RTL-SDR diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 0a43f02f1..23d988119 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -2211,7 +2211,11 @@ export async function controlRadio( if (!radioActionIsCurrent(options)) return cancelled(summarize); const prepared = state.selected ? true - : radio.cycleStation?.(1, { focus: false, autoplay: false }); + : radio.cycleStation?.(1, { + focus: false, + autoplay: false, + fallback: true, + }); return { ok: Boolean(prepared), action: 'control_radio', @@ -2235,6 +2239,7 @@ export async function controlRadio( const selected = radio.cycleStation?.(action === 'next' ? 1 : -1, { focus: false, autoplay: false, + fallback: true, }); return { ok: Boolean(selected), diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 4a3a8ec72..5a6a8b960 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -1850,7 +1850,7 @@ test('voice Radio resolves Austin and exposes semantic selection, volume, pause, assert.equal(result.radioAction, 'play'); assert.equal(result.stationId, 'nearest'); assert.equal('station' in result, false); - assert.deepEqual(calls.at(-1), ['cycle', 1, { focus: false, autoplay: false }]); + assert.deepEqual(calls.at(-1), ['cycle', 1, { focus: false, autoplay: false, fallback: true }]); for (const invalidCoordinates of [ { latitude: null, longitude: null }, diff --git a/src/voice/gevRealtime.test.mjs b/src/voice/gevRealtime.test.mjs index 541e2cc04..c67ad4562 100644 --- a/src/voice/gevRealtime.test.mjs +++ b/src/voice/gevRealtime.test.mjs @@ -593,46 +593,55 @@ test('replacement input invalidates an in-flight Radio preflight immediately', ( assert.equal(controller.radioHandoffEpoch, priorEpoch + 1); }); -test('direct Radio pause or stop cancels a pending voice handoff', async () => { - const order = []; - let playbackControl = null; - const ui = { - root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] }, - status: { textContent: '' }, - detail: { textContent: '', title: '' }, - errorDetail: { textContent: '' }, - }; - const controller = new GevRealtimeController({ - ui, - runner: async () => ({ ok: true }), - radioLayer: { - subscribePlaybackControls(listener) { - playbackControl = listener; - return () => { playbackControl = null; }; +for (const [name, control, message] of [ + ['direct Radio pause or stop cancels a pending voice handoff', 'stop', 'the explicit stop remains authoritative'], + [ + 'a direct Radio Next that does not play cancels a pending voice handoff', + { action: 'select', origin: 'user', attemptId: null }, + 'the user selection is not overridden by voice playback', + ], +]) { + test(name, async () => { + const order = []; + let playbackControl = null; + const ui = { + root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] }, + status: { textContent: '' }, + detail: { textContent: '', title: '' }, + errorDetail: { textContent: '' }, + }; + const controller = new GevRealtimeController({ + ui, + runner: async () => ({ ok: true }), + radioLayer: { + subscribePlaybackControls(listener) { + playbackControl = listener; + return () => { playbackControl = null; }; + }, + playForVoice: async () => { + order.push('radio-play'); + return true; + }, + stopPlayback: () => order.push('radio-stop'), }, - playForVoice: async () => { - order.push('radio-play'); - return true; - }, - stopPlayback: () => order.push('radio-stop'), - }, - }); - controller.debugLog = () => {}; - controller.dc = { - readyState: 'open', - send() {}, - close() { order.push('voice-stop'); }, - }; - controller.pendingRadioPlaybackResult = { ok: true, radioPlaybackRequested: true }; + }); + controller.debugLog = () => {}; + controller.dc = { + readyState: 'open', + send() {}, + close() { order.push('voice-stop'); }, + }; + controller.pendingRadioPlaybackResult = { ok: true, radioPlaybackRequested: true }; - playbackControl('stop'); - await controller.handleRealtimeEvent({ - data: JSON.stringify({ type: 'response.done', response: { status: 'completed' } }), - }); + playbackControl(control); + await controller.handleRealtimeEvent({ + data: JSON.stringify({ type: 'response.done', response: { status: 'completed' } }), + }); - assert.equal(controller.pendingRadioPlaybackResult, null); - assert.deepEqual(order, [], 'the explicit stop remains authoritative'); -}); + assert.equal(controller.pendingRadioPlaybackResult, null); + assert.deepEqual(order, [], message); + }); +} test('confirmed manual Radio playback closes active voice without stopping Radio', () => { const order = []; diff --git a/src/voice/realtimeRadio.js b/src/voice/realtimeRadio.js index 4c5212d6e..065c55c3b 100644 --- a/src/voice/realtimeRadio.js +++ b/src/voice/realtimeRadio.js @@ -65,7 +65,7 @@ export class RealtimeRadio { : control || {}; if ( event.origin === 'user' && - (event.action === 'pause' || event.action === 'stop') + ['pause', 'stop', 'select'].includes(event.action) ) { this.cancelRadioHandoff(); } else if ( From fa840ae3c3dee8d9b0e6a4d22be533ab38660efb Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 14:26:58 +0400 Subject: [PATCH 4/9] Translate the Radio privacy note into the new locales main added de, fr, it, ja and pt with the old "after you press play" note; give them the updated wording. main also removed the compact Context Radio player, so the Next/Previous tests stop checking its button. --- src/data/radioNextState.test.mjs | 7 +------ src/i18n/locales/de.json | 2 +- src/i18n/locales/fr.json | 2 +- src/i18n/locales/it.json | 2 +- src/i18n/locales/ja.json | 2 +- src/i18n/locales/pt.json | 2 +- 6 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/data/radioNextState.test.mjs b/src/data/radioNextState.test.mjs index 102ef3a42..ff8563003 100644 --- a/src/data/radioNextState.test.mjs +++ b/src/data/radioNextState.test.mjs @@ -80,7 +80,7 @@ async function withLayer(failingUrls, body) { const settle = () => new Promise((resolve) => setTimeout(resolve)); -// What the Radio panel, the compact Context player and Cockpit show for a state. +// What the Radio panel and the Cockpit player show for a state. function render(state) { const el = () => { const classes = new Set(); @@ -102,7 +102,6 @@ function render(state) { _radioPlaybackState: el(), _radioPlayBtn: el(), _radioStopBtn: el(), - _contextRadioMiniPlayBtn: el(), _cockpitRadioPlayBtn: el(), _syncContextRadioLauncherState() {}, actions: { @@ -118,7 +117,6 @@ function render(state) { error: host._radioPlaybackState.classList.contains('error'), button: host._radioPlayBtn.textContent, stopDisabled: host._radioStopBtn.disabled, - mini: host._contextRadioMiniPlayBtn.textContent, cockpit: host._cockpitRadioPlayBtn.textContent, }; } finally { @@ -158,7 +156,6 @@ test('Next after a failed stream leaves the new station ready to play, without t error: false, button: 'PLAY', stopDisabled: true, - mini: '▶', cockpit: '▶', }); await radioLayer.togglePlayback({ origin: 'user' }); @@ -184,7 +181,6 @@ test('Next while paused releases the paused stream, and Play starts the new stat error: false, button: 'PLAY', stopDisabled: true, - mini: '▶', cockpit: '▶', }); await radioLayer.togglePlayback({ origin: 'user' }); @@ -272,7 +268,6 @@ test('while a stream connects the transport buttons offer Pause, because a click }); assert.equal(shown.status, 'Connecting directly to broadcaster…'); assert.equal(shown.button, 'PAUSE'); - assert.equal(shown.mini, 'Ⅱ'); assert.equal(shown.cockpit, 'Ⅱ'); }); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8e383aded..aaabc7f5a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -781,7 +781,7 @@ "Stop radio playback": "Radiowiedergabe stoppen", "Radio volume": "Radio-Lautstärke", "STATION SITE": "SENDER-WEBSITE", - "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Beim Starten der Wiedergabe wird eine direkte Verbindung zum Sender hergestellt. Die IP-Adresse ist für diesen Sender sichtbar.", + "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "Beim Starten der Wiedergabe, nach dem Abstimmen oder bei der Wahl eines Senders auf dem Globus wird eine direkte Verbindung zum Sender hergestellt. Die IP-Adresse ist für diesen Sender sichtbar.", "Intelligence HUD (H)": "Lagebild-HUD (H)", "Layout": "Layout", "HUD layout": "HUD-Layout", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 931e7c3ff..3874b6a61 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2828,7 +2828,7 @@ "Radio volume": "Volume de la radio", "STATION SITE": "SITE DE LA STATION", "DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER", - "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture. Votre IP est visible par ce diffuseur.", + "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, terminez le réglage ou choisissez une station sur le globe. Votre IP est visible par ce diffuseur.", "Local RTL-SDR receiver": "Récepteur RTL-SDR local", "LOCAL RTL-SDR": "RTL-SDR LOCAL", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 62c9931c1..267e656b1 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2828,7 +2828,7 @@ "Radio volume": "Volume radio", "STATION SITE": "SITO STAZIONE", "DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER", - "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play. Il tuo IP è visibile a quell'emittente.", + "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play, terminato la sintonizzazione o scelto una stazione sul globo. Il tuo IP è visibile a quell'emittente.", "Local RTL-SDR receiver": "Ricevitore RTL-SDR locale", "LOCAL RTL-SDR": "RTL-SDR LOCALE", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizzatore locale condiviso tra FM e il livello ADS-B locale.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5a48dc886..31bdc0b1f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2686,7 +2686,7 @@ "Radio volume": "ラジオの音量", "STATION SITE": "放送局サイト", "DIRECTORY: RADIO BROWSER": "ディレクトリ:Radio Browser", - "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "再生ボタンを押すと、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。", + "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "再生ボタンを押す、選局を終える、または地球儀で局を選ぶと、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。", "Local RTL-SDR receiver": "ローカルRTL-SDR受信機", "LOCAL RTL-SDR": "ローカルRTL-SDR", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · FMとローカルADS-Bレイヤーで1台のローカルチューナーを共有します。", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index de42b5aab..5f0b36e45 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2830,7 +2830,7 @@ "Radio volume": "Volume do rádio", "STATION SITE": "SITE DA ESTAÇÃO", "DIRECTORY: RADIO BROWSER": "DIRETÓRIO: RADIO BROWSER", - "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora depois que você aperta \"Ouvir\". Seu IP fica visível para essa emissora.", + "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora quando você aperta \"Ouvir\", termina de sintonizar ou escolhe uma estação no globo. Seu IP fica visível para essa emissora.", "Local RTL-SDR receiver": "Receptor RTL-SDR local", "LOCAL RTL-SDR": "RTL-SDR LOCAL", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · um sintonizador local compartilhado entre o FM e a camada ADS-B local.", From a55450e2e70b012890e38867be532ff72afaa86b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 16:37:36 +0400 Subject: [PATCH 5/9] 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'); From 5ed86200e593f62e45729d412320472d4cff9924 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 16:44:15 +0400 Subject: [PATCH 6/9] Bring back the tuner-release and connecting-label Radio fixes Restore the two fixes the previous commit moved out: - A tuner release onto a station the directory dropped mid-drag stops the stream the drag paused, publishing one state with the gesture's origin, so the button reads PLAY and a voice handoff is cancelled. - The transport buttons offer Pause while a stream connects, matching what a click does. Their tests now use the shared Radio layer harness. --- changelog.d/1474-radio-next-no-autoplay.md | 4 +- docs/CURRENT-STATE.md | 3 +- src/data/radioNextState.test.mjs | 69 ++++++++++++++++++++++ src/layers/radio/tuning.js | 5 +- src/ui/radioPresentation.js | 4 +- 5 files changed, 81 insertions(+), 4 deletions(-) diff --git a/changelog.d/1474-radio-next-no-autoplay.md b/changelog.d/1474-radio-next-no-autoplay.md index a4ca6d96b..46c4b1610 100644 --- a/changelog.d/1474-radio-next-no-autoplay.md +++ b/changelog.d/1474-radio-next-no-autoplay.md @@ -1,2 +1,4 @@ - Radio Next and Previous no longer start a broadcaster stream before Play is - pressed; while a stream plays, it follows the new station (gekh, #1474). + pressed; while a stream plays, it follows the new station. Play reads Pause + while a stream connects, and a tuner release onto a station the directory + dropped no longer leaves a Resume for another station (gekh, #1474). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2aafd7ebd..abf8094eb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4774,7 +4774,8 @@ 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 and a user Previous/Next that only selects another station. +user Pause, a user Previous/Next that only selects another station, and a tuner +release onto a station the directory no longer has. 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/radioNextState.test.mjs b/src/data/radioNextState.test.mjs index eeaf2e0d6..1a419b8df 100644 --- a/src/data/radioNextState.test.mjs +++ b/src/data/radioNextState.test.mjs @@ -192,3 +192,72 @@ test('only a user step to another station tells voice to drop its prepared playb assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['select', 'user']]); }); }); + +test('an unavailable tuner release stops the paused stream once, and Play still falls back', async () => { + setLocale('en'); + const failing = []; + await withLayer(failing, async ({ playUrls, catalog }) => { + radioLayer.selectStation(ids[2], { autoplay: true, origin: 'user' }); + 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 = stations.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.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['stop', 'user']]); + 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'); + }); +}); + +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, 'Ⅱ'); +}); diff --git a/src/layers/radio/tuning.js b/src/layers/radio/tuning.js index ab5c56bb5..ed344d2c0 100644 --- a/src/layers/radio/tuning.js +++ b/src/layers/radio/tuning.js @@ -179,7 +179,10 @@ export function createTuning({ state: layerState, services, parts, source }) { layerState._selectionGeneration += 1; parts.tuningNoise.clearRadioTuningNoise({ emit: false }); layerState._tuningUnavailableStationId = stationId; - parts.presentation.emitState(); + // 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 }); return Object.freeze({ ok: false, reason: 'station-unavailable', diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index 1cdabe69a..225d86495 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -20,7 +20,9 @@ export function renderRadioState(state) { const interactive = enabled && !transitioning && !uncertain; const selected = state.selected || null; const hasStations = state.filteredCount > 0; - const activePlayback = ['playing', 'buffering'].includes(state.audioState); + const activePlayback = ['loading', 'buffering', 'playing'].includes( + state.audioState, + ); document .getElementById('title-bar') ?.classList.toggle('radio-broadcasting', state.audioState === 'playing'); From 857b007fb1fe2df77b29d89e0d7868650d7785ce Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 13:27:55 +0400 Subject: [PATCH 7/9] Keep the Radio tuning dial silent unless a stream is playing Releasing the tuning dial always started the chosen station, plus tuning static, even when nothing was playing. It now follows the Previous/Next rule: the release plays only if a stream was loading, buffering or playing when the drag began; otherwise it just selects the station. The privacy note no longer lists finishing tuning as a way to connect and now names voice instead, in all locales. Docs, changelog and comments are tightened to match. --- changelog.d/1474-radio-next-no-autoplay.md | 9 +++---- docs/CURRENT-STATE.md | 10 ++++---- scripts/qa-radio.mjs | 3 +-- src/data/radio.test.mjs | 5 +++- src/data/radioNextState.test.mjs | 28 ++++++++++++++++++++-- src/i18n/locales/ar.json | 2 +- src/i18n/locales/de.json | 2 +- src/i18n/locales/es.json | 2 +- src/i18n/locales/fr.json | 2 +- src/i18n/locales/it.json | 2 +- src/i18n/locales/ja.json | 2 +- src/i18n/locales/pt.json | 2 +- src/i18n/locales/ru.json | 2 +- src/i18n/locales/uk.json | 2 +- src/i18n/locales/zh-CN.json | 2 +- src/i18n/locales/zh-Hant.json | 2 +- src/layers/radio/selection.js | 2 +- src/layers/radio/state.js | 2 ++ src/layers/radio/tuning.js | 19 ++++++++++----- src/testSupport/radioLayerHarness.mjs | 2 -- src/ui/views/panels/RadioPanel.vue | 2 +- 21 files changed, 70 insertions(+), 34 deletions(-) diff --git a/changelog.d/1474-radio-next-no-autoplay.md b/changelog.d/1474-radio-next-no-autoplay.md index 46c4b1610..6fab42138 100644 --- a/changelog.d/1474-radio-next-no-autoplay.md +++ b/changelog.d/1474-radio-next-no-autoplay.md @@ -1,4 +1,5 @@ -- Radio Next and Previous no longer start a broadcaster stream before Play is - pressed; while a stream plays, it follows the new station. Play reads Pause - while a stream connects, and a tuner release onto a station the directory - dropped no longer leaves a Resume for another station (gekh, #1474). +- Radio Next, Previous and the tuning dial no longer start a broadcaster stream + before Play is pressed; while a stream plays, it follows the new station. + Play reads Pause while a stream connects, and a tuner release onto a station + the directory removed or changed no longer leaves a Resume for another + station (gekh, #1474). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2d04d4f7e..2a7e0d827 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4702,10 +4702,12 @@ accepted snapshot, and feed telemetry before re-initialization; monotonic ownership tokens plus the session boundary keep late callbacks from a retired session inert. A tuner drag resolves previews against the single accepted snapshot captured at -pointer-down, even while a newer catalog is admitted. Release starts playback +pointer-down, even while a newer catalog is admitted. Like Previous/Next, +release starts playback only if a stream was active when the drag began, and only when the current same-ID record still exactly matches the frozen -presentation and stream metadata; removal or replacement reports the channel as -unavailable and never silently retargets the drag. A cold degraded fallback may +presentation and stream metadata; otherwise it just selects the station. Removal +or replacement reports the channel as unavailable and never silently retargets +the drag. A cold degraded fallback may still populate the directory and globe, but its null accepted generation cannot populate or begin the tuner. @@ -4884,7 +4886,7 @@ 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. +release onto a station the directory removed or changed mid-drag. 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/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 06b59baf9..8e86de86d 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -3692,8 +3692,7 @@ async function main() { radio.stopPlayback(); Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations').clustering.enabled = true; - // Next only carries playback once a stream is active (HAL-722), so the - // fallback scenario starts from a playing station. + // Next plays only while a stream is active, so start from a playing station. radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false }); }); await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 260facff9..de5717a11 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -902,12 +902,15 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn stations: stationRows, failingUrls: [stationRows[0].streamUrl], }, async ({ playUrls }) => { - assert.equal(radioLayer.selectStation(stationRows[0].id, { autoplay: false }), true); + // A playing stream, so the release plays. + assert.equal(radioLayer.selectStation(stationRows[1].id, { autoplay: true }), true); + await new Promise((resolve) => setTimeout(resolve)); assert.equal(radioLayer.cycleStation(1, { stationIds: stationRows.map(({ id }) => id), autoplay: false, fallback: true, }), true, 'a voice-style prepared cycle creates the stale-fallback precondition'); + playUrls.length = 0; assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.previewTuningStation(stationRows[0].id, { rotate: false }), true); assert.equal(radioLayer.commitTuningStation(stationRows[0].id, { origin: 'user' }).ok, true); diff --git a/src/data/radioNextState.test.mjs b/src/data/radioNextState.test.mjs index 264765c75..3f9b2c6f6 100644 --- a/src/data/radioNextState.test.mjs +++ b/src/data/radioNextState.test.mjs @@ -1,5 +1,4 @@ -// Radio Next/Previous that only select (autoplay: false), against the real layer -// and the panel that renders it. +// Radio selection, fallbacks and voice handoffs on the real layer and its published view. import assert from 'node:assert/strict'; import test from 'node:test'; import { radioLayer } from './radio.js'; @@ -247,3 +246,28 @@ test('while a stream connects the transport buttons offer Pause, because a click assert.equal(shown.button, 'PAUSE'); assert.equal(shown.cockpit, 'Ⅱ'); }); + +test('a dial release plays only if a stream was active when the drag began', async () => { + await withLayer([], async ({ playUrls }) => { + radioLayer.selectStation(ids[0], { autoplay: false }); + assert.equal(radioLayer.beginTuning(), true); + radioLayer.previewTuningStation(ids[1], { rotate: false }); + assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true); + await settle(); + let state = radioLayer.getUIState(); + assert.deepEqual(playUrls, []); + assert.equal(state.selected?.id, ids[1]); + assert.equal(state.audioState, 'stopped'); + assert.equal(state.tuningStatic, false); + + await radioLayer.togglePlayback({ origin: 'user' }); + assert.equal(radioLayer.beginTuning(), true); + radioLayer.previewTuningStation(ids[2], { rotate: false }); + radioLayer.commitTuningStation(ids[2], { origin: 'user' }); + await settle(); + state = radioLayer.getUIState(); + assert.deepEqual(playUrls, [stations[1].streamUrl, stations[2].streamUrl]); + assert.equal(state.audioState, 'playing'); + assert.equal(state.playingStationId, ids[2]); + }); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index ac27e1278..658167d34 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -562,7 +562,7 @@ "Radio volume": "مستوى صوت الراديو", "Radio off": "الراديو متوقف", "STATION SITE": "موقع المحطة", - "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "يتصل التطبيق مباشرةً بمحطة البث عند الضغط على «تشغيل» أو إنهاء الضبط أو اختيار محطة على الكرة الأرضية، فيظهر لها عنوان IP الخاص بك.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "يتصل التطبيق مباشرةً بمحطة البث عند الضغط على «تشغيل» أو اختيار محطة على الكرة الأرضية أو طلب التشغيل بالصوت، فيظهر لها عنوان IP الخاص بك.", "LOADING": "جارٍ التحميل", "DEGRADED": "متدهور", "STALE": "متقادم", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index e17c3b165..c70d3378e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -777,7 +777,7 @@ "Stop radio playback": "Radiowiedergabe stoppen", "Radio volume": "Radio-Lautstärke", "STATION SITE": "SENDER-WEBSITE", - "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "Beim Starten der Wiedergabe, nach dem Abstimmen oder bei der Wahl eines Senders auf dem Globus wird eine direkte Verbindung zum Sender hergestellt. Die IP-Adresse ist für diesen Sender sichtbar.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Beim Starten der Wiedergabe, bei der Wahl eines Senders auf dem Globus oder auf Wunsch per Sprachsteuerung wird eine direkte Verbindung zum Sender hergestellt. Die IP-Adresse ist für diesen Sender sichtbar.", "Intelligence HUD (H)": "Lagebild-HUD (H)", "Layout": "Layout", "HUD layout": "HUD-Layout", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 6a733a147..d45b252d7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -450,7 +450,7 @@ "Radio volume": "Volumen de Radio", "Radio off": "Radio desactivada", "STATION SITE": "SITIO DE LA EMISORA", - "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora al pulsar ESCUCHAR, terminar de sintonizar o elegir una emisora en el globo. Tu IP es visible para esa emisora.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora al pulsar ESCUCHAR, elegir una emisora en el globo o pedirlo por voz. Tu IP es visible para esa emisora.", "LOADING": "CARGANDO", "DEGRADED": "DEGRADADO", "STALE": "DESACTUALIZADO", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 39355732a..a869e77ac 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2819,7 +2819,7 @@ "Radio volume": "Volume de la radio", "STATION SITE": "SITE DE LA STATION", "DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, terminez le réglage ou choisissez une station sur le globe. Votre IP est visible par ce diffuseur.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.", "Local RTL-SDR receiver": "Récepteur RTL-SDR local", "LOCAL RTL-SDR": "RTL-SDR LOCAL", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 027e210b0..a376a8865 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2819,7 +2819,7 @@ "Radio volume": "Volume radio", "STATION SITE": "SITO STAZIONE", "DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play, terminato la sintonizzazione o scelto una stazione sul globo. Il tuo IP è visibile a quell'emittente.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play, scelto una stazione sul globo o chiesto la riproduzione a voce. Il tuo IP è visibile a quell'emittente.", "Local RTL-SDR receiver": "Ricevitore RTL-SDR locale", "LOCAL RTL-SDR": "RTL-SDR LOCALE", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizzatore locale condiviso tra FM e il livello ADS-B locale.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7f6a53c2b..c00780d9e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2677,7 +2677,7 @@ "Radio volume": "ラジオの音量", "STATION SITE": "放送局サイト", "DIRECTORY: RADIO BROWSER": "ディレクトリ:Radio Browser", - "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "再生ボタンを押す、選局を終える、または地球儀で局を選ぶと、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "再生ボタンを押す、地球儀で局を選ぶ、または音声コントロールで再生を指示すると、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。", "Local RTL-SDR receiver": "ローカルRTL-SDR受信機", "LOCAL RTL-SDR": "ローカルRTL-SDR", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · FMとローカルADS-Bレイヤーで1台のローカルチューナーを共有します。", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 8f75df8a1..77d33514a 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2821,7 +2821,7 @@ "Radio volume": "Volume do rádio", "STATION SITE": "SITE DA ESTAÇÃO", "DIRECTORY: RADIO BROWSER": "DIRETÓRIO: RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora quando você aperta \"Ouvir\", termina de sintonizar ou escolhe uma estação no globo. Seu IP fica visível para essa emissora.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora quando você aperta \"Ouvir\", escolhe uma estação no globo ou pede por voz. Seu IP fica visível para essa emissora.", "Local RTL-SDR receiver": "Receptor RTL-SDR local", "LOCAL RTL-SDR": "RTL-SDR LOCAL", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · um sintonizador local compartilhado entre o FM e a camada ADS-B local.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index a397e6575..2f42ed522 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -727,7 +727,7 @@ "STATION TAG": "ТЕГ СТАНЦИИ", "DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ПРОСМОТР · ENTER / SPACE — ВЫБОР", - "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "Звук идёт напрямую от вещателя после нажатия «Пуск», завершения настройки или выбора станции на глобусе. Вещателю виден ваш IP-адрес.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Звук идёт напрямую от вещателя после нажатия «Пуск», выбора станции на глобусе или голосовой команды. Вещателю виден ваш IP-адрес.", "Internet radio and local SDR": "Интернет-радио и локальный SDR", "Filter stations by station tag": "Фильтровать станции по тегу", "CONTEXT": "КОНТЕКСТ", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index daeb655e7..981846c4f 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -2889,7 +2889,7 @@ "Radio volume": "Гучність радіо", "STATION SITE": "САЙТ СТАНЦІЇ", "DIRECTORY: RADIO BROWSER": "КАТАЛОГ: RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "Звук надходить безпосередньо від мовника після натискання «Відтворити», завершення налаштування або вибору станції на глобусі. Мовник бачить вашу IP-адресу.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Звук надходить безпосередньо від мовника після натискання «Відтворити», вибору станції на глобусі або голосової команди. Мовник бачить вашу IP-адресу.", "Local RTL-SDR receiver": "Локальний приймач RTL-SDR", "LOCAL RTL-SDR": "ЛОКАЛЬНИЙ RTL-SDR", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · один локальний тюнер для FM і шару «Локальний ADS-B».", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 14139e4e5..a91e4a359 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2575,7 +2575,7 @@ "Radio volume": "电台音量", "STATION SITE": "电台网站", "DIRECTORY: RADIO BROWSER": "目录:RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "点击播放、完成调谐或在地球上选择电台后,音频将直接连接到广播方。广播方可以看到你的 IP。", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "点击播放、在地球上选择电台或通过语音要求播放后,音频将直接连接到广播方。广播方可以看到你的 IP。", "Local RTL-SDR receiver": "本地 RTL-SDR 接收机", "LOCAL RTL-SDR": "本地 RTL-SDR", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一个本地调谐器由 FM 与本地 ADS-B 图层共享。", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c1e7bbb38..8ae31a1c8 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2563,7 +2563,7 @@ "Radio volume": "電臺音量", "STATION SITE": "電臺網站", "DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.": "點擊播放、完成調諧或在地球上選擇電臺後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "點擊播放、在地球上選擇電臺或通過語音要求播放後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。", "Local RTL-SDR receiver": "本地 RTL-SDR 接收機", "LOCAL RTL-SDR": "本地 RTL-SDR", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本地調諧器由 FM 與本地 ADS-B 圖層共享。", diff --git a/src/layers/radio/selection.js b/src/layers/radio/selection.js index 9359cb3e4..b09a8c19a 100644 --- a/src/layers/radio/selection.js +++ b/src/layers/radio/selection.js @@ -140,7 +140,7 @@ export function createSelection({ if (focus) parts.navigation.focusStation(station); parts.presentation.emitState(); if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId }); - // A user step to another station overrides a playback voice prepared. + // A user step to another station cancels a playback voice prepared. else if (origin === 'user' && changed) parts.presentation.emitPlaybackControl('select', origin); return true; diff --git a/src/layers/radio/state.js b/src/layers/radio/state.js index e1436f1bb..a420c165e 100644 --- a/src/layers/radio/state.js +++ b/src/layers/radio/state.js @@ -78,6 +78,8 @@ export function createState({ services }) { layerState._tuningStartStationId = null; + layerState._tuningResumesPlayback = false; + layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; layerState._tuningStationById = new Map(); diff --git a/src/layers/radio/tuning.js b/src/layers/radio/tuning.js index ed344d2c0..d9903b1a2 100644 --- a/src/layers/radio/tuning.js +++ b/src/layers/radio/tuning.js @@ -66,6 +66,12 @@ export function createTuning({ state: layerState, services, parts, source }) { layerState._cancelledTuningPresentationStation = null; layerState._tuningStartStationId = layerState._selectedId; layerState._tuningPreviewId = layerState._selectedId; + // Like Previous/Next, the release plays only if a stream was active. + layerState._tuningResumesPlayback = [ + 'loading', + 'buffering', + 'playing', + ].includes(layerState._audioState); parts.tuningNoise.installTuningNoise(); parts.playback.pauseRadioPlayback({ origin: 'user' }); parts.tuningNoise.syncTuningNoiseGain(); @@ -179,9 +185,8 @@ 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. + // The drag paused the stream; with no selection, Resume would start the + // nearest station. Stop also publishes the state and cancels a voice handoff. parts.playback.stopRadioPlayback({ origin }); return Object.freeze({ ok: false, @@ -190,9 +195,11 @@ export function createTuning({ state: layerState, services, parts, source }) { generation, }); } + const autoplay = layerState._tuningResumesPlayback; + layerState._tuningResumesPlayback = false; layerState._tuningActive = false; - layerState._tuningStatic = true; - layerState._tuningAwaitingStationId = frozenStation.id; + layerState._tuningStatic = autoplay; + layerState._tuningAwaitingStationId = autoplay ? frozenStation.id : null; layerState._tuningPreviewId = null; layerState._tuningStartStationId = null; layerState._tuningUnavailableStationId = null; @@ -210,7 +217,7 @@ export function createTuning({ state: layerState, services, parts, source }) { parts.tuningNoise.syncTuningNoiseGain(); parts.presentation.emitState(); const selected = parts.selection.selectRadioStation(frozenStation.id, { - autoplay: true, + autoplay, focus: false, origin, cameraNavigation, diff --git a/src/testSupport/radioLayerHarness.mjs b/src/testSupport/radioLayerHarness.mjs index 30b9e2a86..45493357c 100644 --- a/src/testSupport/radioLayerHarness.mjs +++ b/src/testSupport/radioLayerHarness.mjs @@ -1,7 +1,5 @@ -/** 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', diff --git a/src/ui/views/panels/RadioPanel.vue b/src/ui/views/panels/RadioPanel.vue index 01e4114eb..67a8f7543 100644 --- a/src/ui/views/panels/RadioPanel.vue +++ b/src/ui/views/panels/RadioPanel.vue @@ -67,7 +67,7 @@ const radioWords = computed(() => ({ {{ t('STATION SITE') }} {{ t('DIRECTORY: RADIO BROWSER') }}
-

{{ t('Audio connects directly to the broadcaster when you press play, finish tuning or choose a station on the globe. Your IP is visible to that broadcaster.') }}

+

{{ t('Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.') }}

From cfc83a607842dda14dde702713a374137fa83b04 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 17:19:53 +0400 Subject: [PATCH 8/9] Release the tuner noise graph after a silent dial release A release that only selects a station now stops the looping noise source and suspends its AudioContext. The qa-radio dial scenarios start from an active stream, or expect a silent release from a stopped Radio. --- scripts/qa-radio.mjs | 14 ++++++++-- src/data/radioNextState.test.mjs | 47 ++++++++++++++++++++++++++++++++ src/layers/radio/tuning.js | 4 ++- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 8e86de86d..83b359605 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -3073,20 +3073,27 @@ async function main() { expectedSlot: targetIndex, previewPlayDelta: previewPlays - playsBefore, commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore, + audioState: state.audioState, + tuningStatic: state.tuningStatic, samples, spread: Math.max(...samples) - Math.min(...samples), }; }); check( - 'pointer preview never plays and release commits the nearest station once without a needle sweep', + 'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep', tunerDirectRelease.targetId && tunerDirectRelease.selectedId === tunerDirectRelease.targetId && tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot && tunerDirectRelease.previewPlayDelta === 0 - && tunerDirectRelease.commitPlayDelta === 1 + && tunerDirectRelease.commitPlayDelta === 0 + && tunerDirectRelease.audioState === 'stopped' + && !tunerDirectRelease.tuningStatic && tunerDirectRelease.spread < 0.5, JSON.stringify(tunerDirectRelease), ); + // The release plays only if a stream was active when the drag began. + await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' })); + await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); // Earlier cases scroll and resize the directory. Bring the physical drag // target back into its scroller before deriving viewport mouse coordinates. await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' })); @@ -3494,6 +3501,9 @@ async function main() { }); }); await sleep(500); + // Next left the Radio stopped; a release plays only from an active stream. + await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' })); + await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); const failedTuner = await page.evaluate(async () => { const gev = window.__godsEyeView; const slider = document.getElementById('radio-tuner-slider'); diff --git a/src/data/radioNextState.test.mjs b/src/data/radioNextState.test.mjs index 3f9b2c6f6..2c52b4bbc 100644 --- a/src/data/radioNextState.test.mjs +++ b/src/data/radioNextState.test.mjs @@ -271,3 +271,50 @@ test('a dial release plays only if a stream was active when the drag began', asy assert.equal(state.playingStationId, ids[2]); }); }); + +// Records the tuner noise graph so a test can see whether it was released. +function withFakeAudioContext(body) { + const log = { contexts: [], sources: [] }; + const node = () => ({ connect() {}, disconnect() {} }); + class FakeAudioContext { + constructor() { + Object.assign(this, { state: 'suspended', sampleRate: 8000, currentTime: 0, destination: {} }); + log.contexts.push(this); + } + resume() { this.state = 'running'; return Promise.resolve(); } + suspend() { this.state = 'suspended'; return Promise.resolve(); } + close() { this.state = 'closed'; return Promise.resolve(); } + createBuffer(_channels, length) { return { getChannelData: () => new Float32Array(length) }; } + createBufferSource() { + const source = { ...node(), started: false, stopped: false, start() { this.started = true; }, stop() { this.stopped = true; } }; + log.sources.push(source); + return source; + } + createBiquadFilter() { return { ...node(), frequency: { value: 0 }, Q: { value: 0 } }; } + createGain() { + return { ...node(), gain: { value: 0, cancelScheduledValues() {}, setValueAtTime() {}, linearRampToValueAtTime() {} } }; + } + } + const original = globalThis.AudioContext; + globalThis.AudioContext = FakeAudioContext; + return Promise.resolve(body(log)).finally(() => { + if (original === undefined) delete globalThis.AudioContext; + else globalThis.AudioContext = original; + }); +} + +test('a silent dial release stops the tuner noise and suspends its audio context', async () => { + await withFakeAudioContext(async ({ contexts, sources }) => { + await withLayer([], async ({ playUrls }) => { + radioLayer.selectStation(ids[0], { autoplay: false }); + assert.equal(radioLayer.beginTuning(), true); + radioLayer.previewTuningStation(ids[1], { rotate: false }); + assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true); + await settle(); + assert.deepEqual(playUrls, []); + assert.equal(sources[0].started, true); + assert.equal(sources[0].stopped, true, 'noise source still looping'); + assert.equal(contexts[0].state, 'suspended', 'audio context still running'); + }); + }); +}); diff --git a/src/layers/radio/tuning.js b/src/layers/radio/tuning.js index d9903b1a2..53d5637e0 100644 --- a/src/layers/radio/tuning.js +++ b/src/layers/radio/tuning.js @@ -214,7 +214,9 @@ export function createTuning({ state: layerState, services, parts, source }) { layerState._playFallbackFocus = null; layerState._playFallbackOrigin = 'programmatic'; layerState._playFallbackAttemptId = null; - parts.tuningNoise.syncTuningNoiseGain(); + // Static covers only a connecting stream; a silent release frees the noise graph. + if (autoplay) parts.tuningNoise.syncTuningNoiseGain(); + else parts.tuningNoise.clearRadioTuningNoise({ emit: false }); parts.presentation.emitState(); const selected = parts.selection.selectRadioStation(frozenStation.id, { autoplay, From 81ffa5b6637b90ad52c3535c5d7f399403d4ac15 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 14:32:55 +0400 Subject: [PATCH 9/9] Suspend the tuner noise context once a pending resume settles A dial click shorter than the device start-up let suspend() run on a still-suspended context, so the later resume() left it running with no nodes. Cover paused, connecting and voice-fallback releases in radioNextState tests. --- docs/CURRENT-STATE.md | 10 +-- src/data/radioNextState.test.mjs | 107 +++++++++++++++++++++++++++++-- src/layers/radio/selection.js | 2 +- src/layers/radio/tuning.js | 2 +- src/layers/radio/tuningNoise.js | 26 ++++++-- 5 files changed, 131 insertions(+), 16 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2a7e0d827..bbbd039d0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4703,11 +4703,11 @@ ownership tokens plus the session boundary keep late callbacks from a retired session inert. A tuner drag resolves previews against the single accepted snapshot captured at pointer-down, even while a newer catalog is admitted. Like Previous/Next, -release starts playback only if a stream was active when the drag began, and -only when the current same-ID record still exactly matches the frozen -presentation and stream metadata; otherwise it just selects the station. Removal -or replacement reports the channel as unavailable and never silently retargets -the drag. A cold degraded fallback may +release starts playback only if a stream was active when the drag began; +otherwise it just selects the station. It commits only when the current same-ID +record still exactly matches the frozen presentation and stream metadata. +Removal or replacement reports the channel as unavailable and never silently +retargets the drag. A cold degraded fallback may still populate the directory and globe, but its null accepted generation cannot populate or begin the tuner. diff --git a/src/data/radioNextState.test.mjs b/src/data/radioNextState.test.mjs index 2c52b4bbc..ac1f82734 100644 --- a/src/data/radioNextState.test.mjs +++ b/src/data/radioNextState.test.mjs @@ -272,7 +272,10 @@ test('a dial release plays only if a stream was active when the drag began', asy }); }); -// Records the tuner noise graph so a test can see whether it was released. +// Like Chrome, resume() flips `state` only once the device starts, and suspend() +// on a context that is still suspended resolves at once without effect. +const RESUME_MS = 5; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function withFakeAudioContext(body) { const log = { contexts: [], sources: [] }; const node = () => ({ connect() {}, disconnect() {} }); @@ -281,8 +284,17 @@ function withFakeAudioContext(body) { Object.assign(this, { state: 'suspended', sampleRate: 8000, currentTime: 0, destination: {} }); log.contexts.push(this); } - resume() { this.state = 'running'; return Promise.resolve(); } - suspend() { this.state = 'suspended'; return Promise.resolve(); } + resume() { + if (this.state === 'running') return Promise.resolve(); + return new Promise((resolve) => setTimeout(() => { + if (this.state !== 'closed') this.state = 'running'; + resolve(); + }, RESUME_MS)); + } + suspend() { + if (this.state === 'running') this.state = 'suspended'; + return Promise.resolve(); + } close() { this.state = 'closed'; return Promise.resolve(); } createBuffer(_channels, length) { return { getChannelData: () => new Float32Array(length) }; } createBufferSource() { @@ -303,14 +315,14 @@ function withFakeAudioContext(body) { }); } -test('a silent dial release stops the tuner noise and suspends its audio context', async () => { +test('a silent dial release stops the tuner noise and suspends its audio context, even before resume() settles', async () => { await withFakeAudioContext(async ({ contexts, sources }) => { await withLayer([], async ({ playUrls }) => { radioLayer.selectStation(ids[0], { autoplay: false }); assert.equal(radioLayer.beginTuning(), true); radioLayer.previewTuningStation(ids[1], { rotate: false }); assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true); - await settle(); + await sleep(RESUME_MS * 4); assert.deepEqual(playUrls, []); assert.equal(sources[0].started, true); assert.equal(sources[0].stopped, true, 'noise source still looping'); @@ -318,3 +330,88 @@ test('a silent dial release stops the tuner noise and suspends its audio context }); }); }); + +test('a second gesture before the first resume() settles keeps its noise graph', async () => { + await withFakeAudioContext(async ({ contexts, sources }) => { + await withLayer([], async () => { + radioLayer.selectStation(ids[0], { autoplay: false }); + radioLayer.beginTuning(); + radioLayer.commitTuningStation(ids[0], { origin: 'user' }); + assert.equal(radioLayer.beginTuning(), true); + await sleep(RESUME_MS * 4); + assert.equal(contexts[0].state, 'running', 'second gesture lost its context'); + assert.equal(sources.at(-1).stopped, false, 'second gesture lost its source'); + radioLayer.commitTuningStation(ids[1], { origin: 'user' }); + await sleep(RESUME_MS * 4); + assert.equal(sources.at(-1).stopped, true); + assert.equal(contexts[0].state, 'suspended'); + }); + }); +}); + +test('a dial release from a paused stream only selects, without static', async () => { + await withFakeAudioContext(async ({ contexts }) => { + await withLayer([], async ({ playUrls }) => { + radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' }); + await settle(); + await radioLayer.togglePlayback({ origin: 'user' }); + assert.equal(radioLayer.getUIState().audioState, 'paused'); + assert.equal(radioLayer.beginTuning(), true); + await sleep(RESUME_MS * 4); + radioLayer.previewTuningStation(ids[1], { rotate: false }); + radioLayer.commitTuningStation(ids[1], { origin: 'user' }); + await sleep(RESUME_MS * 4); + const state = radioLayer.getUIState(); + assert.deepEqual(playUrls, [stations[0].streamUrl]); + assert.equal(state.selected?.id, ids[1]); + assert.equal(state.audioState, 'stopped'); + assert.equal(state.tuningStatic, false); + assert.equal(state.tuningAwaitingStationId, null); + assert.equal(contexts[0].state, 'suspended'); + }); + }); +}); + +test('a dial release while a stream is still connecting plays the new station', async () => { + await withLayer([], async ({ playUrls }) => { + radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' }); + assert.equal(radioLayer.getUIState().audioState, 'loading'); + assert.equal(radioLayer.beginTuning(), true); + radioLayer.previewTuningStation(ids[1], { rotate: false }); + radioLayer.commitTuningStation(ids[1], { origin: 'user' }); + await settle(); + const state = radioLayer.getUIState(); + assert.equal(playUrls.at(-1), stations[1].streamUrl); + assert.equal(state.audioState, 'playing'); + assert.equal(state.playingStationId, ids[1]); + }); +}); + +test('a silent user step drops the fallback a voice step left behind', async () => { + await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => { + radioLayer.selectStation(ids[0], { autoplay: false }); + // Voice steps to B and keeps C as its fallback; the user steps back to A. + radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' }); + radioLayer.cycleStation(-1, { stationIds: ids, autoplay: false, origin: 'user' }); + await radioLayer.play({ origin: 'voice' }); + await settle(); + await settle(); + assert.deepEqual(playUrls, [stations[0].streamUrl]); + assert.equal(radioLayer.getUIState().audioState, 'error'); + }); +}); + +test('a silent dial release drops the fallback a voice step left behind', async () => { + await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => { + radioLayer.selectStation(ids[0], { autoplay: false }); + radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' }); + assert.equal(radioLayer.beginTuning(), true); + radioLayer.previewTuningStation(ids[0], { rotate: false }); + radioLayer.commitTuningStation(ids[0], { origin: 'user' }); + await radioLayer.play({ origin: 'voice' }); + await settle(); + await settle(); + assert.deepEqual(playUrls, [stations[0].streamUrl]); + assert.equal(radioLayer.getUIState().audioState, 'error'); + }); +}); diff --git a/src/layers/radio/selection.js b/src/layers/radio/selection.js index b09a8c19a..2b2ba282a 100644 --- a/src/layers/radio/selection.js +++ b/src/layers/radio/selection.js @@ -181,7 +181,7 @@ export function createSelection({ current < 0 ? 0 : (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length; - // Voice defers playback, so its silent step needs a fallback; UI steps do not. + // Voice defers playback, so its silent step keeps a fallback; silent UI steps do not. const fallbackId = fallback && ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id diff --git a/src/layers/radio/tuning.js b/src/layers/radio/tuning.js index 53d5637e0..015e79a15 100644 --- a/src/layers/radio/tuning.js +++ b/src/layers/radio/tuning.js @@ -214,7 +214,7 @@ export function createTuning({ state: layerState, services, parts, source }) { layerState._playFallbackFocus = null; layerState._playFallbackOrigin = 'programmatic'; layerState._playFallbackAttemptId = null; - // Static covers only a connecting stream; a silent release frees the noise graph. + // Muting alone would leave the noise graph running after a silent release. if (autoplay) parts.tuningNoise.syncTuningNoiseGain(); else parts.tuningNoise.clearRadioTuningNoise({ emit: false }); parts.presentation.emitState(); diff --git a/src/layers/radio/tuningNoise.js b/src/layers/radio/tuningNoise.js index dfbc3fe3b..ab151246f 100644 --- a/src/layers/radio/tuningNoise.js +++ b/src/layers/radio/tuningNoise.js @@ -50,6 +50,11 @@ export function createTuningNoise({ layerState._tuningNoiseGain = null; } + function suspendTuningNoiseContext() { + const suspended = layerState._tuningNoiseContext?.suspend?.(); + if (suspended?.catch) void suspended.catch(() => {}); + } + function syncTuningNoiseGain() { if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return; const audible = radioTuningStaticShouldPlay({ @@ -79,8 +84,21 @@ export function createTuningNoise({ if (!AudioContextClass) return false; if (!layerState._tuningNoiseContext) layerState._tuningNoiseContext = new AudioContextClass(); - const resumed = layerState._tuningNoiseContext.resume?.(); - if (resumed?.catch) void resumed.catch(() => {}); + const context = layerState._tuningNoiseContext; + const resumed = context.resume?.(); + // A release before the device starts finds the context still suspended, + // so its suspend() is a no-op; suspend again once resume() settles. + if (resumed?.then) + void resumed.then( + () => { + if ( + layerState._tuningNoiseContext === context && + !layerState._tuningNoiseSource + ) + suspendTuningNoiseContext(); + }, + () => {}, + ); if (layerState._tuningNoiseSource) return true; const frameCount = Math.max( @@ -120,6 +138,7 @@ export function createTuningNoise({ if (layerState._tuningCameraNavigation) parts.navigation.invalidateRadioCameraNavigation(); layerState._tuningActive = false; + layerState._tuningResumesPlayback = false; layerState._tuningStatic = false; layerState._tuningAwaitingStationId = null; layerState._tuningPreviewId = null; @@ -131,8 +150,7 @@ export function createTuningNoise({ parts.rendering.updateSelectionEntity(); syncTuningNoiseGain(); stopTuningNoiseSource(); - const suspended = layerState._tuningNoiseContext?.suspend?.(); - if (suspended?.catch) void suspended.catch(() => {}); + suspendTuningNoiseContext(); if (emit) parts.presentation.emitState(); } return {