Keep the Radio tuning dial silent unless a stream is playing

Releasing the tuning dial always started the chosen station, plus tuning
static, even when nothing was playing. It now follows the Previous/Next
rule: the release plays only if a stream was loading, buffering or playing
when the drag began; otherwise it just selects the station.

The privacy note no longer lists finishing tuning as a way to connect and
now names voice instead, in all locales. Docs, changelog and comments are
tightened to match.
This commit is contained in:
German Khokhlov committed 2026-11-02 13:27:55 +04:00
1 parent 0ec6735440
commit 857b007fb1
21 files changed
+70 -34

No files matched your search

+4 -1
View File
@@ -902,12 +902,15 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn
stations: stationRows,
failingUrls: [stationRows[0].streamUrl],
}, async ({ playUrls }) => {
assert.equal(radioLayer.selectStation(stationRows[0].id, { autoplay: false }), true);
// A playing stream, so the release plays.
assert.equal(radioLayer.selectStation(stationRows[1].id, { autoplay: true }), true);
await new Promise((resolve) => setTimeout(resolve));
assert.equal(radioLayer.cycleStation(1, {
stationIds: stationRows.map(({ id }) => id),
autoplay: false,
fallback: true,
}), true, 'a voice-style prepared cycle creates the stale-fallback precondition');
playUrls.length = 0;
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);
+26 -2
View File
@@ -1,5 +1,4 @@
// Radio Next/Previous that only select (autoplay: false), against the real layer
// and the panel that renders it.
// 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';
@@ -247,3 +246,28 @@ test('while a stream connects the transport buttons offer Pause, because a click
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]);
});
});
+1 -1
View File
@@ -562,7 +562,7 @@
"Radio volume": "مستوى صوت الراديو",
"Radio off": "الراديو متوقف",
"STATION SITE": "موقع المحطة",
"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 الخاص بك.",
"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": "جارٍ التحميل",
"DEGRADED": "متدهور",
"STALE": "متقادم",
+1 -1
View File
@@ -777,7 +777,7 @@
"Stop radio playback": "Radiowiedergabe stoppen",
"Radio volume": "Radio-Lautstärke",
"STATION SITE": "SENDER-WEBSITE",
"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.": "Beim Starten der Wiedergabe, nach dem Abstimmen oder bei der Wahl eines Senders auf dem Globus 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)",
"Layout": "Layout",
"HUD layout": "HUD-Layout",
+1 -1
View File
@@ -450,7 +450,7 @@
"Radio volume": "Volumen de Radio",
"Radio off": "Radio desactivada",
"STATION SITE": "SITIO DE LA 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.",
"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",
"DEGRADED": "DEGRADADO",
"STALE": "DESACTUALIZADO",
+1 -1
View File
@@ -2819,7 +2819,7 @@
"Radio volume": "Volume de la radio",
"STATION SITE": "SITE DE LA STATION",
"DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER",
"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.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, terminez le réglage ou choisissez une station sur le globe. 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": "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.",
+1 -1
View File
@@ -2819,7 +2819,7 @@
"Radio volume": "Volume radio",
"STATION SITE": "SITO STAZIONE",
"DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER",
"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.": "L'audio si collega direttamente all'emittente dopo aver premuto play, terminato la sintonizzazione o scelto una stazione sul globo. 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": "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.",
+1 -1
View File
@@ -2677,7 +2677,7 @@
"Radio volume": "ラジオの音量",
"STATION SITE": "放送局サイト",
"DIRECTORY: RADIO BROWSER": "ディレクトリ:Radio Browser",
"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アドレスは放送局側から見えます。",
"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": "ローカルRTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · FMとローカルADS-Bレイヤーで1台のローカルチューナーを共有します。",
+1 -1
View File
@@ -2821,7 +2821,7 @@
"Radio volume": "Volume do rádio",
"STATION SITE": "SITE DA ESTAÇÃO",
"DIRECTORY: RADIO BROWSER": "DIRETÓRIO: RADIO BROWSER",
"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.": "O áudio conecta direto à emissora quando você aperta \"Ouvir\", termina de sintonizar ou escolhe uma estação no globo. 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": "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.",
+1 -1
View File
@@ -727,7 +727,7 @@
"STATION TAG": "ТЕГ СТАНЦИИ",
"DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ПРОСМОТР · ENTER / SPACE — ВЫБОР",
"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-адрес.",
"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",
"Filter stations by station tag": "Фильтровать станции по тегу",
"CONTEXT": "КОНТЕКСТ",
+1 -1
View File
@@ -2889,7 +2889,7 @@
"Radio volume": "Гучність радіо",
"STATION SITE": "САЙТ СТАНЦІЇ",
"DIRECTORY: RADIO BROWSER": "КАТАЛОГ: RADIO BROWSER",
"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-адресу.",
"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": "ЛОКАЛЬНИЙ RTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · один локальний тюнер для FM і шару «Локальний ADS-B».",
+1 -1
View File
@@ -2575,7 +2575,7 @@
"Radio volume": "电台音量",
"STATION SITE": "电台网站",
"DIRECTORY: RADIO BROWSER": "目录:RADIO BROWSER",
"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。",
"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": "本地 RTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一个本地调谐器由 FM 与本地 ADS-B 图层共享。",
+1 -1
View File
@@ -2563,7 +2563,7 @@
"Radio volume": "電臺音量",
"STATION SITE": "電臺網站",
"DIRECTORY: RADIO BROWSER": "目錄:RADIO BROWSER",
"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。",
"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": "本地 RTL-SDR",
"Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一個本地調諧器由 FM 與本地 ADS-B 圖層共享。",
+1 -1
View File
@@ -140,7 +140,7 @@ 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.
// A user step to another station cancels a playback voice prepared.
else if (origin === 'user' && changed)
parts.presentation.emitPlaybackControl('select', origin);
return true;
+2
View File
@@ -78,6 +78,8 @@ export function createState({ services }) {
layerState._tuningStartStationId = null;
layerState._tuningResumesPlayback = false;
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._tuningStationById = new Map();
+13 -6
View File
@@ -66,6 +66,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._cancelledTuningPresentationStation = null;
layerState._tuningStartStationId = 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.playback.pauseRadioPlayback({ origin: 'user' });
parts.tuningNoise.syncTuningNoiseGain();
@@ -179,9 +185,8 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._selectionGeneration += 1;
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
layerState._tuningUnavailableStationId = stationId;
// The drag paused the previous stream; with no selection left, Resume
// could only start the nearest station, so release it. Stop publishes
// the state, and as the user's gesture it cancels a voice handoff.
// 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({
ok: false,
@@ -190,9 +195,11 @@ export function createTuning({ state: layerState, services, parts, source }) {
generation,
});
}
const autoplay = layerState._tuningResumesPlayback;
layerState._tuningResumesPlayback = false;
layerState._tuningActive = false;
layerState._tuningStatic = true;
layerState._tuningAwaitingStationId = frozenStation.id;
layerState._tuningStatic = autoplay;
layerState._tuningAwaitingStationId = autoplay ? frozenStation.id : null;
layerState._tuningPreviewId = null;
layerState._tuningStartStationId = null;
layerState._tuningUnavailableStationId = null;
@@ -210,7 +217,7 @@ export function createTuning({ state: layerState, services, parts, source }) {
parts.tuningNoise.syncTuningNoiseGain();
parts.presentation.emitState();
const selected = parts.selection.selectRadioStation(frozenStation.id, {
autoplay: true,
autoplay,
focus: false,
origin,
cameraNavigation,
-2
View File
@@ -1,7 +1,5 @@
/** Shared Radio layer test stand-ins: a station row, fake Audio, directory fetch and viewer. */
import { radioLayer } from '../data/radio.js';
/** One accepted Radio Browser directory row. */
export function radioStationRow(overrides = {}) {
return {
id: '00000000-0000-4000-8000-000000000001',
+1 -1
View File
@@ -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 href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">{{ t('DIRECTORY: RADIO BROWSER') }}</a>
</div>
<p class="radio-privacy">{{ t('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>
<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 />
</RailPanel>
</template>