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.
This commit is contained in:
German Khokhlov committed 2026-10-30 13:44:48 +04:00
1 parent b775bfed70
commit 4dd5a313b3
20 files changed
+523 -71

No files matched your search

+6 -1
View File
@@ -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),
+1 -1
View File
@@ -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 },
+46 -37
View File
@@ -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 = [];
+1 -1
View File
@@ -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 (