mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge branch 'main' into german/hal-623-hud-summary-locale
This commit is contained in:
30 files changed
+765
-163
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
- Radio Next, Previous and the tuning dial no longer start a broadcaster stream
|
||||||
|
before Play is pressed; while a stream plays, it follows the new station.
|
||||||
|
Play reads Pause while a stream connects, and a tuner release onto a station
|
||||||
|
the directory removed or changed no longer leaves a Resume for another
|
||||||
|
station (gekh, #1474).
|
||||||
+13
-8
@@ -3092,9 +3092,10 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> take over under the Cockpit cap of 60. Loading/capped contacts remain 2D,
|
> 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.
|
> out-of-range contacts use rotation-free dots, and exit restores map treatment.
|
||||||
> Its Radio popover exposes compact power,
|
> Its Radio popover exposes compact power,
|
||||||
> transport, station, and volume controls. Cockpit Previous/Next preserves
|
> transport, station, and volume controls. Cockpit Previous/Next changes the
|
||||||
> selection, autoplay, and broadcaster fallback audio without starting the
|
> selection without starting the map-view station flights that compete with
|
||||||
> map-view station flights that compete with the first-person camera. These
|
> 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:
|
> are a Cockpit-only accordion:
|
||||||
> each static header uses the left accordion's label and divider with a dedicated
|
> 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
|
> directional-chevron disclosure button and no full-row hover treatment. Only one
|
||||||
@@ -4736,10 +4737,12 @@ accepted snapshot, and feed telemetry before re-initialization; monotonic
|
|||||||
ownership tokens plus the session boundary keep late callbacks from a retired
|
ownership tokens plus the session boundary keep late callbacks from a retired
|
||||||
session inert.
|
session inert.
|
||||||
A tuner drag resolves previews against the single accepted snapshot captured at
|
A tuner drag resolves previews against the single accepted snapshot captured at
|
||||||
pointer-down, even while a newer catalog is admitted. Release starts playback
|
pointer-down, even while a newer catalog is admitted. Like Previous/Next,
|
||||||
only when the current same-ID record still exactly matches the frozen
|
release starts playback only if a stream was active when the drag began;
|
||||||
presentation and stream metadata; removal or replacement reports the channel as
|
otherwise it just selects the station. It commits only when the current same-ID
|
||||||
unavailable and never silently retargets the drag. A cold degraded fallback may
|
record still exactly matches the frozen presentation and stream metadata.
|
||||||
|
Removal or replacement reports the channel as unavailable and never silently
|
||||||
|
retargets the drag. A cold degraded fallback may
|
||||||
still populate the directory and globe, but its null accepted generation cannot
|
still populate the directory and globe, but its null accepted generation cannot
|
||||||
populate or begin the tuner.
|
populate or begin the tuner.
|
||||||
|
|
||||||
@@ -4916,7 +4919,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
|
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
|
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
|
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 removed or changed mid-drag.
|
||||||
Only a newer user turn or session teardown aborts the complete active-tool set.
|
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
|
A cancelled turn cannot publish a late Radio playback request. Dedicated Radio
|
||||||
controls and generic layer-visibility commands both forward cancellation.
|
controls and generic layer-visibility commands both forward cancellation.
|
||||||
|
|||||||
+20
-3
@@ -3073,20 +3073,27 @@ async function main() {
|
|||||||
expectedSlot: targetIndex,
|
expectedSlot: targetIndex,
|
||||||
previewPlayDelta: previewPlays - playsBefore,
|
previewPlayDelta: previewPlays - playsBefore,
|
||||||
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore,
|
||||||
|
audioState: state.audioState,
|
||||||
|
tuningStatic: state.tuningStatic,
|
||||||
samples,
|
samples,
|
||||||
spread: Math.max(...samples) - Math.min(...samples),
|
spread: Math.max(...samples) - Math.min(...samples),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
check(
|
check(
|
||||||
'pointer preview never plays and release commits the nearest station once without a needle sweep',
|
'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep',
|
||||||
tunerDirectRelease.targetId
|
tunerDirectRelease.targetId
|
||||||
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
|
&& tunerDirectRelease.selectedId === tunerDirectRelease.targetId
|
||||||
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
|
&& tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot
|
||||||
&& tunerDirectRelease.previewPlayDelta === 0
|
&& tunerDirectRelease.previewPlayDelta === 0
|
||||||
&& tunerDirectRelease.commitPlayDelta === 1
|
&& tunerDirectRelease.commitPlayDelta === 0
|
||||||
|
&& tunerDirectRelease.audioState === 'stopped'
|
||||||
|
&& !tunerDirectRelease.tuningStatic
|
||||||
&& tunerDirectRelease.spread < 0.5,
|
&& tunerDirectRelease.spread < 0.5,
|
||||||
JSON.stringify(tunerDirectRelease),
|
JSON.stringify(tunerDirectRelease),
|
||||||
);
|
);
|
||||||
|
// The release plays only if a stream was active when the drag began.
|
||||||
|
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
|
||||||
|
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||||
// Earlier cases scroll and resize the directory. Bring the physical drag
|
// Earlier cases scroll and resize the directory. Bring the physical drag
|
||||||
// target back into its scroller before deriving viewport mouse coordinates.
|
// target back into its scroller before deriving viewport mouse coordinates.
|
||||||
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
||||||
@@ -3494,6 +3501,9 @@ async function main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
// Next left the Radio stopped; a release plays only from an active stream.
|
||||||
|
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' }));
|
||||||
|
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||||
const failedTuner = await page.evaluate(async () => {
|
const failedTuner = await page.evaluate(async () => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const slider = document.getElementById('radio-tuner-slider');
|
const slider = document.getElementById('radio-tuner-slider');
|
||||||
@@ -3692,7 +3702,14 @@ async function main() {
|
|||||||
radio.stopPlayback();
|
radio.stopPlayback();
|
||||||
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
||||||
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
||||||
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
|
// Next plays only while a stream is active, so start from a playing station.
|
||||||
|
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: true, focus: false });
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const gev = window.__godsEyeView;
|
||||||
|
const camera = gev.viewer.camera;
|
||||||
|
const radio = gev.dataManager.layers.get('radio').module;
|
||||||
const pool = gev.styleManager._radioControls._radioTunerPool;
|
const pool = gev.styleManager._radioControls._radioTunerPool;
|
||||||
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
||||||
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
||||||
|
|||||||
+16
-75
@@ -51,6 +51,7 @@ import {
|
|||||||
radioViewIsGlobal,
|
radioViewIsGlobal,
|
||||||
stationMatchesRadioCategory,
|
stationMatchesRadioCategory,
|
||||||
} from './radio.js';
|
} from './radio.js';
|
||||||
|
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
|
||||||
|
|
||||||
const stations = [
|
const stations = [
|
||||||
{ id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] },
|
{ id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] },
|
||||||
@@ -883,88 +884,33 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('failed exact tuner release cannot consume a stale playback fallback', async () => {
|
test('failed exact tuner release cannot consume a stale playback fallback', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const originalAudio = globalThis.Audio;
|
|
||||||
const playUrls = [];
|
|
||||||
const stationRows = [
|
const stationRows = [
|
||||||
{
|
radioStationRow({
|
||||||
id: '00000000-0000-4000-8000-000000000071',
|
id: '00000000-0000-4000-8000-000000000071',
|
||||||
name: 'Exact target',
|
name: 'Exact target',
|
||||||
lat: 30,
|
|
||||||
lon: -97,
|
|
||||||
streamUrl: 'https://radio.example.com/exact.mp3',
|
streamUrl: 'https://radio.example.com/exact.mp3',
|
||||||
homepage: null,
|
}),
|
||||||
tags: ['news'],
|
radioStationRow({
|
||||||
languages: ['English'],
|
|
||||||
state: 'Texas',
|
|
||||||
country: 'United States',
|
|
||||||
countryCode: 'US',
|
|
||||||
metadataTrust: 'untrusted-community',
|
|
||||||
codec: 'MP3',
|
|
||||||
bitrate: 128,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '00000000-0000-4000-8000-000000000072',
|
id: '00000000-0000-4000-8000-000000000072',
|
||||||
name: 'Stale fallback',
|
name: 'Stale fallback',
|
||||||
lat: 31,
|
lat: 31,
|
||||||
lon: -98,
|
lon: -98,
|
||||||
streamUrl: 'https://radio.example.com/fallback.mp3',
|
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 {
|
await withRadioLayer({
|
||||||
constructor() {
|
stations: stationRows,
|
||||||
this.volume = 0.8;
|
failingUrls: [stationRows[0].streamUrl],
|
||||||
this.src = '';
|
}, async ({ playUrls }) => {
|
||||||
}
|
// A playing stream, so the release plays.
|
||||||
|
assert.equal(radioLayer.selectStation(stationRows[1].id, { autoplay: true }), true);
|
||||||
addEventListener() {}
|
await new Promise((resolve) => setTimeout(resolve));
|
||||||
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();
|
|
||||||
assert.equal(radioLayer.selectStation(stationRows[0].id, { autoplay: false }), true);
|
|
||||||
assert.equal(radioLayer.cycleStation(1, {
|
assert.equal(radioLayer.cycleStation(1, {
|
||||||
stationIds: stationRows.map(({ id }) => id),
|
stationIds: stationRows.map(({ id }) => id),
|
||||||
autoplay: false,
|
autoplay: false,
|
||||||
}), true, 'non-playing cycle creates the stale-fallback precondition');
|
fallback: true,
|
||||||
|
}), true, 'a voice-style prepared cycle creates the stale-fallback precondition');
|
||||||
|
playUrls.length = 0;
|
||||||
assert.equal(radioLayer.beginTuning(), true);
|
assert.equal(radioLayer.beginTuning(), true);
|
||||||
assert.equal(radioLayer.previewTuningStation(stationRows[0].id, { rotate: false }), true);
|
assert.equal(radioLayer.previewTuningStation(stationRows[0].id, { rotate: false }), true);
|
||||||
assert.equal(radioLayer.commitTuningStation(stationRows[0].id, { origin: 'user' }).ok, true);
|
assert.equal(radioLayer.commitTuningStation(stationRows[0].id, { origin: 'user' }).ok, true);
|
||||||
@@ -975,12 +921,7 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn
|
|||||||
assert.equal(state.audioState, 'error');
|
assert.equal(state.audioState, 'error');
|
||||||
assert.equal(state.tuningStatic, true);
|
assert.equal(state.tuningStatic, true);
|
||||||
assert.equal(state.tuningAwaitingStationId, stationRows[0].id);
|
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 () => {
|
test('tuner cancellation restores the frozen start marker after catalog removal', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
// Radio selection, fallbacks and voice handoffs on the real layer and its published view.
|
||||||
|
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';
|
||||||
|
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 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`,
|
||||||
|
}));
|
||||||
|
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.
|
||||||
|
function render(state) {
|
||||||
|
const previousDocument = globalThis.document;
|
||||||
|
globalThis.document = { getElementById: () => null, activeElement: null };
|
||||||
|
try {
|
||||||
|
let view = null;
|
||||||
|
const host = {
|
||||||
|
destroyed: false,
|
||||||
|
_radioPanel: {},
|
||||||
|
store: { publish: (published) => (view = published), publishTuner() {} },
|
||||||
|
actions: {
|
||||||
|
getLifecycle: () => null,
|
||||||
|
preservePanelStateDuringClear: () => false,
|
||||||
|
setPanelCollapsed() {},
|
||||||
|
scheduleLayout() {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderRadioState.call(host, state);
|
||||||
|
return {
|
||||||
|
status: view.playback.text,
|
||||||
|
error: Boolean(view.playback.error),
|
||||||
|
button: view.transport.play.text,
|
||||||
|
stopDisabled: view.transport.stopDisabled,
|
||||||
|
cockpit: view.cockpit.play.glyph,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (previousDocument === undefined) delete globalThis.document;
|
||||||
|
else globalThis.document = previousDocument;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, [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([stations[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), readyToPlay);
|
||||||
|
await radioLayer.togglePlayback({ origin: 'user' });
|
||||||
|
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 }) => {
|
||||||
|
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), readyToPlay);
|
||||||
|
await radioLayer.togglePlayback({ origin: 'user' });
|
||||||
|
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[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 ({ 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 = stations.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('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('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();
|
||||||
|
const state = radioLayer.getUIState();
|
||||||
|
assert.deepEqual(playUrls, [stations[1].streamUrl]);
|
||||||
|
assert.equal(state.selected?.id, ids[1]);
|
||||||
|
assert.equal(state.audioState, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 stops the paused stream once, and Play still falls back', async () => {
|
||||||
|
setLocale('en');
|
||||||
|
const failing = [];
|
||||||
|
await withLayer(failing, async ({ playUrls, catalog }) => {
|
||||||
|
radioLayer.selectStation(ids[2], { autoplay: true, origin: 'user' });
|
||||||
|
await settle();
|
||||||
|
assert.equal(radioLayer.beginTuning(), true);
|
||||||
|
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
||||||
|
// A directory refresh changes the station under the needle mid-drag.
|
||||||
|
catalog.rows = stations.map((row) => row.id === ids[1]
|
||||||
|
? { ...row, streamUrl: 'https://radio.example.com/b2.mp3' }
|
||||||
|
: row);
|
||||||
|
catalog.generation = 2;
|
||||||
|
await radioLayer.update();
|
||||||
|
const states = [];
|
||||||
|
const controls = [];
|
||||||
|
const unsubscribe = radioLayer.subscribe((state) => states.push(state));
|
||||||
|
const unsubscribeControls = radioLayer.subscribePlaybackControls((event) => controls.push(event));
|
||||||
|
states.length = 0;
|
||||||
|
try {
|
||||||
|
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).reason, 'station-unavailable');
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
unsubscribeControls();
|
||||||
|
}
|
||||||
|
assert.equal(states.length, 1);
|
||||||
|
assert.equal(states[0].tuningUnavailableStationId, ids[1]);
|
||||||
|
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['stop', 'user']]);
|
||||||
|
const state = radioLayer.getUIState();
|
||||||
|
assert.equal(state.selected, null);
|
||||||
|
assert.equal(state.audioState, 'stopped');
|
||||||
|
assert.equal(state.playingStationId, null);
|
||||||
|
assert.equal(render(state).button, 'PLAY');
|
||||||
|
playUrls.length = 0;
|
||||||
|
// Play picks the nearest station; its broadcaster fails.
|
||||||
|
const play = globalThis.Audio.prototype.play;
|
||||||
|
globalThis.Audio.prototype.play = function playFirstFails() {
|
||||||
|
if (!failing.length) failing.push(this.src);
|
||||||
|
return play.call(this);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await radioLayer.togglePlayback({ origin: 'user' });
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
} finally {
|
||||||
|
globalThis.Audio.prototype.play = play;
|
||||||
|
}
|
||||||
|
assert.equal(playUrls.length, 2);
|
||||||
|
assert.equal(radioLayer.getUIState().audioState, 'playing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('while a stream connects the transport buttons offer Pause, because a click pauses', () => {
|
||||||
|
setLocale('en');
|
||||||
|
const shown = render({
|
||||||
|
enabled: true,
|
||||||
|
audioState: 'loading',
|
||||||
|
audioError: null,
|
||||||
|
selected: { id: 'a', name: 'Station A' },
|
||||||
|
selectedIndex: 0,
|
||||||
|
filteredCount: 1,
|
||||||
|
stationCount: 1,
|
||||||
|
volume: 1,
|
||||||
|
});
|
||||||
|
assert.equal(shown.status, 'Connecting directly to broadcaster…');
|
||||||
|
assert.equal(shown.button, 'PAUSE');
|
||||||
|
assert.equal(shown.cockpit, 'Ⅱ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a dial release plays only if a stream was active when the drag began', async () => {
|
||||||
|
await withLayer([], async ({ playUrls }) => {
|
||||||
|
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||||
|
assert.equal(radioLayer.beginTuning(), true);
|
||||||
|
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
||||||
|
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
|
||||||
|
await settle();
|
||||||
|
let state = radioLayer.getUIState();
|
||||||
|
assert.deepEqual(playUrls, []);
|
||||||
|
assert.equal(state.selected?.id, ids[1]);
|
||||||
|
assert.equal(state.audioState, 'stopped');
|
||||||
|
assert.equal(state.tuningStatic, false);
|
||||||
|
|
||||||
|
await radioLayer.togglePlayback({ origin: 'user' });
|
||||||
|
assert.equal(radioLayer.beginTuning(), true);
|
||||||
|
radioLayer.previewTuningStation(ids[2], { rotate: false });
|
||||||
|
radioLayer.commitTuningStation(ids[2], { origin: 'user' });
|
||||||
|
await settle();
|
||||||
|
state = radioLayer.getUIState();
|
||||||
|
assert.deepEqual(playUrls, [stations[1].streamUrl, stations[2].streamUrl]);
|
||||||
|
assert.equal(state.audioState, 'playing');
|
||||||
|
assert.equal(state.playingStationId, ids[2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Like Chrome, resume() flips `state` only once the device starts, and suspend()
|
||||||
|
// on a context that is still suspended resolves at once without effect.
|
||||||
|
const RESUME_MS = 5;
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
function withFakeAudioContext(body) {
|
||||||
|
const log = { contexts: [], sources: [] };
|
||||||
|
const node = () => ({ connect() {}, disconnect() {} });
|
||||||
|
class FakeAudioContext {
|
||||||
|
constructor() {
|
||||||
|
Object.assign(this, { state: 'suspended', sampleRate: 8000, currentTime: 0, destination: {} });
|
||||||
|
log.contexts.push(this);
|
||||||
|
}
|
||||||
|
resume() {
|
||||||
|
if (this.state === 'running') return Promise.resolve();
|
||||||
|
return new Promise((resolve) => setTimeout(() => {
|
||||||
|
if (this.state !== 'closed') this.state = 'running';
|
||||||
|
resolve();
|
||||||
|
}, RESUME_MS));
|
||||||
|
}
|
||||||
|
suspend() {
|
||||||
|
if (this.state === 'running') this.state = 'suspended';
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
close() { this.state = 'closed'; return Promise.resolve(); }
|
||||||
|
createBuffer(_channels, length) { return { getChannelData: () => new Float32Array(length) }; }
|
||||||
|
createBufferSource() {
|
||||||
|
const source = { ...node(), started: false, stopped: false, start() { this.started = true; }, stop() { this.stopped = true; } };
|
||||||
|
log.sources.push(source);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
createBiquadFilter() { return { ...node(), frequency: { value: 0 }, Q: { value: 0 } }; }
|
||||||
|
createGain() {
|
||||||
|
return { ...node(), gain: { value: 0, cancelScheduledValues() {}, setValueAtTime() {}, linearRampToValueAtTime() {} } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const original = globalThis.AudioContext;
|
||||||
|
globalThis.AudioContext = FakeAudioContext;
|
||||||
|
return Promise.resolve(body(log)).finally(() => {
|
||||||
|
if (original === undefined) delete globalThis.AudioContext;
|
||||||
|
else globalThis.AudioContext = original;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a silent dial release stops the tuner noise and suspends its audio context, even before resume() settles', async () => {
|
||||||
|
await withFakeAudioContext(async ({ contexts, sources }) => {
|
||||||
|
await withLayer([], async ({ playUrls }) => {
|
||||||
|
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||||
|
assert.equal(radioLayer.beginTuning(), true);
|
||||||
|
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
||||||
|
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
|
||||||
|
await sleep(RESUME_MS * 4);
|
||||||
|
assert.deepEqual(playUrls, []);
|
||||||
|
assert.equal(sources[0].started, true);
|
||||||
|
assert.equal(sources[0].stopped, true, 'noise source still looping');
|
||||||
|
assert.equal(contexts[0].state, 'suspended', 'audio context still running');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second gesture before the first resume() settles keeps its noise graph', async () => {
|
||||||
|
await withFakeAudioContext(async ({ contexts, sources }) => {
|
||||||
|
await withLayer([], async () => {
|
||||||
|
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||||
|
radioLayer.beginTuning();
|
||||||
|
radioLayer.commitTuningStation(ids[0], { origin: 'user' });
|
||||||
|
assert.equal(radioLayer.beginTuning(), true);
|
||||||
|
await sleep(RESUME_MS * 4);
|
||||||
|
assert.equal(contexts[0].state, 'running', 'second gesture lost its context');
|
||||||
|
assert.equal(sources.at(-1).stopped, false, 'second gesture lost its source');
|
||||||
|
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
|
||||||
|
await sleep(RESUME_MS * 4);
|
||||||
|
assert.equal(sources.at(-1).stopped, true);
|
||||||
|
assert.equal(contexts[0].state, 'suspended');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a dial release from a paused stream only selects, without static', async () => {
|
||||||
|
await withFakeAudioContext(async ({ contexts }) => {
|
||||||
|
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');
|
||||||
|
assert.equal(radioLayer.beginTuning(), true);
|
||||||
|
await sleep(RESUME_MS * 4);
|
||||||
|
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
||||||
|
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
|
||||||
|
await sleep(RESUME_MS * 4);
|
||||||
|
const state = radioLayer.getUIState();
|
||||||
|
assert.deepEqual(playUrls, [stations[0].streamUrl]);
|
||||||
|
assert.equal(state.selected?.id, ids[1]);
|
||||||
|
assert.equal(state.audioState, 'stopped');
|
||||||
|
assert.equal(state.tuningStatic, false);
|
||||||
|
assert.equal(state.tuningAwaitingStationId, null);
|
||||||
|
assert.equal(contexts[0].state, 'suspended');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a dial release while a stream is still connecting plays the new station', async () => {
|
||||||
|
await withLayer([], async ({ playUrls }) => {
|
||||||
|
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
||||||
|
assert.equal(radioLayer.getUIState().audioState, 'loading');
|
||||||
|
assert.equal(radioLayer.beginTuning(), true);
|
||||||
|
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
||||||
|
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
|
||||||
|
await settle();
|
||||||
|
const state = radioLayer.getUIState();
|
||||||
|
assert.equal(playUrls.at(-1), stations[1].streamUrl);
|
||||||
|
assert.equal(state.audioState, 'playing');
|
||||||
|
assert.equal(state.playingStationId, ids[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a silent user step drops the fallback a voice step left behind', async () => {
|
||||||
|
await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => {
|
||||||
|
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||||
|
// Voice steps to B and keeps C as its fallback; the user steps back to A.
|
||||||
|
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
||||||
|
radioLayer.cycleStation(-1, { stationIds: ids, autoplay: false, origin: 'user' });
|
||||||
|
await radioLayer.play({ origin: 'voice' });
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(playUrls, [stations[0].streamUrl]);
|
||||||
|
assert.equal(radioLayer.getUIState().audioState, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a silent dial release drops the fallback a voice step left behind', async () => {
|
||||||
|
await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => {
|
||||||
|
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||||
|
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
||||||
|
assert.equal(radioLayer.beginTuning(), true);
|
||||||
|
radioLayer.previewTuningStation(ids[0], { rotate: false });
|
||||||
|
radioLayer.commitTuningStation(ids[0], { origin: 'user' });
|
||||||
|
await radioLayer.play({ origin: 'voice' });
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(playUrls, [stations[0].streamUrl]);
|
||||||
|
assert.equal(radioLayer.getUIState().audioState, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -562,7 +562,7 @@
|
|||||||
"Radio volume": "مستوى صوت الراديو",
|
"Radio volume": "مستوى صوت الراديو",
|
||||||
"Radio off": "الراديو متوقف",
|
"Radio off": "الراديو متوقف",
|
||||||
"STATION SITE": "موقع المحطة",
|
"STATION SITE": "موقع المحطة",
|
||||||
"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, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "يتصل التطبيق مباشرةً بمحطة البث عند الضغط على «تشغيل» أو اختيار محطة على الكرة الأرضية أو طلب التشغيل بالصوت، فيظهر لها عنوان IP الخاص بك.",
|
||||||
"LOADING": "جارٍ التحميل",
|
"LOADING": "جارٍ التحميل",
|
||||||
"DEGRADED": "متدهور",
|
"DEGRADED": "متدهور",
|
||||||
"STALE": "متقادم",
|
"STALE": "متقادم",
|
||||||
|
|||||||
@@ -777,7 +777,7 @@
|
|||||||
"Stop radio playback": "Radiowiedergabe stoppen",
|
"Stop radio playback": "Radiowiedergabe stoppen",
|
||||||
"Radio volume": "Radio-Lautstärke",
|
"Radio volume": "Radio-Lautstärke",
|
||||||
"STATION SITE": "SENDER-WEBSITE",
|
"STATION SITE": "SENDER-WEBSITE",
|
||||||
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Beim Starten der Wiedergabe wird eine direkte Verbindung zum Sender hergestellt. Die IP-Adresse ist für diesen Sender sichtbar.",
|
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Beim Starten der Wiedergabe, bei der Wahl eines Senders auf dem Globus oder auf Wunsch per Sprachsteuerung wird eine direkte Verbindung zum Sender hergestellt. Die IP-Adresse ist für diesen Sender sichtbar.",
|
||||||
"Intelligence HUD (H)": "Lagebild-HUD (H)",
|
"Intelligence HUD (H)": "Lagebild-HUD (H)",
|
||||||
"Layout": "Layout",
|
"Layout": "Layout",
|
||||||
"HUD layout": "HUD-Layout",
|
"HUD layout": "HUD-Layout",
|
||||||
|
|||||||
@@ -450,7 +450,7 @@
|
|||||||
"Radio volume": "Volumen de Radio",
|
"Radio volume": "Volumen de Radio",
|
||||||
"Radio off": "Radio desactivada",
|
"Radio off": "Radio desactivada",
|
||||||
"STATION SITE": "SITIO DE LA EMISORA",
|
"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, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora al pulsar ESCUCHAR, elegir una emisora en el globo o pedirlo por voz. Tu IP es visible para esa emisora.",
|
||||||
"LOADING": "CARGANDO",
|
"LOADING": "CARGANDO",
|
||||||
"DEGRADED": "DEGRADADO",
|
"DEGRADED": "DEGRADADO",
|
||||||
"STALE": "DESACTUALIZADO",
|
"STALE": "DESACTUALIZADO",
|
||||||
|
|||||||
@@ -2818,7 +2818,7 @@
|
|||||||
"Radio volume": "Volume de la radio",
|
"Radio volume": "Volume de la radio",
|
||||||
"STATION SITE": "SITE DE LA STATION",
|
"STATION SITE": "SITE DE LA STATION",
|
||||||
"DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER",
|
"DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER",
|
||||||
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture. Votre IP est visible par ce diffuseur.",
|
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.",
|
||||||
"Local RTL-SDR receiver": "Récepteur RTL-SDR local",
|
"Local RTL-SDR receiver": "Récepteur RTL-SDR local",
|
||||||
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
|
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
|
||||||
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.",
|
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.",
|
||||||
|
|||||||
@@ -2818,7 +2818,7 @@
|
|||||||
"Radio volume": "Volume radio",
|
"Radio volume": "Volume radio",
|
||||||
"STATION SITE": "SITO STAZIONE",
|
"STATION SITE": "SITO STAZIONE",
|
||||||
"DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER",
|
"DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER",
|
||||||
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play. Il tuo IP è visibile a quell'emittente.",
|
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play, scelto una stazione sul globo o chiesto la riproduzione a voce. Il tuo IP è visibile a quell'emittente.",
|
||||||
"Local RTL-SDR receiver": "Ricevitore RTL-SDR locale",
|
"Local RTL-SDR receiver": "Ricevitore RTL-SDR locale",
|
||||||
"LOCAL RTL-SDR": "RTL-SDR LOCALE",
|
"LOCAL RTL-SDR": "RTL-SDR LOCALE",
|
||||||
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizzatore locale condiviso tra FM e il livello ADS-B locale.",
|
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizzatore locale condiviso tra FM e il livello ADS-B locale.",
|
||||||
|
|||||||
@@ -2676,7 +2676,7 @@
|
|||||||
"Radio volume": "ラジオの音量",
|
"Radio volume": "ラジオの音量",
|
||||||
"STATION SITE": "放送局サイト",
|
"STATION SITE": "放送局サイト",
|
||||||
"DIRECTORY: RADIO BROWSER": "ディレクトリ:Radio Browser",
|
"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, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "再生ボタンを押す、地球儀で局を選ぶ、または音声コントロールで再生を指示すると、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。",
|
||||||
"Local RTL-SDR receiver": "ローカルRTL-SDR受信機",
|
"Local RTL-SDR receiver": "ローカルRTL-SDR受信機",
|
||||||
"LOCAL RTL-SDR": "ローカル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台のローカルチューナーを共有します。",
|
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · FMとローカルADS-Bレイヤーで1台のローカルチューナーを共有します。",
|
||||||
|
|||||||
@@ -2820,7 +2820,7 @@
|
|||||||
"Radio volume": "Volume do rádio",
|
"Radio volume": "Volume do rádio",
|
||||||
"STATION SITE": "SITE DA ESTAÇÃO",
|
"STATION SITE": "SITE DA ESTAÇÃO",
|
||||||
"DIRECTORY: RADIO BROWSER": "DIRETÓRIO: RADIO BROWSER",
|
"DIRECTORY: RADIO BROWSER": "DIRETÓRIO: RADIO BROWSER",
|
||||||
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora depois que você aperta \"Ouvir\". Seu IP fica visível para essa emissora.",
|
"Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora quando você aperta \"Ouvir\", escolhe uma estação no globo ou pede por voz. Seu IP fica visível para essa emissora.",
|
||||||
"Local RTL-SDR receiver": "Receptor RTL-SDR local",
|
"Local RTL-SDR receiver": "Receptor RTL-SDR local",
|
||||||
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
|
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
|
||||||
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · um sintonizador local compartilhado entre o FM e a camada ADS-B local.",
|
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · um sintonizador local compartilhado entre o FM e a camada ADS-B local.",
|
||||||
|
|||||||
@@ -727,7 +727,7 @@
|
|||||||
"STATION TAG": "ТЕГ СТАНЦИИ",
|
"STATION TAG": "ТЕГ СТАНЦИИ",
|
||||||
"DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ",
|
"DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ",
|
||||||
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ПРОСМОТР · ENTER / SPACE — ВЫБОР",
|
"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, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Звук идёт напрямую от вещателя после нажатия «Пуск», выбора станции на глобусе или голосовой команды. Вещателю виден ваш IP-адрес.",
|
||||||
"Internet radio and local SDR": "Интернет-радио и локальный SDR",
|
"Internet radio and local SDR": "Интернет-радио и локальный SDR",
|
||||||
"Filter stations by station tag": "Фильтровать станции по тегу",
|
"Filter stations by station tag": "Фильтровать станции по тегу",
|
||||||
"CONTEXT": "КОНТЕКСТ",
|
"CONTEXT": "КОНТЕКСТ",
|
||||||
|
|||||||
@@ -2888,7 +2888,7 @@
|
|||||||
"Radio volume": "Гучність радіо",
|
"Radio volume": "Гучність радіо",
|
||||||
"STATION SITE": "САЙТ СТАНЦІЇ",
|
"STATION SITE": "САЙТ СТАНЦІЇ",
|
||||||
"DIRECTORY: RADIO BROWSER": "КАТАЛОГ: RADIO BROWSER",
|
"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, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Звук надходить безпосередньо від мовника після натискання «Відтворити», вибору станції на глобусі або голосової команди. Мовник бачить вашу IP-адресу.",
|
||||||
"Local RTL-SDR receiver": "Локальний приймач RTL-SDR",
|
"Local RTL-SDR receiver": "Локальний приймач RTL-SDR",
|
||||||
"LOCAL RTL-SDR": "ЛОКАЛЬНИЙ 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».",
|
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · один локальний тюнер для FM і шару «Локальний ADS-B».",
|
||||||
|
|||||||
@@ -2575,7 +2575,7 @@
|
|||||||
"Radio volume": "电台音量",
|
"Radio volume": "电台音量",
|
||||||
"STATION SITE": "电台网站",
|
"STATION SITE": "电台网站",
|
||||||
"DIRECTORY: RADIO BROWSER": "目录:RADIO BROWSER",
|
"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, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "点击播放、在地球上选择电台或通过语音要求播放后,音频将直接连接到广播方。广播方可以看到你的 IP。",
|
||||||
"Local RTL-SDR receiver": "本地 RTL-SDR 接收机",
|
"Local RTL-SDR receiver": "本地 RTL-SDR 接收机",
|
||||||
"LOCAL RTL-SDR": "本地 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 图层共享。",
|
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一个本地调谐器由 FM 与本地 ADS-B 图层共享。",
|
||||||
|
|||||||
@@ -2563,7 +2563,7 @@
|
|||||||
"Radio volume": "電臺音量",
|
"Radio volume": "電臺音量",
|
||||||
"STATION SITE": "電臺網站",
|
"STATION SITE": "電臺網站",
|
||||||
"DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER",
|
"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, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "點擊播放、在地球上選擇電臺或通過語音要求播放後,音頻將直接連接到廣播方。廣播方可以看到你的 IP。",
|
||||||
"Local RTL-SDR receiver": "本地 RTL-SDR 接收機",
|
"Local RTL-SDR receiver": "本地 RTL-SDR 接收機",
|
||||||
"LOCAL RTL-SDR": "本地 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 圖層共享。",
|
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本地調諧器由 FM 與本地 ADS-B 圖層共享。",
|
||||||
|
|||||||
@@ -344,14 +344,17 @@ export function createPlayback({ state: layerState, services, parts, source }) {
|
|||||||
}
|
}
|
||||||
if (!parts.interaction.radioPresentationAllowed())
|
if (!parts.interaction.radioPresentationAllowed())
|
||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
if (!layerState._selectedId) {
|
if (layerState._selectedId) {
|
||||||
|
// Drop a fallback left by a voice step whose playback never started.
|
||||||
|
layerState._playFallbackId = null;
|
||||||
|
layerState._playFallbackFocus = null;
|
||||||
|
} else {
|
||||||
const ranked = parts.queries.rankedVisibleStations();
|
const ranked = parts.queries.rankedVisibleStations();
|
||||||
if (!ranked.length) return Promise.resolve(false);
|
if (!ranked.length) return Promise.resolve(false);
|
||||||
layerState._playFallbackId = ranked[1]?.id || null;
|
|
||||||
layerState._playFallbackFocus = false;
|
|
||||||
parts.selection.selectRadioStation(ranked[0].id, {
|
parts.selection.selectRadioStation(ranked[0].id, {
|
||||||
autoplay: false,
|
autoplay: false,
|
||||||
focus: false,
|
focus: false,
|
||||||
|
playFallback: { id: ranked[1]?.id || null, focus: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return playSelectedRadio({ origin });
|
return playSelectedRadio({ origin });
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ export function createSelection({
|
|||||||
return { filter: layerState._filter, volume: layerState._userVolume };
|
return { filter: layerState._filter, volume: layerState._userVolume };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
)
|
||||||
|
parts.playback.stopRadioPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
/** Select a station. Playback occurs only when autoplay is explicitly true. */
|
/** Select a station. Playback occurs only when autoplay is explicitly true. */
|
||||||
|
|
||||||
function selectRadioStation(
|
function selectRadioStation(
|
||||||
@@ -83,11 +93,18 @@ export function createSelection({
|
|||||||
origin = 'programmatic',
|
origin = 'programmatic',
|
||||||
attemptId = null,
|
attemptId = null,
|
||||||
cameraNavigation = null,
|
cameraNavigation = null,
|
||||||
|
playFallback = null,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
if (!parts.interaction.radioPresentationAllowed()) return false;
|
if (!parts.interaction.radioPresentationAllowed()) return false;
|
||||||
const station = layerState._stationById.get(String(id));
|
const station = layerState._stationById.get(String(id));
|
||||||
if (!station) return false;
|
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;
|
layerState._tuningUnavailableStationId = null;
|
||||||
if (
|
if (
|
||||||
!parts.navigation.radioCameraNavigationOwnsSelection(
|
!parts.navigation.radioCameraNavigationOwnsSelection(
|
||||||
@@ -104,6 +121,7 @@ export function createSelection({
|
|||||||
)
|
)
|
||||||
parts.tuning.endRadioTuning();
|
parts.tuning.endRadioTuning();
|
||||||
layerState._cancelledTuningPresentationStation = null;
|
layerState._cancelledTuningPresentationStation = null;
|
||||||
|
const changed = layerState._selectedId !== station.id;
|
||||||
layerState._selectedId = station.id;
|
layerState._selectedId = station.id;
|
||||||
const generation = ++layerState._selectionGeneration;
|
const generation = ++layerState._selectionGeneration;
|
||||||
const sessionGeneration = layerState._sessionGeneration;
|
const sessionGeneration = layerState._sessionGeneration;
|
||||||
@@ -122,6 +140,9 @@ export function createSelection({
|
|||||||
if (focus) parts.navigation.focusStation(station);
|
if (focus) parts.navigation.focusStation(station);
|
||||||
parts.presentation.emitState();
|
parts.presentation.emitState();
|
||||||
if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId });
|
if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId });
|
||||||
|
// A user step to another station cancels a playback voice prepared.
|
||||||
|
else if (origin === 'user' && changed)
|
||||||
|
parts.presentation.emitPlaybackControl('select', origin);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +154,7 @@ export function createSelection({
|
|||||||
rotate = false,
|
rotate = false,
|
||||||
stationIds = null,
|
stationIds = null,
|
||||||
autoplay = true,
|
autoplay = true,
|
||||||
|
fallback = autoplay,
|
||||||
origin = 'programmatic',
|
origin = 'programmatic',
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
@@ -159,25 +181,29 @@ export function createSelection({
|
|||||||
current < 0
|
current < 0
|
||||||
? 0
|
? 0
|
||||||
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
|
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
|
||||||
layerState._playFallbackId =
|
// Voice defers playback, so its silent step keeps a fallback; silent UI steps do not.
|
||||||
ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null;
|
const fallbackId =
|
||||||
|
fallback && ranked.length > 1
|
||||||
|
? ranked[(nextIndex + 1) % ranked.length].id
|
||||||
|
: null;
|
||||||
const rotationCameraState = rotate
|
const rotationCameraState = rotate
|
||||||
? parts.navigation.radioCameraState()
|
? parts.navigation.radioCameraState()
|
||||||
: null;
|
: null;
|
||||||
const rotationNavigation = rotate
|
const rotationNavigation = rotate
|
||||||
? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
|
? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
|
||||||
: null;
|
: null;
|
||||||
layerState._playFallbackFocus = rotate
|
const fallbackFocus =
|
||||||
? (fallbackStation) => {
|
fallback && rotate
|
||||||
parts.navigation.rotateRadioStationIntoView(
|
? (fallbackStation) => {
|
||||||
fallbackStation,
|
parts.navigation.rotateRadioStationIntoView(
|
||||||
0.65,
|
fallbackStation,
|
||||||
rotationCameraState,
|
0.65,
|
||||||
rotationNavigation,
|
rotationCameraState,
|
||||||
);
|
rotationNavigation,
|
||||||
return rotationNavigation;
|
);
|
||||||
}
|
return rotationNavigation;
|
||||||
: false;
|
}
|
||||||
|
: false;
|
||||||
const station = ranked[nextIndex];
|
const station = ranked[nextIndex];
|
||||||
if (rotate)
|
if (rotate)
|
||||||
parts.navigation.rotateRadioStationIntoView(
|
parts.navigation.rotateRadioStationIntoView(
|
||||||
@@ -191,6 +217,7 @@ export function createSelection({
|
|||||||
focus: false,
|
focus: false,
|
||||||
origin,
|
origin,
|
||||||
cameraNavigation: rotationNavigation,
|
cameraNavigation: rotationNavigation,
|
||||||
|
playFallback: { id: fallbackId, focus: fallbackFocus },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,9 +244,12 @@ export function createSelection({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!ranked.length) return null;
|
if (!ranked.length) return null;
|
||||||
layerState._playFallbackId = ranked[1]?.id || null;
|
selectRadioStation(ranked[0].id, {
|
||||||
layerState._playFallbackFocus = false;
|
autoplay,
|
||||||
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
|
focus: false,
|
||||||
|
origin,
|
||||||
|
playFallback: { id: ranked[1]?.id || null, focus: false },
|
||||||
|
});
|
||||||
return ranked[0];
|
return ranked[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
layerState._tuningStartStationId = null;
|
layerState._tuningStartStationId = null;
|
||||||
|
|
||||||
|
layerState._tuningResumesPlayback = false;
|
||||||
|
|
||||||
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||||
|
|
||||||
layerState._tuningStationById = new Map();
|
layerState._tuningStationById = new Map();
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
|||||||
layerState._cancelledTuningPresentationStation = null;
|
layerState._cancelledTuningPresentationStation = null;
|
||||||
layerState._tuningStartStationId = layerState._selectedId;
|
layerState._tuningStartStationId = layerState._selectedId;
|
||||||
layerState._tuningPreviewId = layerState._selectedId;
|
layerState._tuningPreviewId = layerState._selectedId;
|
||||||
|
// Like Previous/Next, the release plays only if a stream was active.
|
||||||
|
layerState._tuningResumesPlayback = [
|
||||||
|
'loading',
|
||||||
|
'buffering',
|
||||||
|
'playing',
|
||||||
|
].includes(layerState._audioState);
|
||||||
parts.tuningNoise.installTuningNoise();
|
parts.tuningNoise.installTuningNoise();
|
||||||
parts.playback.pauseRadioPlayback({ origin: 'user' });
|
parts.playback.pauseRadioPlayback({ origin: 'user' });
|
||||||
parts.tuningNoise.syncTuningNoiseGain();
|
parts.tuningNoise.syncTuningNoiseGain();
|
||||||
@@ -179,7 +185,9 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
|||||||
layerState._selectionGeneration += 1;
|
layerState._selectionGeneration += 1;
|
||||||
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||||
layerState._tuningUnavailableStationId = stationId;
|
layerState._tuningUnavailableStationId = stationId;
|
||||||
parts.presentation.emitState();
|
// The drag paused the stream; with no selection, Resume would start the
|
||||||
|
// nearest station. Stop also publishes the state and cancels a voice handoff.
|
||||||
|
parts.playback.stopRadioPlayback({ origin });
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
ok: false,
|
ok: false,
|
||||||
reason: 'station-unavailable',
|
reason: 'station-unavailable',
|
||||||
@@ -187,9 +195,11 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
|||||||
generation,
|
generation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const autoplay = layerState._tuningResumesPlayback;
|
||||||
|
layerState._tuningResumesPlayback = false;
|
||||||
layerState._tuningActive = false;
|
layerState._tuningActive = false;
|
||||||
layerState._tuningStatic = true;
|
layerState._tuningStatic = autoplay;
|
||||||
layerState._tuningAwaitingStationId = frozenStation.id;
|
layerState._tuningAwaitingStationId = autoplay ? frozenStation.id : null;
|
||||||
layerState._tuningPreviewId = null;
|
layerState._tuningPreviewId = null;
|
||||||
layerState._tuningStartStationId = null;
|
layerState._tuningStartStationId = null;
|
||||||
layerState._tuningUnavailableStationId = null;
|
layerState._tuningUnavailableStationId = null;
|
||||||
@@ -204,10 +214,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
|
|||||||
layerState._playFallbackFocus = null;
|
layerState._playFallbackFocus = null;
|
||||||
layerState._playFallbackOrigin = 'programmatic';
|
layerState._playFallbackOrigin = 'programmatic';
|
||||||
layerState._playFallbackAttemptId = null;
|
layerState._playFallbackAttemptId = null;
|
||||||
parts.tuningNoise.syncTuningNoiseGain();
|
// Muting alone would leave the noise graph running after a silent release.
|
||||||
|
if (autoplay) parts.tuningNoise.syncTuningNoiseGain();
|
||||||
|
else parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||||
parts.presentation.emitState();
|
parts.presentation.emitState();
|
||||||
const selected = parts.selection.selectRadioStation(frozenStation.id, {
|
const selected = parts.selection.selectRadioStation(frozenStation.id, {
|
||||||
autoplay: true,
|
autoplay,
|
||||||
focus: false,
|
focus: false,
|
||||||
origin,
|
origin,
|
||||||
cameraNavigation,
|
cameraNavigation,
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ export function createTuningNoise({
|
|||||||
layerState._tuningNoiseGain = null;
|
layerState._tuningNoiseGain = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function suspendTuningNoiseContext() {
|
||||||
|
const suspended = layerState._tuningNoiseContext?.suspend?.();
|
||||||
|
if (suspended?.catch) void suspended.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function syncTuningNoiseGain() {
|
function syncTuningNoiseGain() {
|
||||||
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
|
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
|
||||||
const audible = radioTuningStaticShouldPlay({
|
const audible = radioTuningStaticShouldPlay({
|
||||||
@@ -79,8 +84,21 @@ export function createTuningNoise({
|
|||||||
if (!AudioContextClass) return false;
|
if (!AudioContextClass) return false;
|
||||||
if (!layerState._tuningNoiseContext)
|
if (!layerState._tuningNoiseContext)
|
||||||
layerState._tuningNoiseContext = new AudioContextClass();
|
layerState._tuningNoiseContext = new AudioContextClass();
|
||||||
const resumed = layerState._tuningNoiseContext.resume?.();
|
const context = layerState._tuningNoiseContext;
|
||||||
if (resumed?.catch) void resumed.catch(() => {});
|
const resumed = context.resume?.();
|
||||||
|
// A release before the device starts finds the context still suspended,
|
||||||
|
// so its suspend() is a no-op; suspend again once resume() settles.
|
||||||
|
if (resumed?.then)
|
||||||
|
void resumed.then(
|
||||||
|
() => {
|
||||||
|
if (
|
||||||
|
layerState._tuningNoiseContext === context &&
|
||||||
|
!layerState._tuningNoiseSource
|
||||||
|
)
|
||||||
|
suspendTuningNoiseContext();
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
if (layerState._tuningNoiseSource) return true;
|
if (layerState._tuningNoiseSource) return true;
|
||||||
|
|
||||||
const frameCount = Math.max(
|
const frameCount = Math.max(
|
||||||
@@ -120,6 +138,7 @@ export function createTuningNoise({
|
|||||||
if (layerState._tuningCameraNavigation)
|
if (layerState._tuningCameraNavigation)
|
||||||
parts.navigation.invalidateRadioCameraNavigation();
|
parts.navigation.invalidateRadioCameraNavigation();
|
||||||
layerState._tuningActive = false;
|
layerState._tuningActive = false;
|
||||||
|
layerState._tuningResumesPlayback = false;
|
||||||
layerState._tuningStatic = false;
|
layerState._tuningStatic = false;
|
||||||
layerState._tuningAwaitingStationId = null;
|
layerState._tuningAwaitingStationId = null;
|
||||||
layerState._tuningPreviewId = null;
|
layerState._tuningPreviewId = null;
|
||||||
@@ -131,8 +150,7 @@ export function createTuningNoise({
|
|||||||
parts.rendering.updateSelectionEntity();
|
parts.rendering.updateSelectionEntity();
|
||||||
syncTuningNoiseGain();
|
syncTuningNoiseGain();
|
||||||
stopTuningNoiseSource();
|
stopTuningNoiseSource();
|
||||||
const suspended = layerState._tuningNoiseContext?.suspend?.();
|
suspendTuningNoiseContext();
|
||||||
if (suspended?.catch) void suspended.catch(() => {});
|
|
||||||
if (emit) parts.presentation.emitState();
|
if (emit) parts.presentation.emitState();
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { radioLayer } from '../data/radio.js';
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -241,9 +241,12 @@ export function bindRadioControls() {
|
|||||||
const pool = this._radioTunerPool.length
|
const pool = this._radioTunerPool.length
|
||||||
? this._radioTunerPool
|
? this._radioTunerPool
|
||||||
: this._radioTunerStations;
|
: this._radioTunerStations;
|
||||||
|
// Silent unless a stream is active; an active stream follows the selection.
|
||||||
|
const { audioState } = this.radio.getUIState();
|
||||||
const cycled = this.radio.cycleStation(direction, {
|
const cycled = this.radio.cycleStation(direction, {
|
||||||
rotate,
|
rotate,
|
||||||
stationIds: pool.map((station) => station.id),
|
stationIds: pool.map((station) => station.id),
|
||||||
|
autoplay: ['loading', 'buffering', 'playing'].includes(audioState),
|
||||||
origin: 'user',
|
origin: 'user',
|
||||||
});
|
});
|
||||||
if (!cycled) {
|
if (!cycled) {
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ function fixture() {
|
|||||||
publishTuner() {},
|
publishTuner() {},
|
||||||
};
|
};
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
const cycleOptions = [];
|
||||||
const subscribers = [];
|
const subscribers = [];
|
||||||
|
const uiState = { audioState: 'stopped' };
|
||||||
const radio = {
|
const radio = {
|
||||||
subscribe(callback) {
|
subscribe(callback) {
|
||||||
subscribers.push(callback);
|
subscribers.push(callback);
|
||||||
@@ -57,10 +59,12 @@ function fixture() {
|
|||||||
endTuning() {
|
endTuning() {
|
||||||
calls.push('endTuning');
|
calls.push('endTuning');
|
||||||
},
|
},
|
||||||
cycleStation(direction) {
|
cycleStation(direction, options) {
|
||||||
calls.push(['cycle', direction]);
|
calls.push(['cycle', direction]);
|
||||||
|
cycleOptions.push(options);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
getUIState: () => uiState,
|
||||||
};
|
};
|
||||||
const actions = {
|
const actions = {
|
||||||
isRegistered: () => true,
|
isRegistered: () => true,
|
||||||
@@ -90,6 +94,8 @@ function fixture() {
|
|||||||
store,
|
store,
|
||||||
actions,
|
actions,
|
||||||
calls,
|
calls,
|
||||||
|
cycleOptions,
|
||||||
|
uiState,
|
||||||
subscribers,
|
subscribers,
|
||||||
document,
|
document,
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -177,3 +183,31 @@ test('destruction releases an active tuner pointer after revoking listeners', ()
|
|||||||
f.cleanup();
|
f.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Next and Previous play only while a stream is active', () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
f.controls.connect();
|
||||||
|
for (const audioState of ['stopped', 'paused', 'error']) {
|
||||||
|
f.uiState.audioState = audioState;
|
||||||
|
f.store.act('cycleRadio', 1);
|
||||||
|
f.store.act('cycleRadio', -1);
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
f.cycleOptions.map((options) => options.autoplay),
|
||||||
|
[false, false, false, false, false, false],
|
||||||
|
);
|
||||||
|
f.cycleOptions.length = 0;
|
||||||
|
for (const audioState of ['loading', 'buffering', 'playing']) {
|
||||||
|
f.uiState.audioState = audioState;
|
||||||
|
f.store.act('cycleRadio', 1);
|
||||||
|
f.store.act('cycleRadio', -1);
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
f.cycleOptions.map((options) => options.autoplay),
|
||||||
|
[true, true, true, true, true, true],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
f.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -25,7 +25,9 @@ export function renderRadioState(state) {
|
|||||||
const interactive = enabled && !transitioning && !uncertain;
|
const interactive = enabled && !transitioning && !uncertain;
|
||||||
const selected = state.selected || null;
|
const selected = state.selected || null;
|
||||||
const hasStations = state.filteredCount > 0;
|
const hasStations = state.filteredCount > 0;
|
||||||
const activePlayback = ['playing', 'buffering'].includes(state.audioState);
|
const activePlayback = ['loading', 'buffering', 'playing'].includes(
|
||||||
|
state.audioState,
|
||||||
|
);
|
||||||
document
|
document
|
||||||
.getElementById('title-bar')
|
.getElementById('title-bar')
|
||||||
?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
|
?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const radioWords = computed(() => ({
|
|||||||
<a id="radio-station-homepage" :href="radio ? radio.station.homepage || undefined : '#'" target="_blank" rel="noopener noreferrer" :hidden="radio ? !radio.station.homepage : true">{{ t('STATION SITE') }}</a>
|
<a id="radio-station-homepage" :href="radio ? radio.station.homepage || undefined : '#'" target="_blank" rel="noopener noreferrer" :hidden="radio ? !radio.station.homepage : true">{{ t('STATION SITE') }}</a>
|
||||||
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">{{ t('DIRECTORY: RADIO BROWSER') }}</a>
|
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">{{ t('DIRECTORY: RADIO BROWSER') }}</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="radio-privacy">{{ t('Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.') }}</p>
|
<p class="radio-privacy">{{ t('Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.') }}</p>
|
||||||
<SdrCard />
|
<SdrCard />
|
||||||
</RailPanel>
|
</RailPanel>
|
||||||
</template>
|
</template>
|
||||||
@@ -2227,7 +2227,11 @@ export async function controlRadio(
|
|||||||
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
||||||
const prepared = state.selected
|
const prepared = state.selected
|
||||||
? true
|
? true
|
||||||
: radio.cycleStation?.(1, { focus: false, autoplay: false });
|
: radio.cycleStation?.(1, {
|
||||||
|
focus: false,
|
||||||
|
autoplay: false,
|
||||||
|
fallback: true,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ok: Boolean(prepared),
|
ok: Boolean(prepared),
|
||||||
action: 'control_radio',
|
action: 'control_radio',
|
||||||
@@ -2251,6 +2255,7 @@ export async function controlRadio(
|
|||||||
const selected = radio.cycleStation?.(action === 'next' ? 1 : -1, {
|
const selected = radio.cycleStation?.(action === 'next' ? 1 : -1, {
|
||||||
focus: false,
|
focus: false,
|
||||||
autoplay: false,
|
autoplay: false,
|
||||||
|
fallback: true,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
ok: Boolean(selected),
|
ok: Boolean(selected),
|
||||||
|
|||||||
@@ -1905,7 +1905,7 @@ test('voice Radio resolves Austin and exposes semantic selection, volume, pause,
|
|||||||
assert.equal(result.radioAction, 'play');
|
assert.equal(result.radioAction, 'play');
|
||||||
assert.equal(result.stationId, 'nearest');
|
assert.equal(result.stationId, 'nearest');
|
||||||
assert.equal('station' in result, false);
|
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 [
|
for (const invalidCoordinates of [
|
||||||
{ latitude: null, longitude: null },
|
{ latitude: null, longitude: null },
|
||||||
|
|||||||
@@ -591,41 +591,50 @@ test('replacement input invalidates an in-flight Radio preflight immediately', (
|
|||||||
assert.equal(controller.radioHandoffEpoch, priorEpoch + 1);
|
assert.equal(controller.radioHandoffEpoch, priorEpoch + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('direct Radio pause or stop cancels a pending voice handoff', async () => {
|
for (const [name, control, message] of [
|
||||||
const order = [];
|
['direct Radio pause or stop cancels a pending voice handoff', 'stop', 'the explicit stop remains authoritative'],
|
||||||
let playbackControl = null;
|
[
|
||||||
const ui = createTestVoiceUi();
|
'a direct Radio Next that does not play cancels a pending voice handoff',
|
||||||
const controller = new GevRealtimeController({
|
{ action: 'select', origin: 'user', attemptId: null },
|
||||||
ui,
|
'the user selection is not overridden by voice playback',
|
||||||
runner: async () => ({ ok: true }),
|
],
|
||||||
radioLayer: {
|
]) {
|
||||||
subscribePlaybackControls(listener) {
|
test(name, async () => {
|
||||||
playbackControl = listener;
|
const order = [];
|
||||||
return () => { playbackControl = null; };
|
let playbackControl = null;
|
||||||
|
const ui = createTestVoiceUi();
|
||||||
|
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');
|
controller.debugLog = () => {};
|
||||||
return true;
|
controller.dc = {
|
||||||
},
|
readyState: 'open',
|
||||||
stopPlayback: () => order.push('radio-stop'),
|
send() {},
|
||||||
},
|
close() { order.push('voice-stop'); },
|
||||||
});
|
};
|
||||||
controller.debugLog = () => {};
|
controller.pendingRadioPlaybackResult = { ok: true, radioPlaybackRequested: true };
|
||||||
controller.dc = {
|
|
||||||
readyState: 'open',
|
|
||||||
send() {},
|
|
||||||
close() { order.push('voice-stop'); },
|
|
||||||
};
|
|
||||||
controller.pendingRadioPlaybackResult = { ok: true, radioPlaybackRequested: true };
|
|
||||||
|
|
||||||
playbackControl('stop');
|
playbackControl(control);
|
||||||
await controller.handleRealtimeEvent({
|
await controller.handleRealtimeEvent({
|
||||||
data: JSON.stringify({ type: 'response.done', response: { status: 'completed' } }),
|
data: JSON.stringify({ type: 'response.done', response: { status: 'completed' } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(controller.pendingRadioPlaybackResult, null);
|
assert.equal(controller.pendingRadioPlaybackResult, null);
|
||||||
assert.deepEqual(order, [], 'the explicit stop remains authoritative');
|
assert.deepEqual(order, [], message);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('confirmed manual Radio playback closes active voice without stopping Radio', () => {
|
test('confirmed manual Radio playback closes active voice without stopping Radio', () => {
|
||||||
const order = [];
|
const order = [];
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export class RealtimeRadio {
|
|||||||
: control || {};
|
: control || {};
|
||||||
if (
|
if (
|
||||||
event.origin === 'user' &&
|
event.origin === 'user' &&
|
||||||
(event.action === 'pause' || event.action === 'stop')
|
['pause', 'stop', 'select'].includes(event.action)
|
||||||
) {
|
) {
|
||||||
this.cancelRadioHandoff();
|
this.cancelRadioHandoff();
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
Reference in new issue
Block a user