mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat(ui): render the Radio panel's directory, station card and transport from a store
RadioPanel, lifted from ContextView, renders the layer state, Enable, the tag filter, station card, transport, volume and playback line from the gev-radio store that RadioControls publishes to. The tuner, the local SDR card and the Cockpit radio controls keep their element bindings. Enable's busy mark and the dragged volume go through the owner so a render never leaves them stale, and the markup's tag option stands until the categories first change. ./ui/radio now imports Vue and Pinia.
This commit is contained in:
1 parent
a068a45a89
commit
ccb7edd0d1
15 files changed
+593
-270
No files matched your search
@@ -21,6 +21,10 @@
|
||||
(`./ui/layout`) are deprecated: the Display view and the rail now own what
|
||||
they did. `./ui/input` imports Vue (samehkhamis).
|
||||
|
||||
- `./ui/cctv` and `./ui/radio` import Vue and Pinia: their controllers
|
||||
publish the panel's state to a store that the CCTV and Radio views render,
|
||||
instead of writing the panel's markup themselves (samehkhamis).
|
||||
|
||||
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
|
||||
|
||||
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
|
||||
|
||||
@@ -102,7 +102,11 @@ They contain no application state.
|
||||
style parameter rows render through Vue behind `createStyleParameters()`,
|
||||
as a render function so the portable `./ui/input` entry needs no
|
||||
component compiler; the other groups follow one owner at a time),
|
||||
Context and Radio, the command dock,
|
||||
Context and Radio (in progress: `RadioPanel` renders the directory row,
|
||||
station card, transport, volume and playback line from the `gev-radio`
|
||||
store that RadioControls publishes to; the tuner, the local SDR card and
|
||||
the Cockpit radio controls keep their owners' element bindings), the
|
||||
command dock,
|
||||
the phone shell, then Cockpit. Layers that write into the right rail
|
||||
directly gain a slot instead.
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"src/ui/views/CockpitView.vue",
|
||||
"src/ui/views/CommandDockView.vue",
|
||||
"src/ui/views/ContextView.vue",
|
||||
"src/ui/views/panels/RadioPanel.vue",
|
||||
"src/ui/views/DisplayControlsView.vue",
|
||||
"src/ui/views/LayerPanelsView.vue",
|
||||
"src/ui/views/ProviderSettingsView.vue",
|
||||
@@ -50,11 +51,13 @@
|
||||
"src/ui/cctv/CctvCalValue.vue",
|
||||
"src/ui/cctv/CctvPicker.vue",
|
||||
"src/ui/stores/cctv.js",
|
||||
"src/ui/stores/radio.js",
|
||||
"src/ui/stores/streetLevel.js",
|
||||
"src/ui/rail/RailChips.vue",
|
||||
"src/ui/views/panels/DisplayPanel.vue",
|
||||
"src/ui/stores/display.js",
|
||||
"src/ui/rail/rangeValue.js"
|
||||
"src/ui/rail/rangeValue.js",
|
||||
"src/ui/rail/classToggle.js"
|
||||
],
|
||||
"external": ["vue", "pinia"]
|
||||
},
|
||||
@@ -622,6 +625,8 @@
|
||||
"src/ui/radioControls.js",
|
||||
"src/ui/radioBindings.js",
|
||||
"src/ui/radioPresentation.js",
|
||||
"src/ui/vueRuntime.js",
|
||||
"src/ui/stores/radio.js",
|
||||
"src/ui/radioTunerModel.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
@@ -636,7 +641,7 @@
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
],
|
||||
"external": []
|
||||
"external": ["vue", "pinia"]
|
||||
},
|
||||
"cctv-controls": {
|
||||
"exports": ["./ui/cctv"],
|
||||
@@ -959,6 +964,7 @@
|
||||
"src/ui/weather/WeatherReadout.vue",
|
||||
"src/ui/stores/streetLevel.js",
|
||||
"src/ui/stores/cctv.js",
|
||||
"src/ui/stores/radio.js",
|
||||
"src/ui/stores/display.js"
|
||||
],
|
||||
"external": ["cesium", "vue", "pinia"]
|
||||
@@ -2927,6 +2933,7 @@
|
||||
"src/ui/recentImagery/RecentImageryReadout.vue",
|
||||
"src/ui/stores/streetLevel.js",
|
||||
"src/ui/stores/cctv.js",
|
||||
"src/ui/stores/radio.js",
|
||||
"src/ui/stores/display.js"
|
||||
],
|
||||
"external": [
|
||||
|
||||
@@ -2957,7 +2957,7 @@ async function main() {
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.dataManager.layers.get('radio').module.update();
|
||||
});
|
||||
const tunerRefreshRelease = await page.evaluate((target) => {
|
||||
const tunerRefreshRelease = await page.evaluate(async (target) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
const displayedBeforeRelease = document.getElementById('radio-tuner-station').textContent;
|
||||
@@ -2970,6 +2970,10 @@ async function main() {
|
||||
clientY: target.clientY,
|
||||
}));
|
||||
const state = gev.dataManager.layers.get('radio').module.getUIState();
|
||||
// The playback line renders from the Radio store once Vue flushes.
|
||||
const message = await Promise.resolve().then(
|
||||
() => document.getElementById('radio-playback-state').textContent,
|
||||
);
|
||||
return {
|
||||
displayedBeforeRelease,
|
||||
frozenGeneration,
|
||||
@@ -2978,7 +2982,7 @@ async function main() {
|
||||
plays: window.__qaRadioPlayCalls.length,
|
||||
value: document.getElementById('radio-tuner-value').textContent,
|
||||
stationText: document.getElementById('radio-tuner-station').textContent,
|
||||
message: document.getElementById('radio-playback-state').textContent,
|
||||
message,
|
||||
targetId: target.id,
|
||||
};
|
||||
}, tunerRefreshTarget);
|
||||
|
||||
@@ -130,16 +130,20 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
|
||||
test('Context async action buttons remain focused while busy', () => {
|
||||
const init = _initGlobalContextPanel.toString();
|
||||
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._cockpitRadioToggleBtn,'));
|
||||
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
|
||||
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('this._radioView = {'));
|
||||
const radioPanel = readFileSync(new URL('./ui/views/panels/RadioPanel.vue', import.meta.url), 'utf8');
|
||||
const clear = clearSelectedLayers.toString();
|
||||
|
||||
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
|
||||
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
|
||||
for (const name of ['_radioEnableBtn', '_cockpitRadioEnableBtn']) {
|
||||
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
|
||||
}
|
||||
assert.match(radioSync, /_cockpitRadioEnableBtn\.disabled = false/);
|
||||
// The Radio view never disables its Enable button; it marks it busy.
|
||||
const enable = radioPanel.match(/<button id="radio-enable-btn"[^>]*>/)?.[0];
|
||||
assert.ok(enable, 'Radio Enable button is missing');
|
||||
assert.doesNotMatch(enable, /disabled(?!=")|:disabled/);
|
||||
assert.match(enable, /:aria-busy=/);
|
||||
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
|
||||
assert.match(clear, /this\.setClearBusy\(true\)/);
|
||||
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
|
||||
|
||||
@@ -89,17 +89,18 @@ test('radio option text and accessible name use the same localized parentheses',
|
||||
try {
|
||||
for (const [locale, label, expected] of [['zh-CN', '全部', '全部(750)'], ['en', 'All', 'All (750)']]) {
|
||||
setLocale(locale);
|
||||
let shown = null;
|
||||
const host = {
|
||||
_radioPanel: element(), _radioFilter: element(),
|
||||
_radioPanel: element(),
|
||||
store: { publish: (view) => (shown = view) },
|
||||
actions: { getLifecycle: () => null, scheduleLayout() {} },
|
||||
};
|
||||
renderRadioState.call(host, {
|
||||
enabled: true, categories: [{ id: 'all', label, count: 750, color: '' }],
|
||||
filter: 'all', filteredCount: 750, stationCount: 750, volume: 1,
|
||||
});
|
||||
const option = host._radioFilter.children[0];
|
||||
assert.equal(option.textContent, `● ${expected}`);
|
||||
assert.equal(option.attributes['aria-label'], expected);
|
||||
// The Radio panel shows it as `● label` and names the option `label`.
|
||||
assert.equal(shown.filter.options[0].label, expected);
|
||||
}
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
|
||||
+15
-6
@@ -1,4 +1,5 @@
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { showRadioEnableBusy, showRadioVolume } from './radioPresentation.js';
|
||||
import {
|
||||
radioTunerSlot,
|
||||
radioTunerCommitSlot,
|
||||
@@ -338,13 +339,23 @@ export function bindRadioControls() {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// The panel's Enable button renders from the Radio store; the Cockpit one
|
||||
// is still written directly.
|
||||
const setTriggerBusy = (trigger, busy) => {
|
||||
if (
|
||||
trigger === this._radioEnableBtn &&
|
||||
showRadioEnableBusy.call(this, busy)
|
||||
)
|
||||
return;
|
||||
trigger.setAttribute('aria-disabled', String(busy));
|
||||
trigger.setAttribute('aria-busy', String(busy));
|
||||
};
|
||||
const toggleRadio = async (trigger) => {
|
||||
if (!this.actions.isRegistered()) return;
|
||||
if (trigger.getAttribute('aria-busy') === 'true') return;
|
||||
const enabling = !this.actions.isEnabled();
|
||||
const revealAfterEnable = enabling && trigger === this._radioEnableBtn;
|
||||
trigger.setAttribute('aria-disabled', 'true');
|
||||
trigger.setAttribute('aria-busy', 'true');
|
||||
setTriggerBusy(trigger, true);
|
||||
try {
|
||||
const toggled = await this.actions.runUserAction(
|
||||
(notificationToken) =>
|
||||
@@ -377,8 +388,7 @@ export function bindRadioControls() {
|
||||
await this._revealRadioControlsAfterExplicitEnable(trigger);
|
||||
} finally {
|
||||
if (!this.destroyed) {
|
||||
trigger.setAttribute('aria-disabled', 'false');
|
||||
trigger.setAttribute('aria-busy', 'false');
|
||||
setTriggerBusy(trigger, false);
|
||||
if (revealAfterEnable && trigger.isConnected)
|
||||
trigger.focus({ preventScroll: true });
|
||||
}
|
||||
@@ -505,8 +515,7 @@ export function bindRadioControls() {
|
||||
);
|
||||
this.listen(this._radioVolume, 'input', () => {
|
||||
const value = Number(this._radioVolume.value);
|
||||
if (this._radioVolumeValue)
|
||||
this._radioVolumeValue.textContent = `${value}%`;
|
||||
showRadioVolume.call(this, value);
|
||||
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
|
||||
});
|
||||
// Cockpit owns the Cesium camera even though it intentionally clears
|
||||
|
||||
+18
-2
@@ -1,10 +1,23 @@
|
||||
import { bindRadioControls } from './radioBindings.js';
|
||||
import { renderRadioState } from './radioPresentation.js';
|
||||
import { useRadioStore } from './stores/radio.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
|
||||
/** Own Radio DOM, tuner interaction and subscription; receive playback and application actions. */
|
||||
/**
|
||||
* Own Radio interaction, the tuner and the subscription; receive playback and
|
||||
* application actions. The panel view renders the rest of the Radio panel
|
||||
* from what this publishes (`gev-radio` store).
|
||||
*/
|
||||
export class RadioControls {
|
||||
constructor({ elements, radio, actions, canvas }) {
|
||||
constructor({
|
||||
elements,
|
||||
radio,
|
||||
actions,
|
||||
canvas,
|
||||
store = useRadioStore(applicationPinia()),
|
||||
}) {
|
||||
Object.assign(this, elements);
|
||||
this.store = store;
|
||||
this.radio = radio;
|
||||
this.actions = actions;
|
||||
this.canvas = canvas;
|
||||
@@ -12,7 +25,10 @@ export class RadioControls {
|
||||
this.listeners = new AbortController();
|
||||
this._radioUnsubscribe = null;
|
||||
this._radioState = null;
|
||||
this._radioView = null;
|
||||
this._radioCategorySignature = '';
|
||||
this._radioFilterOptions = null;
|
||||
this._radioVolumeShown = null;
|
||||
this._radioTunerStations = [];
|
||||
this._radioTunerPool = [];
|
||||
this._radioTunerDragging = false;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// src/ui/radioPanel.test.mjs — the Radio panel renders what RadioControls
|
||||
// presents (`renderRadioState()`), over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { useRadioStore } from './stores/radio.js';
|
||||
import RadioPanel from './views/panels/RadioPanel.vue';
|
||||
import {
|
||||
renderRadioState,
|
||||
showRadioEnableBusy,
|
||||
showRadioVolume,
|
||||
} from './radioPresentation.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(RadioPanel).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let lifecycle = null;
|
||||
const owner = {
|
||||
destroyed: false,
|
||||
store: useRadioStore(pinia),
|
||||
_radioPanel: $('radio-panel'),
|
||||
_radioVolume: $('radio-volume'),
|
||||
actions: {
|
||||
getLifecycle: () => lifecycle,
|
||||
preservePanelStateDuringClear: () => true,
|
||||
setPanelCollapsed() {},
|
||||
scheduleLayout() {},
|
||||
},
|
||||
};
|
||||
const render = async (state) => {
|
||||
renderRadioState.call(owner, {
|
||||
enabled: true,
|
||||
categories: [
|
||||
{ id: 'all', label: 'All', count: 3, color: '' },
|
||||
{ id: 'jazz', label: 'Jazz', count: 1, color: '#ffb547' },
|
||||
],
|
||||
filter: 'all',
|
||||
filteredCount: 3,
|
||||
stationCount: 3,
|
||||
volume: 0.8,
|
||||
audioState: 'stopped',
|
||||
...state,
|
||||
});
|
||||
await nextTick();
|
||||
};
|
||||
const setLifecycle = (next) => (lifecycle = next);
|
||||
return { $, owner, render, setLifecycle };
|
||||
}
|
||||
|
||||
test('before RadioControls renders, the panel shows its initial markup', (t) => {
|
||||
const { $ } = fixture(t);
|
||||
assert.equal($('radio-layer-state').textContent, 'OFF');
|
||||
assert.equal($('radio-enable-btn').textContent, 'ENABLE');
|
||||
assert.equal($('radio-enable-btn').hasAttribute('aria-busy'), false);
|
||||
assert.equal($('radio-filter').disabled, true);
|
||||
assert.equal($('radio-filter').options.length, 1);
|
||||
assert.equal($('radio-filter').options[0].value, 'all');
|
||||
assert.equal($('radio-filter').options[0].textContent, 'All');
|
||||
assert.equal($('radio-play-btn').getAttribute('class'), null);
|
||||
assert.equal($('radio-play-btn').disabled, true);
|
||||
assert.equal($('radio-volume').value, '80');
|
||||
assert.equal($('radio-volume-value').textContent, '80%');
|
||||
assert.equal($('radio-playback-state').textContent, 'Radio off');
|
||||
assert.equal($('radio-station-homepage').getAttribute('href'), '#');
|
||||
assert.equal($('radio-station-homepage').hidden, true);
|
||||
});
|
||||
|
||||
test('the panel renders the presented directory, station, transport and playback', async (t) => {
|
||||
const { $, render } = fixture(t);
|
||||
const panel = $('radio-panel');
|
||||
await render({
|
||||
selected: {
|
||||
name: 'KJAZ',
|
||||
state: 'Texas',
|
||||
countryCode: 'US',
|
||||
codec: 'MP3',
|
||||
bitrate: 128,
|
||||
tags: ['jazz', 'blues'],
|
||||
homepage: 'https://example.org/',
|
||||
},
|
||||
selectedIndex: 0,
|
||||
audioState: 'playing',
|
||||
filter: 'jazz',
|
||||
});
|
||||
assert.equal(panel.classList.contains('radio-enabled'), true);
|
||||
assert.equal(panel.classList.contains('collapsed'), true, 'chrome keeps its class');
|
||||
assert.equal($('radio-layer-state').className, 'radio-layer-state active');
|
||||
assert.equal($('radio-layer-state').textContent, '3/3');
|
||||
const enable = $('radio-enable-btn');
|
||||
assert.equal(enable.className, 'panel-layer-toggle active');
|
||||
assert.equal(enable.textContent, 'DISABLE');
|
||||
assert.equal(enable.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(enable.getAttribute('aria-label'), 'Disable Radio');
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'false');
|
||||
const filter = $('radio-filter');
|
||||
const [all, jazz] = filter.options;
|
||||
assert.equal(filter.options.length, 2);
|
||||
assert.equal(jazz.textContent, '● Jazz (1)');
|
||||
assert.equal(jazz.getAttribute('aria-label'), 'Jazz (1)');
|
||||
assert.equal(jazz.dataset.radioColor, '#ffb547');
|
||||
assert.equal(all.getAttribute('aria-label'), 'All (3)');
|
||||
assert.equal(filter.value, 'jazz');
|
||||
assert.equal(filter.disabled, false);
|
||||
assert.ok(filter.style.color);
|
||||
assert.equal($('radio-station-name').textContent, 'KJAZ');
|
||||
assert.equal($('radio-station-meta').textContent, 'Texas · US / MP3 · 128 kbps');
|
||||
assert.equal($('radio-station-tags').textContent, 'TAGS · jazz · blues');
|
||||
assert.equal($('radio-station-homepage').getAttribute('href'), 'https://example.org/');
|
||||
assert.equal($('radio-station-homepage').hidden, false);
|
||||
const play = $('radio-play-btn');
|
||||
assert.equal(play.className, 'active');
|
||||
assert.equal(play.textContent, 'PAUSE');
|
||||
assert.equal(play.getAttribute('aria-label'), 'Pause selected radio station');
|
||||
assert.equal($('radio-prev-btn').disabled, false);
|
||||
assert.equal($('radio-stop-btn').disabled, false);
|
||||
assert.equal($('radio-playback-state').textContent, 'Playing KJAZ');
|
||||
|
||||
await render({ audioState: 'error', filter: 'all' });
|
||||
assert.equal(play.className, '');
|
||||
assert.equal($('radio-playback-state').className, 'radio-playback-state error');
|
||||
assert.equal($('radio-station-homepage').hasAttribute('href'), false);
|
||||
assert.equal($('radio-station-homepage').hidden, true);
|
||||
assert.equal(filter.getAttribute('style'), null);
|
||||
});
|
||||
|
||||
test('the markup’s tag option stands until the categories first change', async (t) => {
|
||||
const { $, render } = fixture(t);
|
||||
await render({ enabled: false, categories: [], filteredCount: 0, stationCount: 0 });
|
||||
const filter = $('radio-filter');
|
||||
assert.equal(filter.options.length, 1);
|
||||
assert.equal(filter.options[0].textContent, 'All');
|
||||
await render({});
|
||||
assert.equal(filter.options.length, 2);
|
||||
await render({ categories: [] });
|
||||
assert.equal(filter.options.length, 0, 'categories that empty clear the list');
|
||||
});
|
||||
|
||||
test('a render puts back the published tag after a refused pick', async (t) => {
|
||||
const { $, render } = fixture(t);
|
||||
await render({});
|
||||
const filter = $('radio-filter');
|
||||
filter.value = 'jazz';
|
||||
await render({});
|
||||
assert.equal(filter.value, 'all');
|
||||
});
|
||||
|
||||
test('a volume the person is dragging is not rewritten; its readout follows the drag', async (t) => {
|
||||
const { $, owner, render } = fixture(t);
|
||||
await render({ volume: 0.5 });
|
||||
const volume = $('radio-volume');
|
||||
assert.equal(volume.value, '50');
|
||||
volume.focus();
|
||||
volume.value = '30';
|
||||
showRadioVolume.call(owner, 30);
|
||||
await nextTick();
|
||||
assert.equal($('radio-volume-value').textContent, '30%');
|
||||
await render({ volume: 0.9 });
|
||||
assert.equal(volume.value, '30');
|
||||
assert.equal($('radio-volume-value').textContent, '30%');
|
||||
volume.blur();
|
||||
await render({ volume: 0.9 });
|
||||
assert.equal(volume.value, '90');
|
||||
assert.equal($('radio-volume-value').textContent, '90%');
|
||||
});
|
||||
|
||||
test('a Radio lifecycle in transition marks Enable busy without disabling it', async (t) => {
|
||||
const { $, render, setLifecycle } = fixture(t);
|
||||
setLifecycle({ lifecycleState: 'enabling', enabled: false, uncertain: false });
|
||||
await render({});
|
||||
const enable = $('radio-enable-btn');
|
||||
assert.equal(enable.textContent, 'ENABLING');
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(enable.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(enable.disabled, false);
|
||||
assert.equal($('radio-layer-state').textContent, 'ENABLING');
|
||||
assert.equal($('radio-playback-state').textContent, 'Radio is enabling…');
|
||||
setLifecycle({ lifecycleState: 'enabled', enabled: true, uncertain: true });
|
||||
await render({});
|
||||
assert.equal(enable.textContent, 'RECONCILE');
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'false');
|
||||
assert.equal($('radio-panel').classList.contains('lifecycle-uncertain'), true);
|
||||
assert.equal($('radio-playback-state').className, 'radio-playback-state error');
|
||||
});
|
||||
|
||||
test('tag options show localized text and names', async (t) => {
|
||||
const { $, render } = fixture(t);
|
||||
setLocale('zh-CN');
|
||||
t.after(() => setLocale('en'));
|
||||
await render({ categories: [{ id: 'all', label: '全部', count: 750, color: '' }] });
|
||||
const [option] = $('radio-filter').options;
|
||||
assert.equal(option.textContent, '● 全部(750)');
|
||||
assert.equal(option.getAttribute('aria-label'), '全部(750)');
|
||||
});
|
||||
|
||||
test('a toggle that ends mid-transition does not leave Enable marked idle', async (t) => {
|
||||
const { $, owner, render, setLifecycle } = fixture(t);
|
||||
await render({});
|
||||
const enable = $('radio-enable-btn');
|
||||
showRadioEnableBusy.call(owner, true);
|
||||
await nextTick();
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'true');
|
||||
setLifecycle({ lifecycleState: 'enabling', enabled: false, uncertain: false });
|
||||
await render({});
|
||||
// The toggle was refused while the lifecycle is still enabling.
|
||||
showRadioEnableBusy.call(owner, false);
|
||||
await nextTick();
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'false');
|
||||
await render({});
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'true', 'the next render reports it');
|
||||
assert.equal(enable.getAttribute('aria-disabled'), 'true');
|
||||
});
|
||||
+128
-115
@@ -1,4 +1,9 @@
|
||||
/** Render Radio state without making playback or Context decisions. */
|
||||
/**
|
||||
* Present Radio state without making playback or Context decisions: the
|
||||
* panel view renders the directory row, station card, transport, volume and
|
||||
* playback line from what this publishes (`gev-radio` store); the tuner and
|
||||
* the Cockpit radio controls are still written here.
|
||||
*/
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export function renderRadioState(state) {
|
||||
@@ -24,22 +29,17 @@ export function renderRadioState(state) {
|
||||
document
|
||||
.getElementById('title-bar')
|
||||
?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
|
||||
this._radioPanel.classList.toggle('radio-enabled', enabled);
|
||||
this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain);
|
||||
this._radioLayerState?.classList.toggle('active', enabled);
|
||||
const lifecycleLabel =
|
||||
lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING');
|
||||
if (this._radioLayerState) {
|
||||
this._radioLayerState.textContent = transitioning
|
||||
? lifecycleLabel
|
||||
: uncertain
|
||||
? t('UNCERTAIN')
|
||||
: state.loading
|
||||
? t('SYNC')
|
||||
: enabled
|
||||
? `${state.filteredCount}/${state.stationCount}`
|
||||
: t('OFF');
|
||||
}
|
||||
const layerStateText = transitioning
|
||||
? lifecycleLabel
|
||||
: uncertain
|
||||
? t('UNCERTAIN')
|
||||
: state.loading
|
||||
? t('SYNC')
|
||||
: enabled
|
||||
? `${state.filteredCount}/${state.stationCount}`
|
||||
: t('OFF');
|
||||
const enableLabel = uncertain
|
||||
? t('RECONCILE')
|
||||
: enabled
|
||||
@@ -50,17 +50,6 @@ export function renderRadioState(state) {
|
||||
: enabled
|
||||
? t('Disable Radio')
|
||||
: t('Enable Radio');
|
||||
if (this._radioEnableBtn) {
|
||||
this._radioEnableBtn.classList.toggle('active', enabled);
|
||||
this._radioEnableBtn.setAttribute('aria-pressed', String(enabled));
|
||||
this._radioEnableBtn.textContent = transitioning
|
||||
? lifecycleLabel
|
||||
: enableLabel;
|
||||
this._radioEnableBtn.setAttribute('aria-label', enableAria);
|
||||
this._radioEnableBtn.disabled = false;
|
||||
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
|
||||
this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
|
||||
}
|
||||
if (this._cockpitRadioEnableBtn) {
|
||||
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
|
||||
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
|
||||
@@ -79,36 +68,26 @@ export function renderRadioState(state) {
|
||||
);
|
||||
}
|
||||
|
||||
if (this._radioFilter) {
|
||||
const prior = state.filter || 'all';
|
||||
const categorySignature = state.categories
|
||||
.map((category) => `${category.id}:${category.count}:${category.color}`)
|
||||
.join('|');
|
||||
if (categorySignature !== this._radioCategorySignature) {
|
||||
this._radioFilter.replaceChildren(
|
||||
...state.categories.map((category) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = category.id;
|
||||
const label = t('{category} ({count})', {
|
||||
category: category.label,
|
||||
count: category.count,
|
||||
});
|
||||
option.textContent = `● ${label}`;
|
||||
option.dataset.radioColor = category.color;
|
||||
option.style.color = category.color;
|
||||
option.setAttribute('aria-label', label);
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
this._radioCategorySignature = categorySignature;
|
||||
}
|
||||
this._radioFilter.value = prior;
|
||||
const activeCategory = state.categories.find(
|
||||
(category) => category.id === prior,
|
||||
);
|
||||
this._radioFilter.style.color = activeCategory?.color || '';
|
||||
this._radioFilter.disabled = !interactive || !state.stationCount;
|
||||
const filter = state.filter || 'all';
|
||||
const categories = state.categories || [];
|
||||
// The options change only with the categories; until they first do, the
|
||||
// markup's own option stands.
|
||||
const categorySignature = categories
|
||||
.map((category) => `${category.id}:${category.count}:${category.color}`)
|
||||
.join('|');
|
||||
if (categorySignature !== (this._radioCategorySignature ?? '')) {
|
||||
this._radioCategorySignature = categorySignature;
|
||||
this._radioFilterOptions = categories.map((category) => ({
|
||||
id: category.id,
|
||||
color: category.color,
|
||||
label: t('{category} ({count})', {
|
||||
category: category.label,
|
||||
count: category.count,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
const filterColor =
|
||||
categories.find((category) => category.id === filter)?.color || '';
|
||||
|
||||
const tunerAvailable = interactive && state.filteredCount > 0;
|
||||
if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable;
|
||||
@@ -141,42 +120,22 @@ export function renderRadioState(state) {
|
||||
this._radioTunerSelectedId = null;
|
||||
}
|
||||
|
||||
if (this._radioStationName)
|
||||
this._radioStationName.textContent =
|
||||
selected?.name || t('NO STATION SELECTED');
|
||||
if (this._radioStationMeta) {
|
||||
const place = selected
|
||||
? [selected.state, selected.countryCode].filter(Boolean).join(' · ')
|
||||
: '';
|
||||
const signal = selected
|
||||
? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: '';
|
||||
this._radioStationMeta.textContent = selected
|
||||
? [place, signal].filter(Boolean).join(' / ') ||
|
||||
t('Directory metadata only')
|
||||
: state.loading
|
||||
? t('Loading station directory…')
|
||||
: t('Choose a globe marker or use next.');
|
||||
}
|
||||
if (this._radioStationTags) {
|
||||
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
|
||||
this._radioStationTags.textContent = tags.length
|
||||
? t('TAGS · {tags}', { tags: tags.join(' · ') })
|
||||
: '';
|
||||
}
|
||||
if (this._radioStationHomepage) {
|
||||
const homepage = selected?.homepage || '';
|
||||
this._radioStationHomepage.hidden = !homepage;
|
||||
if (homepage) this._radioStationHomepage.href = homepage;
|
||||
else this._radioStationHomepage.removeAttribute('href');
|
||||
}
|
||||
const place = selected
|
||||
? [selected.state, selected.countryCode].filter(Boolean).join(' · ')
|
||||
: '';
|
||||
const signal = selected
|
||||
? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: '';
|
||||
const stationMeta = selected
|
||||
? [place, signal].filter(Boolean).join(' / ') ||
|
||||
t('Directory metadata only')
|
||||
: state.loading
|
||||
? t('Loading station directory…')
|
||||
: t('Choose a globe marker or use next.');
|
||||
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
|
||||
|
||||
if (this._radioPrevBtn)
|
||||
this._radioPrevBtn.disabled = !interactive || !hasStations;
|
||||
if (this._radioNextBtn)
|
||||
this._radioNextBtn.disabled = !interactive || !hasStations;
|
||||
if (this._cockpitRadioPrevBtn)
|
||||
this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
|
||||
if (this._cockpitRadioNextBtn)
|
||||
@@ -197,12 +156,6 @@ export function renderRadioState(state) {
|
||||
action: playbackAction,
|
||||
target: playTargetWord,
|
||||
});
|
||||
if (this._radioPlayBtn) {
|
||||
this._radioPlayBtn.disabled = !interactive || !hasStations;
|
||||
this._radioPlayBtn.classList.toggle('active', activePlayback);
|
||||
this._radioPlayBtn.textContent = playbackButtonLabel;
|
||||
this._radioPlayBtn.setAttribute('aria-label', playAria);
|
||||
}
|
||||
if (this._cockpitRadioPlayBtn) {
|
||||
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
|
||||
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
|
||||
@@ -210,15 +163,10 @@ export function renderRadioState(state) {
|
||||
this._cockpitRadioPlayBtn.setAttribute('aria-label', playAria);
|
||||
this._cockpitRadioPlayBtn.title = playbackAction;
|
||||
}
|
||||
if (this._radioStopBtn)
|
||||
this._radioStopBtn.disabled =
|
||||
!interactive || state.audioState === 'stopped';
|
||||
if (this._radioVolume) this._radioVolume.disabled = !interactive;
|
||||
if (this._radioVolume && document.activeElement !== this._radioVolume) {
|
||||
this._radioVolume.value = String(Math.round(state.volume * 100));
|
||||
if (this._radioVolumeValue)
|
||||
this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
|
||||
}
|
||||
// A slider the person is dragging keeps the value they are setting.
|
||||
if (document.activeElement !== this._radioVolume)
|
||||
this._radioVolumeShown = Math.round(state.volume * 100);
|
||||
else this._radioVolumeShown ??= Number(this._radioVolume?.value ?? 80);
|
||||
if (
|
||||
this._cockpitRadioVolume &&
|
||||
document.activeElement !== this._cockpitRadioVolume
|
||||
@@ -235,7 +183,9 @@ export function renderRadioState(state) {
|
||||
? t('UNCERTAIN')
|
||||
: selected?.name || (state.loading ? t('SYNCING') : t('READY'));
|
||||
}
|
||||
if (this._radioPlaybackState) {
|
||||
let playbackText = '';
|
||||
let playbackError = false;
|
||||
{
|
||||
const stationName = selected?.name || t('station');
|
||||
const catalogSuffix = state.degraded
|
||||
? state.stale
|
||||
@@ -284,17 +234,50 @@ export function renderRadioState(state) {
|
||||
: null;
|
||||
// Directory errors are msg() literals from the radio layer's ingestion.
|
||||
const directoryError = state.error ? t(state.error) : null;
|
||||
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || directoryError || messages[state.audioState] || t('Ready')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
|
||||
this._radioPlaybackState.classList.toggle(
|
||||
'error',
|
||||
Boolean(
|
||||
uncertainMessage ||
|
||||
unavailable ||
|
||||
state.error ||
|
||||
state.audioState === 'error',
|
||||
),
|
||||
playbackText = `${uncertainMessage || unavailable || lifecycleMessage || directoryError || messages[state.audioState] || t('Ready')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
|
||||
playbackError = Boolean(
|
||||
uncertainMessage ||
|
||||
unavailable ||
|
||||
state.error ||
|
||||
state.audioState === 'error',
|
||||
);
|
||||
}
|
||||
this._radioView = {
|
||||
enabled,
|
||||
uncertain,
|
||||
layerState: { active: enabled, text: layerStateText },
|
||||
enable: {
|
||||
active: enabled,
|
||||
text: transitioning ? lifecycleLabel : enableLabel,
|
||||
label: enableAria,
|
||||
busy: transitioning,
|
||||
},
|
||||
filter: {
|
||||
options: this._radioFilterOptions ?? null,
|
||||
value: filter,
|
||||
color: filterColor,
|
||||
disabled: !interactive || !state.stationCount,
|
||||
},
|
||||
station: {
|
||||
name: selected?.name || t('NO STATION SELECTED'),
|
||||
meta: stationMeta,
|
||||
tags: tags.length ? t('TAGS · {tags}', { tags: tags.join(' · ') }) : '',
|
||||
homepage: selected?.homepage || '',
|
||||
},
|
||||
transport: {
|
||||
stepDisabled: !interactive || !hasStations,
|
||||
play: {
|
||||
disabled: !interactive || !hasStations,
|
||||
active: activePlayback,
|
||||
text: playbackButtonLabel,
|
||||
label: playAria,
|
||||
},
|
||||
stopDisabled: !interactive || state.audioState === 'stopped',
|
||||
},
|
||||
volume: { disabled: !interactive, value: this._radioVolumeShown },
|
||||
playback: { text: playbackText, error: playbackError },
|
||||
};
|
||||
this.store?.publish(this._radioView);
|
||||
if (
|
||||
!enabled &&
|
||||
!transitioning &&
|
||||
@@ -305,3 +288,33 @@ export function renderRadioState(state) {
|
||||
}
|
||||
this.actions.scheduleLayout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the volume the person is setting before the layer reports it.
|
||||
* @param {number} value Percent, as the slider holds it.
|
||||
*/
|
||||
export function showRadioVolume(value) {
|
||||
this._radioVolumeShown = value;
|
||||
if (!this._radioView) return;
|
||||
this._radioView = {
|
||||
...this._radioView,
|
||||
volume: { ...this._radioView.volume, value },
|
||||
};
|
||||
this.store?.publish(this._radioView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the panel's Enable button busy while its own toggle runs, as a render
|
||||
* would until the next one reports the lifecycle.
|
||||
* @param {boolean} busy
|
||||
* @returns {boolean} False before the first render, when nothing is shown yet.
|
||||
*/
|
||||
export function showRadioEnableBusy(busy) {
|
||||
if (!this._radioView) return false;
|
||||
this._radioView = {
|
||||
...this._radioView,
|
||||
enable: { ...this._radioView.enable, busy },
|
||||
};
|
||||
this.store?.publish(this._radioView);
|
||||
return true;
|
||||
}
|
||||
@@ -18,10 +18,11 @@ function el() {
|
||||
|
||||
function render(audioError) {
|
||||
globalThis.document = { getElementById: () => null, activeElement: null };
|
||||
let shown = null;
|
||||
const host = {
|
||||
destroyed: false,
|
||||
_radioPanel: el(),
|
||||
_radioPlaybackState: el(),
|
||||
store: { publish: (view) => (shown = view) },
|
||||
actions: {
|
||||
getLifecycle: () => null,
|
||||
preservePanelStateDuringClear: () => false,
|
||||
@@ -37,7 +38,7 @@ function render(audioError) {
|
||||
stationCount: 1,
|
||||
volume: 1,
|
||||
});
|
||||
return host._radioPlaybackState.textContent;
|
||||
return shown.playback.text;
|
||||
}
|
||||
|
||||
const PLAYBACK_ERRORS = [
|
||||
|
||||
@@ -22,11 +22,11 @@ for (const lifecycleState of ['enabling', 'disabling']) {
|
||||
const prior = globalThis.document;
|
||||
globalThis.document = { getElementById: () => null };
|
||||
try {
|
||||
const enable = el();
|
||||
let shown = null;
|
||||
const ctx = {
|
||||
destroyed: false,
|
||||
_radioPanel: el(),
|
||||
_radioEnableBtn: enable,
|
||||
store: { publish: (view) => (shown = view) },
|
||||
actions: {
|
||||
getLifecycle: () => ({
|
||||
lifecycleState,
|
||||
@@ -55,8 +55,8 @@ for (const lifecycleState of ['enabling', 'disabling']) {
|
||||
`render threw: ${error.message}`,
|
||||
);
|
||||
}
|
||||
assert.equal(enable.attrs.get('aria-busy'), 'true');
|
||||
assert.equal(enable.textContent, lifecycleState.toUpperCase());
|
||||
assert.equal(shown.enable.busy, true);
|
||||
assert.equal(shown.enable.text, lifecycleState.toUpperCase());
|
||||
} finally {
|
||||
globalThis.document = prior;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* The Radio panel as its view renders it. RadioControls presents the
|
||||
* directory row, station card, transport, volume and playback line
|
||||
* (`renderRadioState()`) and publishes them here; it keeps its listeners,
|
||||
* the tuner and the Cockpit radio controls.
|
||||
*/
|
||||
export const useRadioStore = defineStore('gev-radio', () => {
|
||||
/** The presented panel; null until the controls first render. */
|
||||
const view = shallowRef(null);
|
||||
|
||||
/** @param {object} next A fresh presentation; every publish re-renders. */
|
||||
function publish(next) {
|
||||
view.value = markRaw(next);
|
||||
}
|
||||
|
||||
return { view, publish };
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
<script setup>
|
||||
import RadioPanel from './panels/RadioPanel.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Global Context is deliberately independent from the left layer accordion. -->
|
||||
<aside id="right-context-rail">
|
||||
@@ -81,134 +85,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label>
|
||||
<div class="radio-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>RADIO</span>
|
||||
<span id="radio-layer-state" class="radio-layer-state" data-i18n>OFF</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section" data-i18n-title data-i18n-aria-label>+</button>
|
||||
</div>
|
||||
<div class="radio-directory-row">
|
||||
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
|
||||
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
|
||||
<select class="gev-field" id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
|
||||
<option value="all" data-i18n data-i18n-context="radio tag">All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
|
||||
<strong id="radio-station-name" data-i18n>NO STATION SELECTED</strong>
|
||||
<span id="radio-station-meta" data-i18n>Enable Radio, then choose a globe marker or use next.</span>
|
||||
<span id="radio-station-tags"></span>
|
||||
</div>
|
||||
<div id="radio-tuner" class="radio-tuner" hidden>
|
||||
<div class="radio-tuner-header">
|
||||
<span data-i18n id="radio-tuner-band-label">DIRECTORY BAND</span>
|
||||
<output id="radio-tuner-value" for="radio-tuner-slider" data-i18n>DRAG TO TUNE</output>
|
||||
</div>
|
||||
<div class="radio-tuner-dial">
|
||||
<div class="radio-tuner-scale" aria-hidden="true"></div>
|
||||
<div id="radio-tuner-needle" class="radio-tuner-needle" aria-hidden="true"></div>
|
||||
<input id="radio-tuner-slider" type="range" min="0" max="0" step="1" value="0"
|
||||
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled data-i18n-aria-label />
|
||||
</div>
|
||||
<div class="radio-tuner-readout">
|
||||
<span id="radio-tuner-station" data-i18n>ALL · DRAG THE NEEDLE</span>
|
||||
<span data-i18n>SNAPS TO AVAILABLE STATIONS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-transport" role="group" aria-label="Radio playback" data-i18n-aria-label>
|
||||
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" disabled data-i18n-aria-label data-i18n>PREV</button>
|
||||
<button id="radio-play-btn" type="button" aria-label="Play selected station" disabled data-i18n-aria-label data-i18n>PLAY</button>
|
||||
<button id="radio-next-btn" type="button" aria-label="Next filtered station" disabled data-i18n-aria-label data-i18n>NEXT</button>
|
||||
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled data-i18n-aria-label data-i18n>STOP</button>
|
||||
</div>
|
||||
<label class="radio-volume-row" for="radio-volume">
|
||||
<span data-i18n>VOLUME</span>
|
||||
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" data-i18n-aria-label />
|
||||
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">80%</output>
|
||||
</label>
|
||||
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite" data-i18n>Radio off</div>
|
||||
<div class="radio-links">
|
||||
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden data-i18n>STATION SITE</a>
|
||||
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer" data-i18n>DIRECTORY: RADIO BROWSER</a>
|
||||
</div>
|
||||
<p class="radio-privacy" data-i18n>Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver" data-i18n-aria-label>
|
||||
<div class="sdr-radio-heading">
|
||||
<strong data-i18n>LOCAL RTL-SDR</strong>
|
||||
<span id="sdr-connection-state" data-i18n-runtime>NO USB</span>
|
||||
</div>
|
||||
<p data-i18n>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
|
||||
<div class="sdr-radio-actions">
|
||||
<button id="sdr-connect-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>CONNECT</button>
|
||||
<button id="sdr-locate-btn" type="button" title="Use your location for faster ADS-B position decoding" data-i18n-title data-i18n>LOCATE</button>
|
||||
<button id="sdr-change-device-btn" type="button" title="Choose a different RTL-SDR or receiver channel" data-i18n-title data-i18n>CHANGE DEVICE</button>
|
||||
</div>
|
||||
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode" data-i18n-aria-label>
|
||||
<button id="sdr-mode-fm-btn" class="active" type="button" aria-pressed="true" translate="no">FM</button>
|
||||
<button id="sdr-mode-adsb-btn" type="button" aria-pressed="false" translate="no">ADS-B · 1090</button>
|
||||
</div>
|
||||
<div class="sdr-gain-row">
|
||||
<label for="sdr-gain-select" data-i18n>GAIN</label>
|
||||
<select class="gev-field" id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label>
|
||||
<option value="auto" data-i18n>AUTO</option>
|
||||
<option value="0.0" translate="no">0.0 dB</option>
|
||||
<option value="0.9" translate="no">0.9 dB</option>
|
||||
<option value="1.4" translate="no">1.4 dB</option>
|
||||
<option value="2.7" translate="no">2.7 dB</option>
|
||||
<option value="3.7" translate="no">3.7 dB</option>
|
||||
<option value="7.7" translate="no">7.7 dB</option>
|
||||
<option value="8.7" translate="no">8.7 dB</option>
|
||||
<option value="12.5" translate="no">12.5 dB</option>
|
||||
<option value="14.4" translate="no">14.4 dB</option>
|
||||
<option value="15.7" translate="no">15.7 dB</option>
|
||||
<option value="16.6" translate="no">16.6 dB</option>
|
||||
<option value="19.7" translate="no">19.7 dB</option>
|
||||
<option value="20.7" translate="no">20.7 dB</option>
|
||||
<option value="22.9" translate="no">22.9 dB</option>
|
||||
<option value="25.4" translate="no">25.4 dB</option>
|
||||
<option value="28.0" translate="no">28.0 dB</option>
|
||||
<option value="29.7" translate="no">29.7 dB</option>
|
||||
<option value="32.8" translate="no">32.8 dB</option>
|
||||
<option value="33.8" translate="no">33.8 dB</option>
|
||||
<option value="36.4" translate="no">36.4 dB</option>
|
||||
<option value="37.2" translate="no">37.2 dB</option>
|
||||
<option value="38.6" translate="no">38.6 dB</option>
|
||||
<option value="40.2" translate="no">40.2 dB</option>
|
||||
<option value="42.1" translate="no">42.1 dB</option>
|
||||
<option value="43.4" translate="no">43.4 dB</option>
|
||||
<option value="43.9" translate="no">43.9 dB</option>
|
||||
<option value="44.5" translate="no">44.5 dB</option>
|
||||
<option value="48.0" translate="no">48.0 dB</option>
|
||||
<option value="49.6" translate="no">49.6 dB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" hidden>
|
||||
<span><output id="sdr-stat-rate">--</output> <span data-i18n>MSG/S</span></span>
|
||||
<span><output id="sdr-stat-heard">0</output> <span data-i18n>HEARD</span></span>
|
||||
<span><output id="sdr-stat-positioned">0</output> <span data-i18n>POSITIONED</span></span>
|
||||
<span translate="no">IQ <output id="sdr-stat-iq">--</output></span>
|
||||
</div>
|
||||
<div class="sdr-frequency-row">
|
||||
<label for="sdr-frequency-input" data-i18n>FM MHZ</label>
|
||||
<input class="gev-field" id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
|
||||
<button id="sdr-tune-btn" type="button" data-i18n>TUNE</button>
|
||||
</div>
|
||||
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label>
|
||||
<button id="sdr-seek-back-btn" type="button" disabled data-i18n>◀ SEEK</button>
|
||||
<button id="sdr-seek-forward-btn" type="button" disabled data-i18n>SEEK ▶</button>
|
||||
</div>
|
||||
<label class="radio-volume-row" for="sdr-volume">
|
||||
<span data-i18n>SDR VOL</span>
|
||||
<input id="sdr-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" data-i18n-aria-label />
|
||||
<output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">80%</output>
|
||||
</label>
|
||||
<div id="sdr-status" class="radio-playback-state" role="status" aria-live="polite" data-i18n>Connect an RTL-SDR to begin.</div>
|
||||
<div id="sdr-feed-status" class="sdr-feed-status" role="status" aria-live="off" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<RadioPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { vClassToggle } from '../../rail/classToggle.js';
|
||||
import { vRangeValue } from '../../rail/rangeValue.js';
|
||||
import { useRadioStore } from '../../stores/radio.js';
|
||||
|
||||
// The Radio panel inside Context. RadioControls presents the directory row,
|
||||
// station card, transport, volume and playback line and publishes them;
|
||||
// until it does, the panel shows its initial markup. The tuner and the
|
||||
// local SDR card keep their owners' element bindings.
|
||||
const { view: radio } = storeToRefs(useRadioStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="radio-panel" class="panel-collapsible collapsed" v-class-toggle="radio ? { 'radio-enabled': radio.enabled, 'lifecycle-uncertain': radio.uncertain } : null" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label>
|
||||
<div class="radio-panel-inner">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" data-i18n>RADIO</span>
|
||||
<span id="radio-layer-state" class="radio-layer-state" :class="{ active: radio?.layerState.active }">{{ radio ? radio.layerState.text : t('OFF') }}</span>
|
||||
<span class="panel-divider"></span>
|
||||
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section" data-i18n-title data-i18n-aria-label>+</button>
|
||||
</div>
|
||||
<div class="radio-directory-row">
|
||||
<button id="radio-enable-btn" class="panel-layer-toggle" :class="{ active: radio?.enable.active }" type="button" :aria-pressed="String(radio?.enable.active ?? false)" :aria-label="radio?.enable.label" :aria-disabled="radio ? String(radio.enable.busy) : undefined" :aria-busy="radio ? String(radio.enable.busy) : undefined">{{ radio ? radio.enable.text : t('ENABLE') }}</button>
|
||||
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
|
||||
<select class="gev-field" id="radio-filter" aria-label="Filter stations by station tag" :disabled="radio ? radio.filter.disabled : true" v-bind="radio?.filter.color ? { style: { color: radio.filter.color } } : {}" v-range-value="radio ? radio.filter.value : 'all'" data-i18n-aria-label>
|
||||
<option v-if="!radio?.filter.options" value="all">{{ t('All', { context: 'radio tag' }) }}</option><template v-else><option v-for="option in radio.filter.options" :key="option.id" :value="option.id" :data-radio-color="option.color" :style="{ color: option.color }" :aria-label="option.label">● {{ option.label }}</option></template>
|
||||
</select>
|
||||
</div>
|
||||
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
|
||||
<strong id="radio-station-name">{{ radio ? radio.station.name : t('NO STATION SELECTED') }}</strong>
|
||||
<span id="radio-station-meta">{{ radio ? radio.station.meta : t('Enable Radio, then choose a globe marker or use next.') }}</span>
|
||||
<span id="radio-station-tags">{{ radio?.station.tags ?? '' }}</span>
|
||||
</div>
|
||||
<div id="radio-tuner" class="radio-tuner" hidden>
|
||||
<div class="radio-tuner-header">
|
||||
<span data-i18n id="radio-tuner-band-label">DIRECTORY BAND</span>
|
||||
<output id="radio-tuner-value" for="radio-tuner-slider" data-i18n>DRAG TO TUNE</output>
|
||||
</div>
|
||||
<div class="radio-tuner-dial">
|
||||
<div class="radio-tuner-scale" aria-hidden="true"></div>
|
||||
<div id="radio-tuner-needle" class="radio-tuner-needle" aria-hidden="true"></div>
|
||||
<input id="radio-tuner-slider" type="range" min="0" max="0" step="1" value="0"
|
||||
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled data-i18n-aria-label />
|
||||
</div>
|
||||
<div class="radio-tuner-readout">
|
||||
<span id="radio-tuner-station" data-i18n>ALL · DRAG THE NEEDLE</span>
|
||||
<span data-i18n>SNAPS TO AVAILABLE STATIONS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radio-transport" role="group" aria-label="Radio playback" data-i18n-aria-label>
|
||||
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" :disabled="radio ? radio.transport.stepDisabled : true" data-i18n-aria-label data-i18n>PREV</button>
|
||||
<button id="radio-play-btn" type="button" v-class-toggle="{ active: radio?.transport.play.active }" :aria-label="radio ? radio.transport.play.label : t('Play selected station')" :disabled="radio ? radio.transport.play.disabled : true">{{ radio ? radio.transport.play.text : t('PLAY') }}</button>
|
||||
<button id="radio-next-btn" type="button" aria-label="Next filtered station" :disabled="radio ? radio.transport.stepDisabled : true" data-i18n-aria-label data-i18n>NEXT</button>
|
||||
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" :disabled="radio ? radio.transport.stopDisabled : true" data-i18n-aria-label data-i18n>STOP</button>
|
||||
</div>
|
||||
<label class="radio-volume-row" for="radio-volume">
|
||||
<span data-i18n>VOLUME</span>
|
||||
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" :disabled="radio?.volume.disabled" v-range-value="radio ? radio.volume.value : 80" data-i18n-aria-label />
|
||||
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">{{ radio ? `${radio.volume.value}%` : '80%' }}</output>
|
||||
</label>
|
||||
<div id="radio-playback-state" class="radio-playback-state" :class="{ error: radio?.playback.error }" role="status" aria-live="polite">{{ radio ? radio.playback.text : t('Radio off') }}</div>
|
||||
<div class="radio-links">
|
||||
<a id="radio-station-homepage" :href="radio ? radio.station.homepage || undefined : '#'" target="_blank" rel="noopener noreferrer" :hidden="radio ? !radio.station.homepage : true" data-i18n>STATION SITE</a>
|
||||
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer" data-i18n>DIRECTORY: RADIO BROWSER</a>
|
||||
</div>
|
||||
<p class="radio-privacy" data-i18n>Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
|
||||
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver" data-i18n-aria-label>
|
||||
<div class="sdr-radio-heading">
|
||||
<strong data-i18n>LOCAL RTL-SDR</strong>
|
||||
<span id="sdr-connection-state" data-i18n-runtime>NO USB</span>
|
||||
</div>
|
||||
<p data-i18n>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
|
||||
<div class="sdr-radio-actions">
|
||||
<button id="sdr-connect-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>CONNECT</button>
|
||||
<button id="sdr-locate-btn" type="button" title="Use your location for faster ADS-B position decoding" data-i18n-title data-i18n>LOCATE</button>
|
||||
<button id="sdr-change-device-btn" type="button" title="Choose a different RTL-SDR or receiver channel" data-i18n-title data-i18n>CHANGE DEVICE</button>
|
||||
</div>
|
||||
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode" data-i18n-aria-label>
|
||||
<button id="sdr-mode-fm-btn" class="active" type="button" aria-pressed="true" translate="no">FM</button>
|
||||
<button id="sdr-mode-adsb-btn" type="button" aria-pressed="false" translate="no">ADS-B · 1090</button>
|
||||
</div>
|
||||
<div class="sdr-gain-row">
|
||||
<label for="sdr-gain-select" data-i18n>GAIN</label>
|
||||
<select class="gev-field" id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" data-i18n-aria-label>
|
||||
<option value="auto" data-i18n>AUTO</option>
|
||||
<option value="0.0" translate="no">0.0 dB</option>
|
||||
<option value="0.9" translate="no">0.9 dB</option>
|
||||
<option value="1.4" translate="no">1.4 dB</option>
|
||||
<option value="2.7" translate="no">2.7 dB</option>
|
||||
<option value="3.7" translate="no">3.7 dB</option>
|
||||
<option value="7.7" translate="no">7.7 dB</option>
|
||||
<option value="8.7" translate="no">8.7 dB</option>
|
||||
<option value="12.5" translate="no">12.5 dB</option>
|
||||
<option value="14.4" translate="no">14.4 dB</option>
|
||||
<option value="15.7" translate="no">15.7 dB</option>
|
||||
<option value="16.6" translate="no">16.6 dB</option>
|
||||
<option value="19.7" translate="no">19.7 dB</option>
|
||||
<option value="20.7" translate="no">20.7 dB</option>
|
||||
<option value="22.9" translate="no">22.9 dB</option>
|
||||
<option value="25.4" translate="no">25.4 dB</option>
|
||||
<option value="28.0" translate="no">28.0 dB</option>
|
||||
<option value="29.7" translate="no">29.7 dB</option>
|
||||
<option value="32.8" translate="no">32.8 dB</option>
|
||||
<option value="33.8" translate="no">33.8 dB</option>
|
||||
<option value="36.4" translate="no">36.4 dB</option>
|
||||
<option value="37.2" translate="no">37.2 dB</option>
|
||||
<option value="38.6" translate="no">38.6 dB</option>
|
||||
<option value="40.2" translate="no">40.2 dB</option>
|
||||
<option value="42.1" translate="no">42.1 dB</option>
|
||||
<option value="43.4" translate="no">43.4 dB</option>
|
||||
<option value="43.9" translate="no">43.9 dB</option>
|
||||
<option value="44.5" translate="no">44.5 dB</option>
|
||||
<option value="48.0" translate="no">48.0 dB</option>
|
||||
<option value="49.6" translate="no">49.6 dB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" hidden>
|
||||
<span><output id="sdr-stat-rate">--</output> <span data-i18n>MSG/S</span></span>
|
||||
<span><output id="sdr-stat-heard">0</output> <span data-i18n>HEARD</span></span>
|
||||
<span><output id="sdr-stat-positioned">0</output> <span data-i18n>POSITIONED</span></span>
|
||||
<span translate="no">IQ <output id="sdr-stat-iq">--</output></span>
|
||||
</div>
|
||||
<div class="sdr-frequency-row">
|
||||
<label for="sdr-frequency-input" data-i18n>FM MHZ</label>
|
||||
<input class="gev-field" id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
|
||||
<button id="sdr-tune-btn" type="button" data-i18n>TUNE</button>
|
||||
</div>
|
||||
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label>
|
||||
<button id="sdr-seek-back-btn" type="button" disabled data-i18n>◀ SEEK</button>
|
||||
<button id="sdr-seek-forward-btn" type="button" disabled data-i18n>SEEK ▶</button>
|
||||
</div>
|
||||
<label class="radio-volume-row" for="sdr-volume">
|
||||
<span data-i18n>SDR VOL</span>
|
||||
<input id="sdr-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" data-i18n-aria-label />
|
||||
<output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">80%</output>
|
||||
</label>
|
||||
<div id="sdr-status" class="radio-playback-state" role="status" aria-live="polite" data-i18n>Connect an RTL-SDR to begin.</div>
|
||||
<div id="sdr-feed-status" class="sdr-feed-status" role="status" aria-live="off" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in new issue
Block a user