From 4dd5a313b39877d354e81241e168d04801ca073e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 13:44:48 +0400 Subject: [PATCH] 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 (