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

+3 -1
View File
@@ -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).
+7 -4
View File
@@ -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.
+2 -1
View File
@@ -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);
+395
View File
@@ -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, []);
});
});
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "КОНТЕКСТ",
+1 -1
View File
@@ -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».",
+1 -1
View File
@@ -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 图层共享。",
+1 -1
View File
@@ -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 圖層共享。",
+6 -1
View File
@@ -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;
+40 -12
View File
@@ -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(
+4 -1
View File
@@ -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',
+2 -3
View File
@@ -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,
+1 -1
View File
@@ -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();
+3 -1
View File
@@ -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');
+1 -1
View File
@@ -162,7 +162,7 @@
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden data-i18n>STATION SITE</a>
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer" data-i18n>DIRECTORY: RADIO BROWSER</a>
</div>
<p class="radio-privacy" data-i18n>Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
<p class="radio-privacy" data-i18n>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.</p>
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver" data-i18n-aria-label>
<div class="sdr-radio-heading">
<strong data-i18n>LOCAL RTL-SDR</strong>
+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 (