Trim the Radio Next/Previous change to the HAL-722 scope

- Selections take the playback fallback as a `playFallback` option and
  set it after releasing a stale stream, instead of callers arming state
  that the release then had to save and restore.
- Move the tuner `station-unavailable` release and the Pause label while
  a stream connects out of this branch: both were wrong on main already
  and go in a separate change.
- Share the Radio layer test setup in src/testSupport/radioLayerHarness.mjs
  and fold overlapping Next/Previous tests (13 to 7).
- One-line comments and a two-line changelog entry.
This commit is contained in:
German Khokhlov committed 2026-10-30 16:37:36 +04:00
1 parent fa840ae3c3
commit a55450e2e7
10 files changed
+172 -340

No files matched your search

+1 -4
View File
@@ -1,5 +1,2 @@
- 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. 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).
pressed; while a stream plays, it follows the new station (gekh, #1474).
+1 -2
View File
@@ -4774,8 +4774,7 @@ 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, a user Previous/Next that only selects another station, and a tuner
release onto a station the directory no longer has.
user Pause and a user Previous/Next that only selects another station.
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.
+10 -73
View File
@@ -51,6 +51,7 @@ import {
radioViewIsGlobal,
stationMatchesRadioCategory,
} from './radio.js';
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
const stations = [
{ id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] },
@@ -883,83 +884,24 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele
});
test('failed exact tuner release cannot consume a stale playback fallback', async () => {
const originalFetch = globalThis.fetch;
const originalAudio = globalThis.Audio;
const playUrls = [];
const stationRows = [
{
radioStationRow({
id: '00000000-0000-4000-8000-000000000071',
name: 'Exact target',
lat: 30,
lon: -97,
streamUrl: 'https://radio.example.com/exact.mp3',
homepage: null,
tags: ['news'],
languages: ['English'],
state: 'Texas',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
},
{
}),
radioStationRow({
id: '00000000-0000-4000-8000-000000000072',
name: 'Stale fallback',
lat: 31,
lon: -98,
streamUrl: 'https://radio.example.com/fallback.mp3',
homepage: null,
tags: ['news'],
languages: ['English'],
state: 'Texas',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
},
}),
];
globalThis.Audio = class FakeAudio {
constructor() {
this.volume = 0.8;
this.src = '';
}
addEventListener() {}
pause() {}
play() {
playUrls.push(this.src);
if (this.src === stationRows[0].streamUrl) {
return Promise.reject(new DOMException('Exact target 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: stationRows,
stale: false,
degraded: false,
acceptedGeneration: 1,
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 withRadioLayer({
stations: stationRows,
failingUrls: [stationRows[0].streamUrl],
}, async ({ playUrls }) => {
assert.equal(radioLayer.selectStation(stationRows[0].id, { autoplay: false }), true);
assert.equal(radioLayer.cycleStation(1, {
stationIds: stationRows.map(({ id }) => id),
@@ -976,12 +918,7 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn
assert.equal(state.audioState, 'error');
assert.equal(state.tuningStatic, true);
assert.equal(state.tuningAwaitingStationId, stationRows[0].id);
} finally {
globalThis.fetch = originalFetch;
if (originalAudio === undefined) delete globalThis.Audio;
else globalThis.Audio = originalAudio;
radioLayer.destroy();
}
});
});
test('tuner cancellation restores the frozen start marker after catalog removal', async () => {
+34 -230
View File
@@ -5,79 +5,18 @@ import test from 'node:test';
import { radioLayer } from './radio.js';
import { setLocale } from '../i18n/index.js';
import { renderRadioState } from '../ui/radioPresentation.js';
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
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) => ({
const stations = ids.map((id, index) => radioStationRow({
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 withLayer = (failingUrls, body) => withRadioLayer({ stations, failingUrls }, body);
const settle = () => new Promise((resolve) => setTimeout(resolve));
// What the Radio panel and the Cockpit player show for a state.
@@ -125,24 +64,27 @@ function render(state) {
}
}
test('Next before Play selects without starting a stream, and Play starts the new station', async () => {
await withLayer([], async (playUrls) => {
const readyToPlay = { status: READY, error: false, button: 'PLAY', stopDisabled: true, cockpit: '▶' };
test('Next before Play only selects, and Play tries just that station', async () => {
await withLayer([stations[1].streamUrl], 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' });
await settle();
const state = radioLayer.getUIState();
assert.deepEqual(playUrls, [stationRows[1].streamUrl]);
assert.equal(state.audioState, 'playing');
assert.equal(state.playingStationId, ids[1]);
assert.deepEqual(playUrls, [stations[1].streamUrl], 'no fallback picked by the silent step');
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'error');
});
});
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) => {
await withLayer([stations[0].streamUrl], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
assert.equal(radioLayer.getUIState().audioState, 'error');
@@ -151,22 +93,16 @@ test('Next after a failed stream leaves the new station ready to play, without t
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,
cockpit: '▶',
});
assert.deepEqual(render(state), readyToPlay);
await radioLayer.togglePlayback({ origin: 'user' });
assert.deepEqual(playUrls, [stationRows[0].streamUrl, stationRows[1].streamUrl]);
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[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) => {
await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
await radioLayer.togglePlayback({ origin: 'user' });
@@ -176,15 +112,9 @@ test('Next while paused releases the paused stream, and Play starts the new stat
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,
cockpit: '▶',
});
assert.deepEqual(render(state), readyToPlay);
await radioLayer.togglePlayback({ origin: 'user' });
assert.deepEqual(playUrls, [stationRows[0].streamUrl, stationRows[1].streamUrl]);
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
assert.equal(radioLayer.getUIState().audioState, 'playing');
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
});
@@ -192,12 +122,12 @@ test('Next while paused releases the paused stream, and Play starts the new stat
test('a catalog refresh after a paused Next cannot bring back Resume for an unnamed station', async () => {
setLocale('en');
await withLayer([], async (_playUrls, catalog) => {
await withLayer([], async ({ 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.rows = stations.filter(({ id }) => id !== ids[1]);
catalog.generation = 2;
await radioLayer.update();
const state = radioLayer.getUIState();
@@ -209,71 +139,9 @@ test('a catalog refresh after a paused Next cannot bring back Resume for an unna
});
});
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.cockpit, 'Ⅱ');
});
test('a voice station request after a voice pause keeps the fallback of that request', async () => {
const failing = [];
await withLayer(failing, async (playUrls) => {
await withLayer(failing, async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
await settle();
radioLayer.pause({ origin: 'voice-duck' });
@@ -293,98 +161,34 @@ test('a voice station request after a voice pause keeps the fallback of that req
});
});
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' });
test('a cancelled voice step leaves no fallback for a later Play from the panel', async () => {
await withLayer([stations[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();
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');
assert.deepEqual(playUrls, [stations[1].streamUrl]);
assert.equal(state.selected?.id, ids[1]);
assert.equal(state.audioState, 'error');
});
});
test('a user step that does not play tells voice to drop its prepared playback', async () => {
test('only a user step to another station 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' });
radioLayer.cycleStation(1, { stationIds: [ids[1]], autoplay: false, origin: 'user' });
} finally {
unsubscribe();
}
assert.equal(radioLayer.getUIState().selected?.id, ids[1]);
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, []);
});
});
+2 -4
View File
@@ -345,18 +345,16 @@ export function createPlayback({ state: layerState, services, parts, source }) {
if (!parts.interaction.radioPresentationAllowed())
return Promise.resolve(false);
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.
// Drop a fallback left by a voice step whose playback 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;
layerState._playFallbackFocus = false;
parts.selection.selectRadioStation(ranked[0].id, {
autoplay: false,
focus: false,
playFallback: { id: ranked[1]?.id || null, focus: false },
});
}
return playSelectedRadio({ origin });
+20 -18
View File
@@ -73,22 +73,14 @@ 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.
// A paused or failed stream of another station would outlive its selection.
function releaseStaleRadioStream(stationId) {
if (
!layerState._audioStationId ||
layerState._audioStationId === stationId ||
!['paused', 'error'].includes(layerState._audioState)
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;
parts.playback.stopRadioPlayback();
}
/** Select a station. Playback occurs only when autoplay is explicitly true. */
@@ -101,12 +93,18 @@ export function createSelection({
origin = 'programmatic',
attemptId = null,
cameraNavigation = null,
playFallback = null,
} = {},
) {
if (!parts.interaction.radioPresentationAllowed()) return false;
const station = layerState._stationById.get(String(id));
if (!station) return false;
if (!autoplay) releaseStaleRadioStream(station.id);
// Set after the release, which clears fallbacks.
if (playFallback) {
layerState._playFallbackId = playFallback.id;
layerState._playFallbackFocus = playFallback.focus;
}
layerState._tuningUnavailableStationId = null;
if (
!parts.navigation.radioCameraNavigationOwnsSelection(
@@ -184,7 +182,7 @@ export function createSelection({
? 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 =
const fallbackId =
fallback && ranked.length > 1
? ranked[(nextIndex + 1) % ranked.length].id
: null;
@@ -194,7 +192,7 @@ export function createSelection({
const rotationNavigation = rotate
? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
: null;
layerState._playFallbackFocus =
const fallbackFocus =
fallback && rotate
? (fallbackStation) => {
parts.navigation.rotateRadioStationIntoView(
@@ -219,6 +217,7 @@ export function createSelection({
focus: false,
origin,
cameraNavigation: rotationNavigation,
playFallback: { id: fallbackId, focus: fallbackFocus },
});
}
@@ -245,9 +244,12 @@ export function createSelection({
},
);
if (!ranked.length) return null;
layerState._playFallbackId = ranked[1]?.id || null;
layerState._playFallbackFocus = false;
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
selectRadioStation(ranked[0].id, {
autoplay,
focus: false,
origin,
playFallback: { id: ranked[1]?.id || null, focus: false },
});
return ranked[0];
}
+1 -4
View File
@@ -179,10 +179,7 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._selectionGeneration += 1;
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
layerState._tuningUnavailableStationId = stationId;
// 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 });
parts.presentation.emitState();
return Object.freeze({
ok: false,
reason: 'station-unavailable',
+101
View File
@@ -0,0 +1,101 @@
/** Shared Radio layer test stand-ins: a station row, fake Audio, directory fetch and viewer. */
import { radioLayer } from '../data/radio.js';
/** One accepted Radio Browser directory row. */
export function radioStationRow(overrides = {}) {
return {
id: '00000000-0000-4000-8000-000000000001',
name: 'Station',
lat: 30,
lon: -97,
streamUrl: 'https://radio.example.com/station.mp3',
homepage: null,
tags: ['news'],
languages: ['English'],
state: 'Texas',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
...overrides,
};
}
/**
* Run `body` against an enabled `radioLayer` that serves `stations`.
* Streams listed in `failingUrls` (mutable) reject play(); `body` receives the
* played URLs and the catalog, whose rows and generation the next update() serves.
*/
export async function withRadioLayer({ stations, failingUrls = [] }, body) {
const originalFetch = globalThis.fetch;
const originalAudio = globalThis.Audio;
const playUrls = [];
const catalog = { rows: stations, 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();
}
}
+1 -2
View File
@@ -328,8 +328,7 @@ export function bindRadioControls() {
const pool = this._radioTunerPool.length
? this._radioTunerPool
: this._radioTunerStations;
// Keep the step silent unless a stream is active. An active stream follows
// the selection, so the station label matches the broadcaster.
// Silent unless a stream is active; an active stream follows the selection.
const { audioState } = this.radio.getUIState();
const cycled = this.radio.cycleStation(direction, {
rotate,
+1 -3
View File
@@ -20,9 +20,7 @@ export function renderRadioState(state) {
const interactive = enabled && !transitioning && !uncertain;
const selected = state.selected || null;
const hasStations = state.filteredCount > 0;
const activePlayback = ['loading', 'buffering', 'playing'].includes(
state.audioState,
);
const activePlayback = ['playing', 'buffering'].includes(state.audioState);
document
.getElementById('title-bar')
?.classList.toggle('radio-broadcasting', state.audioState === 'playing');