mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
b775bfed70
commit
4dd5a313b3
20 files changed
+523
-71
No files matched your search
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "КОНТЕКСТ",
|
||||
|
||||
@@ -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».",
|
||||
|
||||
@@ -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 图层共享。",
|
||||
|
||||
@@ -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 圖層共享。",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user