From 1ef9be22485b22012b630567cf0173054df3a971 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:36:02 +0400 Subject: [PATCH] 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(); + } +});