From 5ed86200e593f62e45729d412320472d4cff9924 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 16:44:15 +0400 Subject: [PATCH] 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');