refactor: isolate Radio controls and tuner presentation

This commit is contained in:
Sameh Khamis committed 2026-09-12 23:23:20 -07:00
1 parent 79a0f09087
commit 7b9aca8597
21 files changed
+1507 -1134

No files matched your search

+24 -26
View File
@@ -1,3 +1,6 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
@@ -248,8 +251,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, \.42\)/);
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-launcher\s*\{[\s\S]*?border:\s*0;[\s\S]*?background:\s*transparent;[\s\S]*?box-shadow:\s*none;/);
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, \.22\);/);
assert.match(ui, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
assert.match(ui, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
assert.match(radioBindings, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
assert.match(radioBindings, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
assert.match(css, /#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/);
assert.doesNotMatch(ui, /--cockpit-display-tab-width/);
// These two widths size Cockpit Radio and nothing else. They were named for
@@ -307,7 +310,7 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
assert.match(ui, /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
assert.match(ui, /!this\._preservePanelStateDuringLayerClear[\s\S]*?this\.setPanelCollapsed\('radio-panel', true\)/);
assert.match(radioPresentation, /!this\.actions\.preservePanelStateDuringClear\(\)[\s\S]*?this\.actions\.setPanelCollapsed\('radio-panel', true\)/);
assert.match(ui, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
});
@@ -320,16 +323,13 @@ test('Cockpit Display portal retains both scroll owners across round trips', ()
});
test('Cockpit side surfaces behave as two single-expanded accordions', () => {
assert.match(
ui,
/if \(displayOpen \|\| radioOpen\) this\.cockpitView\?\.setSignalCollapsed\(true\);/,
assert.match(radioBindings,
/if \(displayOpen \|\| radioOpen\) this\.actions\.setSignalCollapsed\(true\);/,
);
assert.match(
ui,
assert.match(radioBindings,
/'gev:cockpit-signal-expanded'[\s\S]*?setCockpitDisclosure\('display', false\);/,
);
assert.match(
ui,
assert.match(radioBindings,
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
);
assert.match(
@@ -341,13 +341,11 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
/nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
'closing Data Layers must restore Contact only after an automatic collapse',
);
assert.match(
ui,
assert.match(ui,
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
'Contact expansion must notify only on a collapsed-to-expanded transition',
);
assert.match(
ui,
assert.match(ui,
/const wasCollapsed = this\.signalCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.signalCollapsed[\s\S]*?'gev:cockpit-signal-expanded'/,
'Live Signals expansion must notify only on a collapsed-to-expanded transition',
);
@@ -356,12 +354,12 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
/setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/,
);
assert.match(
ui,
/!displayOpen && !radioOpen[\s\S]*?this\.cockpitView\?\.active[\s\S]*?!this\.cockpitView\.signalUserCollapsed[\s\S]*?setSignalCollapsed\(false\)/,
radioBindings,
/!displayOpen && !radioOpen[\s\S]*?this\.actions\.isCockpitActive\(\)[\s\S]*?!this\.actions\.signalUserCollapsed\(\)[\s\S]*?setSignalCollapsed\(false\)/,
'Live Signals should reopen only after both utility panels close and no manual collapse is retained',
);
assert.match(
ui,
radioBindings,
/event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\) return;[\s\S]*?setCockpitDisclosure\('display', false\);/,
'left-side interactions must not collapse the independent Cockpit utilities',
);
@@ -505,18 +503,18 @@ test('Location navigation releases immediate routes before flight and deferred r
});
test('Cockpit Radio station changes preserve first-person camera ownership', () => {
const cycleHelper = ui.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/);
const cycleHelper = radioBindings.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/);
assert.ok(cycleHelper, 'shared Radio cycle helper is missing');
assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/);
assert.match(ui, /_radioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1\)\)/);
assert.match(ui, /_contextRadioMiniNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1\)\)/);
assert.match(radioBindings, /listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/);
assert.match(radioBindings, /listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/);
assert.match(
ui,
/_cockpitRadioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/,
radioBindings,
/listen\(this\._cockpitRadioPrevBtn, 'click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/,
);
assert.match(
ui,
/_cockpitRadioNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/,
radioBindings,
/listen\(this\._cockpitRadioNextBtn, 'click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/,
);
});
@@ -653,9 +651,9 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1);
assert.match(html, /data-cockpit-display-slot="detection"[\s\S]*?data-cockpit-display-slot="parameters"[\s\S]*?data-cockpit-display-slot="models3d"/);
assert.match(ui, /_revealStyleParameters\(\) \{[\s\S]*?if \(this\._cockpitDisplayPortalActive\) return;/);
assert.match(ui, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
assert.match(radioBindings, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
assert.match(ui, /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/);
assert.match(ui, /if \(displayOpen\) this\._revealCockpitStyleParameters\(\);/);
assert.match(radioBindings, /if \(displayOpen\) this\.actions\.revealStyleParameters\(\);/);
assert.match(ui, /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/);
assert.match(css, /\.cockpit-display-slot\s*\{[\s\S]*?display:\s*block;[\s\S]*?min-width:\s*0;/);
assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{\s*width:\s*100%/);
+14 -9
View File
@@ -1,3 +1,7 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
// Source-contract pins for StyleManager's Context-session handler ordering.
// ui.js cannot be imported headlessly (it touches the DOM at module scope), so
// these read the shipped source — the idiom established by cockpitMarkup /
@@ -173,23 +177,24 @@ test('every user-facing Context exit route settles through the failure surface',
});
test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => {
const radioControls = src.slice(
src.indexOf('const toggleRadio = async (trigger) => {'),
src.indexOf("this._radioFilter?.addEventListener('change'"),
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
);
assert.match(radioControls, /await this\._runUserFacingContextAction\(/);
assert.match(radioControls, /await this\.actions\.runUserAction\(/);
assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/);
assert.match(radioControls, /if \(toggled === false\) return/);
assert.match(radioControls, /if \(this\.destroyed \|\| toggled === false\) return/);
});
test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => {
const radioControls = src.slice(
src.indexOf('const toggleRadio = async (trigger) => {'),
src.indexOf("this._radioFilter?.addEventListener('change'"),
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
);
assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/);
assert.match(radioControls, /if \(revealAfterEnable\) await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
assert.equal((src.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 2);
assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1);
assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/);
});
test('right-rail context entry is transactional: activation result gates the mode', () => {
+6 -8
View File
@@ -1,3 +1,7 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -136,14 +140,8 @@ test('Context async action buttons remain focused while busy', () => {
ui.indexOf('_initGlobalContextPanel() {'),
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
);
const radio = ui.slice(
ui.indexOf('const toggleRadio = async (trigger) => {'),
ui.indexOf('this._contextRadioToggleBtn?.addEventListener', ui.indexOf('const toggleRadio = async (trigger) => {')),
);
const radioSync = ui.slice(
ui.indexOf('_renderRadioState(state) {'),
ui.indexOf('if (this._radioFilter)', ui.indexOf('_renderRadioState(state) {')),
);
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
const clear = ui.slice(
ui.indexOf('clearSelectedLayers() {'),
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
+1 -87
View File
@@ -1,3 +1,4 @@
export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from '../ui/radioTunerModel.js';
/**
* Internet-radio station directory and direct-playback layer.
*
@@ -945,93 +946,6 @@ export function radioStationIdFromPick(picked) {
return null;
}
/** Map an integer tuner slot directly to one available directory station. */
export function radioTunerSlot(value, stationCount) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count) return { slot: 0, max: 0, locked: false, stationIndex: -1, leftIndex: -1, rightIndex: -1 };
const max = Math.max(0, count - 1);
const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0)));
return {
slot,
max,
locked: true,
stationIndex: slot,
leftIndex: slot,
rightIndex: slot,
};
}
/** Snap a tuner release to the nearest available directory station. */
export function radioTunerCommitSlot(value, stationCount) {
return radioTunerSlot(value, stationCount);
}
/** Map one pointer coordinate to continuous absolute directory progress. */
export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 };
if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 };
const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2);
const ratio = Math.min(1, Math.max(0, ((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth));
const coordinate = ratio * (count - 1);
return {
ratio,
coordinate,
stationIndex: Math.min(count - 1, Math.max(0, Math.floor(coordinate + 0.5))),
};
}
/** Build a bounded virtual tuner tape around one continuous directory coordinate. */
export function buildRadioTunerTicks(coordinate, stationCount, width, {
insetPx = 7,
minPitchPx = 14,
speedFactor = 5,
overscan = 2,
labelStep = 6,
} = {}) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
const dialWidth = Math.max(0, Number(width) || 0);
const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(0, dialWidth - inset * 2);
if (!count) return { ticks: [], needleX: inset, pitchPx: 0, ratio: 0 };
const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0));
if (count === 1) {
return {
ticks: [{ stationIndex: 0, channel: 1, xPx: inset + usableWidth / 2, current: true, label: '01' }],
needleX: inset + usableWidth / 2,
pitchPx: Math.max(1, Number(minPitchPx) || 14),
ratio: 0.5,
};
}
const directoryStep = usableWidth / (count - 1);
const pitchPx = Math.max(
Math.max(1, Number(minPitchPx) || 14),
directoryStep * Math.max(1, Number(speedFactor) || 5),
);
const needleX = inset + directoryStep * value;
const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0);
const first = Math.max(0, Math.ceil(value + (-overscanPx - needleX) / pitchPx));
const last = Math.min(count - 1, Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx));
const currentIndex = Math.min(count - 1, Math.max(0, Math.floor(value + 0.5)));
const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6));
const labelWidth = Math.max(2, String(count).length);
const ticks = [];
for (let stationIndex = first; stationIndex <= last; stationIndex += 1) {
const channel = stationIndex + 1;
const current = stationIndex === currentIndex;
const labelled = current || stationIndex === 0 || stationIndex === count - 1 || channel % majorEvery === 0;
ticks.push({
stationIndex,
channel,
xPx: needleX + pitchPx * (stationIndex - value),
current,
label: labelled ? String(channel).padStart(labelWidth, '0') : '',
});
}
return { ticks, needleX, pitchPx, ratio: value / (count - 1) };
}
/** Plan a station-centered camera move that preserves altitude and view angle. */
export function radioStationCameraPlan(station, cameraState = {}) {
const targetLat = Number(station?.lat);
+1 -1
View File
@@ -263,7 +263,7 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
});
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
// stopPropagation() does NOT stop later listeners on the SAME document, so the
+7 -3
View File
@@ -1,3 +1,7 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -155,16 +159,16 @@ test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit',
test('Cockpit utility Escape leaves an expanded nested Parameters panel to the shared handler', () => {
assert.match(
source,
radioBindings,
/const nestedPanel = event\.target\?\.closest\?\.\('\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)'\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/,
);
assert.match(
source,
radioBindings,
/const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/,
'Cockpit utility disclosures clear their own focus when Escape closes them',
);
assert.match(
source,
radioBindings,
/_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/,
'compact Radio disclosure clears its own focus when Escape closes it',
);
+30 -30
View File
@@ -1,3 +1,7 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
@@ -249,19 +253,19 @@ test('Radio is nested inside Context with separate disclosure and power controls
assert.match(css, /--radio-broadcast-opacity: \.17/);
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.radio-tuner-needle,[\s\S]*?\.radio-tuner-tick\s*\{\s*transition: none;/);
assert.doesNotMatch(ui, /_radioTunerCameraRemove = this\.viewer\?\.camera\?\.changed/);
assert.match(ui, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/);
assert.match(ui, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/);
const cycleStart = ui.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>');
const cycleMethod = ui.slice(cycleStart, ui.indexOf('const toggleRadio', cycleStart));
assert.match(radioPresentation, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/);
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/);
const cycleStart = radioBindings.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>');
const cycleMethod = radioBindings.slice(cycleStart, radioBindings.indexOf('const toggleRadio', cycleStart));
assert.doesNotMatch(cycleMethod, /refreshTunerBand/);
assert.match(ui, /_radioTunerBandPinnedForNavigation = true/);
assert.match(ui, /viewer\?\.canvas\?\.addEventListener\('pointerdown', releaseNavigationBand/);
assert.match(ui, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/);
assert.match(ui, /tunerPreview\(\{ coordinate: this\._radioTunerCoordinate, rotate: commit \}\)/);
assert.match(ui, /radioLayer\.cancelTuning\(\)/);
assert.match(ui, /classList\.remove\('radio-broadcasting'\)/);
assert.match(ui, /radioLayer\.getTunerStations\(750\)/);
assert.match(ui, /radioTunerPointerPosition\(/);
assert.match(radioBindings, /_radioTunerBandPinnedForNavigation = true/);
assert.match(radioBindings, /listen\(this\.canvas, 'pointerdown', releaseNavigationBand/);
assert.match(radioBindings, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/);
assert.match(radioBindings, /tunerPreview\(\{ coordinate: this\._radioTunerCoordinate, rotate: commit \}\)/);
assert.match(radioBindings, /this\.radio\.cancelTuning\(\)/);
assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/);
assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/);
assert.match(radioBindings, /radioTunerPointerPosition\(/);
assert.doesNotMatch(css, /#right-context-rail\s*>\s*#radio-panel/);
assert.match(css, /#global-context-panel #radio-panel\.collapsed/);
assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/);
@@ -277,16 +281,15 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
assert.match(voice, /'radio'/);
assert.match(voice, /name:\s*'control_radio'/);
assert.deepEqual(realtimeTools().find(tool => tool.name === 'control_radio').parameters.properties.action.enum, ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status']);
const enableStart = ui.lastIndexOf('\n _initRadioPanel()');
const enableMethod = ui.slice(enableStart, ui.indexOf('\n _renderRadioState(state)', enableStart));
const enableMethod = radioBindings;
assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i);
assert.match(enableMethod, /contextRadioToggleBtn/);
assert.match(enableMethod, /contextRadioMiniEnableBtn/);
assert.match(enableMethod, /contextRadioDetailsBtn/);
assert.match(enableMethod, /contextRadioMiniCloseBtn/);
assert.match(enableMethod, /contextRadioMiniPlayBtn/);
const disclosureStart = enableMethod.indexOf('this._contextRadioToggleBtn?.addEventListener');
const disclosureEnd = enableMethod.indexOf("this._radioFilter?.addEventListener", disclosureStart);
const disclosureStart = enableMethod.indexOf('this.listen(this._contextRadioToggleBtn,');
const disclosureEnd = enableMethod.indexOf("this.listen(this._radioFilter,", disclosureStart);
const disclosureBindings = enableMethod.slice(disclosureStart, disclosureEnd);
assert.ok(disclosureStart >= 0 && disclosureEnd > disclosureStart, 'compact Radio disclosure bindings are missing');
assert.doesNotMatch(disclosureBindings, /toggleRadio\(this\._contextRadioToggleBtn\)/);
@@ -304,32 +307,29 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
});
test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => {
const revealStart = ui.indexOf('\n async _revealRadioPanelInsideContext');
const revealEnd = ui.indexOf('\n /**', revealStart + 10);
const revealMethod = ui.slice(revealStart, revealEnd);
const revealStart = radioControlsSource.indexOf('\n async _revealRadioPanelInsideContext');
const revealEnd = radioControlsSource.indexOf('\n /**', revealStart + 10);
const revealMethod = radioControlsSource.slice(revealStart, revealEnd);
assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing');
assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/);
assert.match(revealMethod, /scroller\.scrollTo\(\{ top: next, behavior: reducedMotion \? 'auto' : 'smooth' \}\)/);
assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/);
assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/);
const syncStart = ui.indexOf('\n _syncContextRadioLauncherState()');
const syncEnd = ui.indexOf('\n /**', syncStart + 10);
const syncMethod = ui.slice(syncStart, syncEnd);
const syncStart = radioControlsSource.indexOf('\n _syncContextRadioLauncherState()');
const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10);
const syncMethod = radioControlsSource.slice(syncStart, syncEnd);
assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing');
assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded', String\(radioExpanded\)/);
assert.match(syncMethod, /aria-controls', 'context-radio-mini'[\s\S]*?aria-expanded', String\(compactOpen\)/);
const renderStart = ui.indexOf('\n _renderRadioState(state)');
const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart));
const renderMethod = radioPresentation;
assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/);
assert.doesNotMatch(renderMethod, /compact Radio controls/);
assert.doesNotMatch(renderMethod, /_contextRadioToggleBtn\.setAttribute\('aria-(?:controls|expanded|label)'/);
});
test('Radio disclosure is explicit, starts closed while off, and preserves playback state', () => {
const renderStart = ui.indexOf('\n _renderRadioState(state)');
const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart));
assert.ok(renderStart >= 0, 'Radio render method is missing');
const renderMethod = radioPresentation;
assert.doesNotMatch(renderMethod, /setPanelCollapsed\('radio-panel', true\).*stopPlayback/s);
assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s);
assert.match(ui, /contextRadioDetailsBtn/);
@@ -346,8 +346,8 @@ test('successful explicit user playback hands the speaker from voice to Radio',
assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying);
assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/);
assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/);
assert.match(ui, /togglePlayback\(\{ origin: 'user' \}\)/);
assert.match(ui, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
assert.match(ui, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/);
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
assert.match(realtime, /event\.origin === 'user' && event\.action === 'play' && this\.isActive\(\)[\s\S]*?this\.stop\(\{ preserveRadioPlayback: true \}\)/);
});
+76 -864
View File
@@ -1,3 +1,4 @@
import { RadioControls } from './ui/radio.js';
import { LocationControls, LocationSearch } from './ui/location.js';
import { bindClearLayersControl } from './ui/layers.js';
import { createMapSourceControls } from './ui/mapSource.js';
@@ -61,12 +62,7 @@ import militaryFlightsLayer from './data/militaryFlights.js';
import { isTr3b, toggleTr3b } from './data/tr3bRegistry.js';
import satellitesLayer from './data/satellites.js';
import cctvLayer from './data/cctv.js';
import radioLayer, {
buildRadioTunerTicks,
radioTunerCommitSlot,
radioTunerPointerPosition,
radioTunerSlot,
} from './data/radio.js';
import radioLayer from './data/radio.js';
import bikeshareLayer from './data/bikeshare.js';
import aisLiveVesselsLayer from './data/aisLiveVessels.js';
import militaryAwarenessLayer from './data/militaryAwareness.js';
@@ -2089,25 +2085,6 @@ export class StyleManager {
this._clearSelectedLayersManagerPromise = null;
this._dataManager = null;
this._cctvUnsubscribe = null;
this._radioUnsubscribe = null;
this._radioState = null;
this._radioCategorySignature = '';
this._radioTunerStations = [];
this._radioTunerPool = [];
this._radioTunerDragging = false;
this._radioTunerDragStartSlot = 0;
this._radioTunerDragSnapshot = null;
this._radioTunerLastSlot = 0;
this._radioTunerDragDirection = 0;
this._radioTunerCoordinate = 0;
this._radioTunerPointerId = null;
this._radioTunerKeyboardKey = null;
this._radioTunerAbort = null;
this._radioTunerBandSignature = '';
this._radioTunerSelectedId = null;
this._radioTunerBandPinnedForNavigation = false;
this._radioTunerCameraRemove = null;
this._refreshRadioTunerBand = null;
this._cctvState = null;
this._cctvSummaryTypingTimer = null;
this._lastCctvSummaryText = '';
@@ -4208,15 +4185,7 @@ export class StyleManager {
if (typeof cctvLayer.getUIState === 'function') {
this._renderCctvState(cctvLayer.getUIState());
}
if (this._radioUnsubscribe) {
this._radioUnsubscribe();
this._radioUnsubscribe = null;
}
if (typeof radioLayer.subscribe === 'function') {
this._radioUnsubscribe = radioLayer.subscribe((state) => {
this._renderRadioState(state);
});
}
this._radioControls.connect();
if (!this._awarenessSelectedHandler) {
this._awarenessSelectedHandler = (event) => this._persistAwarenessSelection(event, false);
this._awarenessClearedHandler = (event) => this._persistAwarenessSelection(event, true);
@@ -5030,826 +4999,83 @@ export class StyleManager {
/** Wire the independent Radio companion controls. */
_initRadioPanel() {
if (!this._radioPanel) return;
this._radioTunerAbort?.abort();
this._radioTunerAbort = new AbortController();
const tunerListenerOptions = { signal: this._radioTunerAbort.signal };
const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => {
const open = Boolean(expanded);
this._contextRadioDock?.classList.toggle('disclosure-open', open);
if (this._contextRadioMini) this._contextRadioMini.hidden = !open;
this._syncContextRadioLauncherState();
if (!open && returnFocus) this._contextRadioToggleBtn?.focus({ preventScroll: true });
};
this._setRadioDisclosure = setRadioDisclosure;
const setCockpitDisclosure = (kind, expanded, { returnFocus = false } = {}) => {
const displayOpen = kind === 'display' && Boolean(expanded);
const radioOpen = kind === 'radio' && Boolean(expanded);
if (displayOpen || radioOpen) this.cockpitView?.setSignalCollapsed(true);
if (this._cockpitDisplayPanel) this._cockpitDisplayPanel.hidden = !displayOpen;
if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen;
this._cockpitDisplayToggleBtn?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', displayOpen);
this._cockpitRadioToggleBtn?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', radioOpen);
this._cockpitDisplayToggleBtn?.setAttribute('aria-expanded', String(displayOpen));
this._cockpitRadioToggleBtn?.setAttribute('aria-expanded', String(radioOpen));
if (displayOpen) this._revealCockpitStyleParameters();
if (this._cockpitDisplayToggleBtn) {
const action = displayOpen ? 'Collapse' : 'Expand';
this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀';
this._cockpitDisplayToggleBtn.setAttribute('aria-label', `${action} Cockpit display options`);
this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`;
}
if (this._cockpitRadioToggleBtn) {
const action = radioOpen ? 'Collapse' : 'Expand';
this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀';
this._cockpitRadioToggleBtn.setAttribute('aria-label', `${action} Cockpit Radio controls`);
this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`;
}
if (!expanded && returnFocus) {
(kind === 'display' ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn)
?.focus({ preventScroll: true });
}
if (!displayOpen && !radioOpen
&& this.cockpitView?.active
&& !this.cockpitView.signalUserCollapsed) {
this.cockpitView.setSignalCollapsed(false);
}
this.cockpitView?.scheduleContextLayout();
};
this._setCockpitDisclosure = setCockpitDisclosure;
const syncTunerTape = (coordinate) => {
const scale = this._radioTuner?.querySelector('.radio-tuner-scale');
const dial = this._radioTuner?.querySelector('.radio-tuner-dial');
if (!scale || !dial) return null;
const model = buildRadioTunerTicks(
coordinate,
this._radioTunerStations.length,
dial.getBoundingClientRect().width,
);
while (scale.children.length < model.ticks.length) {
const tick = document.createElement('span');
tick.className = 'radio-tuner-tick';
scale.append(tick);
}
while (scale.children.length > model.ticks.length) scale.lastElementChild?.remove();
model.ticks.forEach((entry, index) => {
const tick = scale.children[index];
tick.style.left = `${entry.xPx}px`;
tick.textContent = entry.label;
tick.dataset.stationIndex = String(entry.stationIndex);
tick.classList.toggle('is-current', entry.current);
});
scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`);
return model;
};
const tunerPreview = ({ coordinate = this._radioTunerCoordinate, syncStatic = true, rotate = true } = {}) => {
const slot = radioTunerSlot(this._radioTunerSlider?.value, this._radioTunerStations.length);
const station = slot.locked ? this._radioTunerStations[slot.stationIndex] || null : null;
const resolvedCoordinate = this._radioTunerStations.length <= 1
? 0
: Math.min(this._radioTunerStations.length - 1, Math.max(0, Number(coordinate) || 0));
const ratio = this._radioTunerStations.length === 1
? 0.5
: resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1);
this._radioTunerCoordinate = resolvedCoordinate;
this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio));
this._radioTuner?.classList.toggle('is-static', syncStatic ? false : Boolean(this._radioState?.tuningStatic));
syncTunerTape(resolvedCoordinate);
if (this._radioTunerValue) {
this._radioTunerValue.textContent = station
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}`
: 'NO STATIONS';
}
if (this._radioTunerStation) this._radioTunerStation.textContent = station?.name || 'NO STATION AVAILABLE';
if (this._radioTunerSlider) {
this._radioTunerSlider.setAttribute('aria-valuetext', station
? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}`
: 'No station available');
}
if (syncStatic) radioLayer.previewTuningStation(station?.id || null, { rotate });
return station;
};
const setTunerDirectory = (pool) => {
this._radioTunerPool = [...pool];
this._radioTunerStations = [...pool];
this._radioTunerBandSignature = this._radioTunerStations.map((station) => station.id).join('|');
if (this._radioTunerSlider) {
this._radioTunerSlider.min = '0';
this._radioTunerSlider.max = String(Math.max(0, this._radioTunerStations.length - 1));
this._radioTunerSlider.step = '1';
}
};
const refreshTunerBand = ({ force = false } = {}) => {
if (this._radioTunerDragging || this._radioTuner?.hidden || this._radioTunerSlider?.disabled) return false;
const selectedId = this._radioState?.selected?.id || null;
const pool = radioLayer.getTunerStations(750);
const poolSignature = pool.map((station) => station.id).join('|');
const currentPoolSignature = this._radioTunerPool.map((station) => station.id).join('|');
if (!force && poolSignature === currentPoolSignature && selectedId === this._radioTunerSelectedId) return false;
setTunerDirectory(pool);
this._radioTunerSelectedId = selectedId;
const selectedPoolIndex = pool.findIndex((station) => station.id === selectedId);
const slot = radioTunerSlot(selectedPoolIndex >= 0 ? selectedPoolIndex : 0, this._radioTunerStations.length);
this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false });
return true;
};
this._refreshRadioTunerBand = refreshTunerBand;
const beginTuner = () => {
if (this._radioTunerDragging || this._radioTunerSlider?.disabled) return false;
refreshTunerBand();
if (!this._radioTunerStations.length || !radioLayer.beginTuning()) return false;
// A tuner-owned camera preview must never replace the frozen directory.
// Only an explicit globe pointer/wheel gesture releases camera pinning.
this._radioTunerBandPinnedForNavigation = true;
this._radioTunerDragging = true;
this._radioTuner?.classList.add('is-dragging');
const selectedIndex = this._radioTunerStations.findIndex((station) => station.id === this._radioState?.selected?.id);
const slot = radioTunerSlot(selectedIndex >= 0 ? selectedIndex : 0, this._radioTunerStations.length);
this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
this._radioTunerDragStartSlot = slot.slot;
this._radioTunerDragSnapshot = {
stations: [...this._radioTunerStations],
bandSignature: this._radioTunerBandSignature,
selectedId: this._radioTunerSelectedId,
slot: slot.slot,
coordinate: this._radioTunerCoordinate,
};
this._radioTunerLastSlot = slot.slot;
this._radioTunerDragDirection = 0;
tunerPreview({ coordinate: this._radioTunerCoordinate });
return true;
};
const finishTuner = (commit) => {
if (!this._radioTunerDragging) return;
const dragSnapshot = this._radioTunerDragSnapshot;
if (!commit && dragSnapshot) {
this._radioTunerStations = [...dragSnapshot.stations];
this._radioTunerPool = [...dragSnapshot.stations];
this._radioTunerBandSignature = dragSnapshot.bandSignature;
this._radioTunerSelectedId = dragSnapshot.selectedId;
if (this._radioTunerSlider) {
const startSlot = radioTunerSlot(dragSnapshot.slot, this._radioTunerStations.length);
this._radioTunerSlider.max = String(startSlot.max);
this._radioTunerSlider.value = String(startSlot.slot);
}
this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate)
? dragSnapshot.coordinate
: dragSnapshot.slot;
} else if (!commit && this._radioTunerSlider) {
this._radioTunerSlider.value = String(this._radioTunerDragStartSlot);
this._radioTunerCoordinate = this._radioTunerDragStartSlot;
}
let station = tunerPreview({ coordinate: this._radioTunerCoordinate, rotate: commit });
if (commit && !station && this._radioTunerSlider) {
const snapped = radioTunerCommitSlot(
this._radioTunerSlider.value,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(snapped.slot);
this._radioTunerCoordinate = snapped.stationIndex;
station = tunerPreview({ coordinate: this._radioTunerCoordinate });
}
let result = null;
if (commit && station) {
// Keep the exact band used by the drag so the selected channel cannot
// jump to a refreshed catalog slot while its camera flight settles.
this._radioTunerBandPinnedForNavigation = true;
result = radioLayer.commitTuningStation(station.id, { origin: 'user' });
} else if (!commit) {
radioLayer.cancelTuning();
} else {
radioLayer.endTuning();
}
// Radio emits selection/tuning state synchronously. Keep both the logical
// drag and the no-transition class active until that state has settled,
// then restore the exact selected slot before permitting CSS motion.
this._radioTunerDragging = false;
this._radioTunerPointerId = null;
this._radioTunerKeyboardKey = null;
if (commit && (!result || result.ok)) refreshTunerBand();
this._radioTunerDragSnapshot = null;
// Flush the snapped position while transitions are still disabled so
// removing the drag class cannot interpolate from the released gap.
void this._radioTunerNeedle?.offsetLeft;
this._radioTuner?.classList.remove('is-dragging');
if (result && !result.ok) {
this._radioTunerBandPinnedForNavigation = false;
if (result.reason === 'station-unavailable') {
if (this._radioTunerValue) this._radioTunerValue.textContent = 'OFF AIR';
if (this._radioTunerStation) this._radioTunerStation.textContent = 'STATION UNAVAILABLE';
this._radioTunerSlider?.setAttribute(
'aria-valuetext',
'Station unavailable after directory refresh',
);
}
}
};
const cycleRadio = (direction, { rotate = true } = {}) => {
this._radioTunerBandPinnedForNavigation = true;
const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations;
const cycled = radioLayer.cycleStation(direction, {
rotate,
stationIds: pool.map((station) => station.id),
origin: 'user',
});
if (!cycled) {
this._radioTunerBandPinnedForNavigation = false;
return;
}
};
const toggleRadio = async (trigger) => {
if (!this._dataManager?.layers?.has('radio')) return;
if (trigger.getAttribute('aria-busy') === 'true') return;
const enabling = !this._dataManager.isEnabled('radio');
const revealAfterEnable = enabling && trigger === this._radioEnableBtn;
trigger.setAttribute('aria-disabled', 'true');
trigger.setAttribute('aria-busy', 'true');
try {
const toggled = await this._runUserFacingContextAction(
(notificationToken) => this._dataManager.setEnabled('radio', enabling, {
origin: 'user',
notificationToken,
}),
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
);
if (toggled === false) return;
if (enabling && trigger === this._radioEnableBtn
&& !document.getElementById('global-context-panel')?.classList.contains('collapsed')) {
this.setPanelCollapsed('radio-panel', false, { explicit: true });
}
if (revealAfterEnable) await this._revealRadioControlsAfterExplicitEnable(trigger);
} finally {
trigger.setAttribute('aria-disabled', 'false');
trigger.setAttribute('aria-busy', 'false');
if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true });
}
};
this._radioEnableBtn?.addEventListener('click', () => void toggleRadio(this._radioEnableBtn));
this._contextRadioMiniEnableBtn?.addEventListener('click', () => void toggleRadio(this._contextRadioMiniEnableBtn));
this._cockpitRadioEnableBtn?.addEventListener('click', () => void toggleRadio(this._cockpitRadioEnableBtn));
this._contextRadioToggleBtn?.addEventListener('click', () => {
const contextPanel = document.getElementById('global-context-panel');
if (contextPanel && !contextPanel.classList.contains('collapsed')) {
setRadioDisclosure(false);
this.setPanelCollapsed('radio-panel', false, { explicit: true });
void this._revealRadioPanelInsideContext({
focusTarget: this._radioPanel?.querySelector('[data-collapse-target="radio-panel"]'),
});
return;
}
setRadioDisclosure(!this._contextRadioDock?.classList.contains('disclosure-open'));
this._radioControls?.destroy();
this._radioControls = new RadioControls({
elements: {
_cockpitDisplayPanel: this._cockpitDisplayPanel,
_cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn,
_cockpitRadioEnableBtn: this._cockpitRadioEnableBtn,
_cockpitRadioNextBtn: this._cockpitRadioNextBtn,
_cockpitRadioPanel: this._cockpitRadioPanel,
_cockpitRadioPlayBtn: this._cockpitRadioPlayBtn,
_cockpitRadioPrevBtn: this._cockpitRadioPrevBtn,
_cockpitRadioStation: this._cockpitRadioStation,
_cockpitRadioToggleBtn: this._cockpitRadioToggleBtn,
_cockpitRadioVolume: this._cockpitRadioVolume,
_cockpitRadioVolumeValue: this._cockpitRadioVolumeValue,
_cockpitUtilityControls: this._cockpitUtilityControls,
_contextRadioDetailsBtn: this._contextRadioDetailsBtn,
_contextRadioDock: this._contextRadioDock,
_contextRadioMini: this._contextRadioMini,
_contextRadioMiniCloseBtn: this._contextRadioMiniCloseBtn,
_contextRadioMiniEnableBtn: this._contextRadioMiniEnableBtn,
_contextRadioMiniNextBtn: this._contextRadioMiniNextBtn,
_contextRadioMiniPlayBtn: this._contextRadioMiniPlayBtn,
_contextRadioMiniPrevBtn: this._contextRadioMiniPrevBtn,
_contextRadioMiniStation: this._contextRadioMiniStation,
_contextRadioMiniVolume: this._contextRadioMiniVolume,
_contextRadioMiniVolumeValue: this._contextRadioMiniVolumeValue,
_contextRadioToggleBtn: this._contextRadioToggleBtn,
_radioEnableBtn: this._radioEnableBtn,
_radioFilter: this._radioFilter,
_radioLayerState: this._radioLayerState,
_radioNextBtn: this._radioNextBtn,
_radioPanel: this._radioPanel,
_radioPlayBtn: this._radioPlayBtn,
_radioPlaybackState: this._radioPlaybackState,
_radioPrevBtn: this._radioPrevBtn,
_radioStationHomepage: this._radioStationHomepage,
_radioStationMeta: this._radioStationMeta,
_radioStationName: this._radioStationName,
_radioStationTags: this._radioStationTags,
_radioStopBtn: this._radioStopBtn,
_radioTuner: this._radioTuner,
_radioTunerBandLabel: this._radioTunerBandLabel,
_radioTunerNeedle: this._radioTunerNeedle,
_radioTunerSlider: this._radioTunerSlider,
_radioTunerStation: this._radioTunerStation,
_radioTunerValue: this._radioTunerValue,
_radioVolume: this._radioVolume,
_radioVolumeValue: this._radioVolumeValue,
},
radio: radioLayer,
canvas: this.viewer?.canvas,
actions: {
isRegistered: () => this._dataManager?.layers?.has('radio'),
isEnabled: () => this._dataManager?.isEnabled('radio'),
setEnabled: (enabled, options) => this._dataManager.setEnabled('radio', enabled, options),
setParams: (params, options) => this._dataManager?.setLayerParams('radio', params, options),
getLifecycle: () => this._dataManager?.getLayerLifecycleState?.('radio'),
runUserAction: (...args) => this._runUserFacingContextAction(...args),
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
revealStyleParameters: () => this._revealCockpitStyleParameters(),
setSignalCollapsed: (value) => this.cockpitView?.setSignalCollapsed(value),
isCockpitActive: () => this.cockpitView?.active,
signalUserCollapsed: () => this.cockpitView?.signalUserCollapsed,
layoutCockpit: () => this.cockpitView?.scheduleContextLayout(),
preservePanelStateDuringClear: () => this._preservePanelStateDuringLayerClear,
scheduleLayout: () => this._scheduleRightPanelLayout(),
},
});
this._contextRadioMiniCloseBtn?.addEventListener('click', () => {
setRadioDisclosure(false, { returnFocus: true });
});
this._contextRadioDetailsBtn?.addEventListener('click', () => {
if (!this.cockpitView?.active) this.setPanelCollapsed('global-context-panel', false, { explicit: true });
this.setPanelCollapsed('radio-panel', false, { explicit: true });
setRadioDisclosure(false);
this._radioEnableBtn?.focus({ preventScroll: true });
});
this._cockpitRadioToggleBtn?.addEventListener('click', () => {
const open = this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true';
setCockpitDisclosure('radio', !open);
});
document.addEventListener('pointerdown', (event) => {
if (!this._contextRadioDock?.classList.contains('disclosure-open')) return;
if (event.target?.closest?.('#context-radio-dock')) return;
setRadioDisclosure(false);
}, tunerListenerOptions);
document.addEventListener('pointerdown', (event) => {
if (!this._cockpitUtilityControls || event.target?.closest?.('#cockpit-utility-controls')) return;
if (event.target?.closest?.('.cockpit-vision-controls')) return;
if (event.target?.closest?.('#left-panel-stack, #cockpit-context')) return;
setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false);
}, tunerListenerOptions);
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !this._contextRadioDock?.classList.contains('disclosure-open')) return;
event.preventDefault();
// Immediate: a plain stopPropagation() still lets every LATER listener on
// this same document run, so closing the disclosure ALSO dismissed the
// first-run launcher — one key, two actions. Matches the cockpit
// disclosure handler directly below.
event.stopImmediatePropagation();
const escapedFromDisclosure = event.target === this._contextRadioToggleBtn
|| this._contextRadioToggleBtn?.contains?.(event.target);
setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.();
}, { capture: true, signal: this._radioTunerAbort.signal });
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true';
const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true';
if (!displayOpen && !radioOpen) return;
const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)');
if (nestedPanel) return;
event.preventDefault();
event.stopImmediatePropagation();
const kind = displayOpen ? 'display' : 'radio';
const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn;
const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target);
setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) disclosure?.blur?.();
}, { capture: true, signal: this._radioTunerAbort.signal });
window.addEventListener('gev:cockpit-mode-changed', (event) => {
if (event?.detail?.active) return;
setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false);
}, tunerListenerOptions);
window.addEventListener('gev:cockpit-signal-expanded', () => {
setCockpitDisclosure('display', false);
}, tunerListenerOptions);
window.addEventListener('gev:cockpit-context-expanded', () => {
this.setPanelCollapsed('data-panel', true);
}, tunerListenerOptions);
this._radioFilter?.addEventListener('change', () => {
const presentation = radioLayer.getUIState();
if (!presentation.presentationActive) {
this._radioFilter.value = presentation.filter;
return;
}
if (this._radioTunerDragging) finishTuner(false);
if (!this._dataManager?.setLayerParams('radio', {
filter: this._radioFilter.value,
}, { origin: 'user' })) {
this._radioFilter.value = radioLayer.getUIState().filter;
return;
}
this._radioTunerBandPinnedForNavigation = false;
this._radioTunerPool = [];
refreshTunerBand({ force: true });
});
this._radioPrevBtn?.addEventListener('click', () => cycleRadio(-1));
this._radioNextBtn?.addEventListener('click', () => cycleRadio(1));
this._radioPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' }));
this._radioStopBtn?.addEventListener('click', () => radioLayer.stopPlayback({ origin: 'user' }));
this._radioVolume?.addEventListener('input', () => {
const value = Number(this._radioVolume.value);
if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${value}%`;
this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' });
});
this._contextRadioMiniPrevBtn?.addEventListener('click', () => cycleRadio(-1));
this._contextRadioMiniNextBtn?.addEventListener('click', () => cycleRadio(1));
this._contextRadioMiniPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' }));
// Cockpit owns the Cesium camera even though it intentionally clears
// viewer.trackedEntity. Station changes must never start the map-view
// rotation/fallback flights that would compete with its preUpdate pose.
this._cockpitRadioPrevBtn?.addEventListener('click', () => cycleRadio(-1, { rotate: false }));
this._cockpitRadioNextBtn?.addEventListener('click', () => cycleRadio(1, { rotate: false }));
this._cockpitRadioPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' }));
this._contextRadioMiniVolume?.addEventListener('input', () => {
const value = Number(this._contextRadioMiniVolume.value);
if (this._contextRadioMiniVolumeValue) this._contextRadioMiniVolumeValue.textContent = `${value}%`;
this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' });
});
this._cockpitRadioVolume?.addEventListener('input', () => {
const value = Number(this._cockpitRadioVolume.value);
if (this._cockpitRadioVolumeValue) this._cockpitRadioVolumeValue.textContent = `${value}%`;
this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' });
});
const updateTunerFromPointer = (event) => {
const rect = this._radioTunerSlider?.getBoundingClientRect();
if (!rect || !this._radioTunerStations.length) return false;
const position = radioTunerPointerPosition(
event.clientX,
rect.left,
rect.width,
this._radioTunerStations.length,
);
if (position.stationIndex > this._radioTunerLastSlot) this._radioTunerDragDirection = 1;
else if (position.stationIndex < this._radioTunerLastSlot) this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = position.stationIndex;
this._radioTunerCoordinate = position.coordinate;
this._radioTunerSlider.value = String(position.stationIndex);
tunerPreview({ coordinate: position.coordinate });
return true;
};
this._radioTunerSlider?.addEventListener('pointerdown', (event) => {
if (!beginTuner()) return;
this._radioTunerPointerId = event.pointerId;
this._radioTunerSlider.focus({ preventScroll: true });
try { this._radioTunerSlider.setPointerCapture(event.pointerId); } catch { /* capture is best effort */ }
updateTunerFromPointer(event);
event.preventDefault();
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('pointermove', (event) => {
if (!this._radioTunerDragging || this._radioTunerPointerId !== event.pointerId) return;
updateTunerFromPointer(event);
event.preventDefault();
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('input', () => {
if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) return;
if (!this._radioTunerDragging && !beginTuner()) return;
const inputSlot = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length);
if (inputSlot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1;
else if (inputSlot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = inputSlot.slot;
this._radioTunerCoordinate = inputSlot.stationIndex;
tunerPreview({ coordinate: this._radioTunerCoordinate });
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('change', () => {
if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey) finishTuner(true);
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('pointerup', (event) => {
if (this._radioTunerPointerId !== event.pointerId) return;
updateTunerFromPointer(event);
finishTuner(true);
try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ }
event.preventDefault();
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('pointercancel', (event) => {
if (this._radioTunerPointerId !== null && this._radioTunerPointerId !== event.pointerId) return;
try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ }
finishTuner(false);
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('lostpointercapture', (event) => {
if (this._radioTunerDragging && this._radioTunerPointerId === event.pointerId) finishTuner(false);
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && this._radioTunerDragging) {
event.preventDefault();
finishTuner(false);
return;
}
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return;
if (!this._radioTunerDragging && !beginTuner()) return;
event.preventDefault();
this._radioTunerKeyboardKey = event.key;
const max = Math.max(0, this._radioTunerStations.length - 1);
const current = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length).slot;
const page = Math.max(1, Math.round(max / 10));
const next = event.key === 'Home' ? 0
: event.key === 'End' ? max
: event.key === 'PageUp' ? current + page
: event.key === 'PageDown' ? current - page
: current + (event.key === 'ArrowRight' ? 1 : -1);
const slot = radioTunerSlot(next, this._radioTunerStations.length);
if (slot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1;
else if (slot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = slot.slot;
this._radioTunerCoordinate = slot.stationIndex;
this._radioTunerSlider.value = String(slot.slot);
tunerPreview({ coordinate: this._radioTunerCoordinate });
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('keyup', (event) => {
if (!this._radioTunerKeyboardKey || event.key !== this._radioTunerKeyboardKey) return;
event.preventDefault();
finishTuner(true);
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('blur', () => finishTuner(true), tunerListenerOptions);
const releaseNavigationBand = () => {
this._radioTunerBandPinnedForNavigation = false;
};
this.viewer?.canvas?.addEventListener('pointerdown', releaseNavigationBand, tunerListenerOptions);
this.viewer?.canvas?.addEventListener('wheel', releaseNavigationBand, tunerListenerOptions);
// The directory order is catalog/filter authority, not camera authority.
// Globe motion therefore never rebuilds or re-ranks the frequency band.
this._radioTunerCameraRemove?.();
this._radioTunerCameraRemove = null;
this._radioSelectedHandler = () => this.setPanelCollapsed('radio-panel', false);
document.addEventListener('gev:radio-selected', this._radioSelectedHandler);
}
/**
* Reveal the newly enabled directory and transport inside Context without
* moving focus, the page, or the globe. Only the expanded Enable path calls
* this helper.
* @param {HTMLElement} trigger Initiating Radio Enable button.
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioControlsAfterExplicitEnable(trigger) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
const directory = this._radioPanel?.querySelector('.radio-directory-row');
const transport = this._radioPanel?.querySelector('.radio-transport');
if (!contextPanel || contextPanel.classList.contains('collapsed')
|| !scroller || !directory || !transport || !this._radioState?.enabled) return false;
_setCockpitDisclosure(...args) { return this._radioControls?._setCockpitDisclosure?.(...args); }
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
if (!this._radioState?.enabled || !trigger?.isConnected) return false;
_setRadioDisclosure(...args) { return this._radioControls?._setRadioDisclosure?.(...args); }
const viewport = scroller.getBoundingClientRect();
const directoryRect = directory.getBoundingClientRect();
const transportRect = transport.getBoundingClientRect();
const margin = 10;
const minimum = scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin);
const maximum = scroller.scrollTop + directoryRect.top - (viewport.top + margin);
const desired = minimum <= maximum
? Math.min(Math.max(scroller.scrollTop, minimum), maximum)
: minimum;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
if (Math.abs(next - scroller.scrollTop) < 1) return false;
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' });
return true;
}
_syncContextRadioLauncherState(...args) { return this._radioControls?._syncContextRadioLauncherState?.(...args); }
/**
* Bring the embedded Radio section into the expanded Context scroller.
* This never changes Radio power, playback, selection, or Context mode.
* @param {{focusTarget?: HTMLElement|null}} [options]
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
if (!contextPanel || contextPanel.classList.contains('collapsed')
|| !scroller || !this._radioPanel || this._radioPanel.classList.contains('collapsed')) return false;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
if (contextPanel.classList.contains('collapsed') || this._radioPanel.classList.contains('collapsed')) return false;
const viewport = scroller.getBoundingClientRect();
const radioRect = this._radioPanel.getBoundingClientRect();
const desired = scroller.scrollTop + radioRect.top - viewport.top - 10;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
const moved = Math.abs(next - scroller.scrollTop) >= 1;
if (moved) {
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' });
}
focusTarget?.focus?.({ preventScroll: true });
return moved;
}
/** Keep the Context header Radio shortcut truthful for its current route. */
_syncContextRadioLauncherState() {
if (!this._contextRadioToggleBtn) return;
const contextPanel = document.getElementById('global-context-panel');
const contextExpanded = Boolean(contextPanel && !contextPanel.classList.contains('collapsed'));
if (contextExpanded) {
const radioExpanded = Boolean(this._radioPanel && !this._radioPanel.classList.contains('collapsed'));
this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel');
this._contextRadioToggleBtn.setAttribute('aria-expanded', String(radioExpanded));
const label = radioExpanded ? 'Go to expanded Radio section' : 'Expand Radio section in Context';
this._contextRadioToggleBtn.setAttribute('aria-label', label);
this._contextRadioToggleBtn.title = label;
return;
}
const compactOpen = Boolean(this._contextRadioDock?.classList.contains('disclosure-open'));
this._contextRadioToggleBtn.setAttribute('aria-controls', 'context-radio-mini');
this._contextRadioToggleBtn.setAttribute('aria-expanded', String(compactOpen));
const action = compactOpen ? 'Close' : 'Open';
this._contextRadioToggleBtn.setAttribute('aria-label', `${action} compact Radio controls`);
this._contextRadioToggleBtn.title = `${action} compact Radio controls`;
}
/** Render Radio state without making playback or Context decisions. */
_renderRadioState(state) {
if (!state || !this._radioPanel) return;
const lifecycle = this._dataManager?.getLayerLifecycleState?.('radio') || null;
const lifecycleState = lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled');
state = {
...state,
enabled: lifecycle ? lifecycle.enabled : state.enabled,
lifecycleState,
lifecycleUncertain: lifecycle?.uncertain || false,
};
this._radioState = state;
const enabled = Boolean(state.enabled);
const transitioning = lifecycleState === 'enabling' || lifecycleState === 'disabling';
const uncertain = Boolean(state.lifecycleUncertain);
const interactive = enabled && !transitioning && !uncertain;
const selected = state.selected || null;
const hasStations = state.filteredCount > 0;
const activePlayback = ['playing', 'buffering'].includes(state.audioState);
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._contextRadioDock?.classList.toggle('active', enabled);
if (this._contextRadioToggleBtn) {
this._contextRadioToggleBtn.classList.toggle('active', enabled);
}
this._syncContextRadioLauncherState();
this._radioLayerState?.classList.toggle('active', enabled);
if (this._radioLayerState) {
this._radioLayerState.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'UNCERTAIN' : (state.loading ? 'SYNC' : (enabled ? `${state.filteredCount}/${state.stationCount}` : 'OFF')));
}
if (this._radioEnableBtn) {
this._radioEnableBtn.classList.toggle('active', enabled);
this._radioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._radioEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE'));
this._radioEnableBtn.setAttribute(
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._radioEnableBtn.disabled = false;
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._contextRadioMiniEnableBtn) {
this._contextRadioMiniEnableBtn.classList.toggle('active', enabled);
this._contextRadioMiniEnableBtn.setAttribute('aria-pressed', String(enabled));
this._contextRadioMiniEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE'));
this._contextRadioMiniEnableBtn.setAttribute(
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._contextRadioMiniEnableBtn.disabled = false;
this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._cockpitRadioEnableBtn) {
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._cockpitRadioEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE'));
this._cockpitRadioEnableBtn.setAttribute(
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._cockpitRadioEnableBtn.disabled = false;
this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
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;
option.textContent = `● ${category.label} (${category.count})`;
option.dataset.radioColor = category.color;
option.style.color = category.color;
option.setAttribute('aria-label', `${category.label} (${category.count})`);
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 tunerAvailable = interactive && state.filteredCount > 0;
if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable;
if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable;
if (this._radioTunerBandLabel) {
const activeCategory = state.categories.find((category) => category.id === state.filter);
this._radioTunerBandLabel.textContent = state.filter === 'all'
? 'DIRECTORY BAND'
: `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`;
}
this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic));
if (tunerAvailable) this._refreshRadioTunerBand?.();
if (!tunerAvailable && this._radioTunerDragging) {
this._radioTunerDragging = false;
this._radioTunerDragSnapshot = null;
this._radioTunerStations = [];
this._radioTuner?.classList.remove('is-static', 'is-dragging');
}
if (!tunerAvailable) {
this._radioTunerStations = [];
this._radioTunerPool = [];
this._radioTunerBandSignature = '';
this._radioTunerSelectedId = null;
}
if (this._radioStationName) this._radioStationName.textContent = selected?.name || '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(' / ') || 'Directory metadata only'
: (state.loading ? 'Loading station directory…' : '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 ? `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');
}
if (this._radioPrevBtn) this._radioPrevBtn.disabled = !interactive || !hasStations;
if (this._radioNextBtn) this._radioNextBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniPrevBtn) this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniNextBtn) this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioPrevBtn) this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioNextBtn) this._cockpitRadioNextBtn.disabled = !interactive || !hasStations;
if (this._radioPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play');
this._radioPlayBtn.disabled = !interactive || !hasStations;
this._radioPlayBtn.classList.toggle('active', activePlayback);
this._radioPlayBtn.textContent = action.toUpperCase();
this._radioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`);
}
if (this._contextRadioMiniPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play');
this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations;
this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback);
this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._contextRadioMiniPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`);
this._contextRadioMiniPlayBtn.title = action;
}
if (this._cockpitRadioPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play');
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._cockpitRadioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`);
this._cockpitRadioPlayBtn.title = action;
}
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)}%`;
}
if (this._contextRadioMiniVolume && document.activeElement !== this._contextRadioMiniVolume) {
this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100));
}
if (this._contextRadioMiniVolume) this._contextRadioMiniVolume.disabled = !interactive;
if (this._contextRadioMiniVolumeValue) {
this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._cockpitRadioVolume && document.activeElement !== this._cockpitRadioVolume) {
this._cockpitRadioVolume.value = String(Math.round(state.volume * 100));
}
if (this._cockpitRadioVolume) this._cockpitRadioVolume.disabled = !interactive;
if (this._cockpitRadioVolumeValue) {
this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._contextRadioMiniStation) {
this._contextRadioMiniStation.textContent = uncertain
? 'RADIO STATE UNCERTAIN'
: (selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY'));
}
if (this._cockpitRadioStation) {
this._cockpitRadioStation.textContent = uncertain
? 'UNCERTAIN'
: (selected?.name || (state.loading ? 'SYNCING' : 'READY'));
}
if (this._radioPlaybackState) {
const catalogSuffix = state.degraded
? (state.stale ? ' · stale/degraded directory' : ' · degraded directory')
: (state.stale ? ' · stale directory' : '');
const outsideFilter = selected && state.selectedIndex < 0 ? ' · outside current filter' : '';
const messages = {
stopped: enabled ? 'Ready — playback starts only from your action' : 'Radio off',
loading: 'Connecting directly to broadcaster…',
buffering: 'Buffering broadcaster stream…',
playing: `Playing ${selected?.name || 'station'}`,
paused: `Paused ${selected?.name || 'station'}`,
error: state.audioError || 'Broadcaster stream unavailable',
};
const voiceSuffix = state.voiceDucked
? ' · muted during voice interaction'
: (state.voiceRestoring ? ' · restoring volume after voice' : '');
const tuningSuffix = state.tuningAwaitingStationId
? (state.audioState === 'error'
? ' · static indicates no broadcaster audio'
: ' · tuning static until broadcaster starts')
: '';
const unavailable = state.tuningUnavailableStationId
? 'Station unavailable after directory refresh — choose another channel'
: null;
const lifecycleMessage = transitioning
? (lifecycleState === 'enabling' ? 'Radio is enabling…' : 'Radio is disabling…')
: null;
const uncertainMessage = uncertain
? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile'
: null;
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
this._radioPlaybackState.classList.toggle('error', Boolean(uncertainMessage || unavailable || state.error || state.audioState === 'error'));
}
if (
!enabled
&& !transitioning
&& !this._preservePanelStateDuringLayerClear
&& !this._radioPanel.classList.contains('collapsed')
) {
this.setPanelCollapsed('radio-panel', true);
}
this._scheduleRightPanelLayout();
}
_renderRadioState(...args) { return this._radioControls?._renderRadioState?.(...args); }
/**
* Activates an explicit CCTV target, then releases tracking before its camera
@@ -9282,6 +8508,7 @@ export class StyleManager {
this._mapSourceControls?.destroy();
this._clearLayersControl?.destroy();
this._locationControls?.destroy();
this._radioControls?.destroy();
this._visualEffects.stop();
this._styleParameters?.destroy();
for (const control of this._panelDisclosureControls || []) control.destroy();
@@ -9419,22 +8646,7 @@ export class StyleManager {
window.removeEventListener('gev:cockpit-mode-changed', this._leftStackCockpitModeHandler);
this._leftStackCockpitModeHandler = null;
}
this._radioUnsubscribe?.();
this._radioUnsubscribe = null;
this._radioTunerAbort?.abort();
this._radioTunerAbort = null;
this._radioTunerBandPinnedForNavigation = false;
this._radioTunerDragSnapshot = null;
this._radioTunerPool = [];
this._radioTunerCameraRemove?.();
this._radioTunerCameraRemove = null;
this._refreshRadioTunerBand = null;
document.getElementById('title-bar')?.classList.remove('radio-broadcasting');
radioLayer.endTuning();
if (this._radioSelectedHandler) {
document.removeEventListener('gev:radio-selected', this._radioSelectedHandler);
this._radioSelectedHandler = null;
}
destroyTrackedReadout();
destroyDetection();
destroyWorldOverlay();
+2
View File
@@ -0,0 +1,2 @@
export { RadioControls } from './radioControls.js';
export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js';
+488
View File
@@ -0,0 +1,488 @@
import { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js';
/** Bind Radio input using a RadioControls receiver and supplied actions. */
export function bindRadioControls() {
if (!this._radioPanel) return;
const tunerListenerOptions = {};
const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => {
const open = Boolean(expanded);
this._contextRadioDock?.classList.toggle('disclosure-open', open);
if (this._contextRadioMini) this._contextRadioMini.hidden = !open;
this._syncContextRadioLauncherState();
if (!open && returnFocus) this._contextRadioToggleBtn?.focus({ preventScroll: true });
};
this._setRadioDisclosure = setRadioDisclosure;
const setCockpitDisclosure = (kind, expanded, { returnFocus = false } = {}) => {
const displayOpen = kind === 'display' && Boolean(expanded);
const radioOpen = kind === 'radio' && Boolean(expanded);
if (displayOpen || radioOpen) this.actions.setSignalCollapsed(true);
if (this._cockpitDisplayPanel) this._cockpitDisplayPanel.hidden = !displayOpen;
if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen;
this._cockpitDisplayToggleBtn?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', displayOpen);
this._cockpitRadioToggleBtn?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', radioOpen);
this._cockpitDisplayToggleBtn?.setAttribute('aria-expanded', String(displayOpen));
this._cockpitRadioToggleBtn?.setAttribute('aria-expanded', String(radioOpen));
if (displayOpen) this.actions.revealStyleParameters();
if (this._cockpitDisplayToggleBtn) {
const action = displayOpen ? 'Collapse' : 'Expand';
this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀';
this._cockpitDisplayToggleBtn.setAttribute('aria-label', `${action} Cockpit display options`);
this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`;
}
if (this._cockpitRadioToggleBtn) {
const action = radioOpen ? 'Collapse' : 'Expand';
this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀';
this._cockpitRadioToggleBtn.setAttribute('aria-label', `${action} Cockpit Radio controls`);
this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`;
}
if (!expanded && returnFocus) {
(kind === 'display' ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn)
?.focus({ preventScroll: true });
}
if (!displayOpen && !radioOpen
&& this.actions.isCockpitActive()
&& !this.actions.signalUserCollapsed()) {
this.actions.setSignalCollapsed(false);
}
this.actions.layoutCockpit();
};
this._setCockpitDisclosure = setCockpitDisclosure;
const syncTunerTape = (coordinate) => {
const scale = this._radioTuner?.querySelector('.radio-tuner-scale');
const dial = this._radioTuner?.querySelector('.radio-tuner-dial');
if (!scale || !dial) return null;
const model = buildRadioTunerTicks(
coordinate,
this._radioTunerStations.length,
dial.getBoundingClientRect().width,
);
while (scale.children.length < model.ticks.length) {
const tick = document.createElement('span');
tick.className = 'radio-tuner-tick';
scale.append(tick);
}
while (scale.children.length > model.ticks.length) scale.lastElementChild?.remove();
model.ticks.forEach((entry, index) => {
const tick = scale.children[index];
tick.style.left = `${entry.xPx}px`;
tick.textContent = entry.label;
tick.dataset.stationIndex = String(entry.stationIndex);
tick.classList.toggle('is-current', entry.current);
});
scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`);
return model;
};
const tunerPreview = ({ coordinate = this._radioTunerCoordinate, syncStatic = true, rotate = true } = {}) => {
const slot = radioTunerSlot(this._radioTunerSlider?.value, this._radioTunerStations.length);
const station = slot.locked ? this._radioTunerStations[slot.stationIndex] || null : null;
const resolvedCoordinate = this._radioTunerStations.length <= 1
? 0
: Math.min(this._radioTunerStations.length - 1, Math.max(0, Number(coordinate) || 0));
const ratio = this._radioTunerStations.length === 1
? 0.5
: resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1);
this._radioTunerCoordinate = resolvedCoordinate;
this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio));
this._radioTuner?.classList.toggle('is-static', syncStatic ? false : Boolean(this._radioState?.tuningStatic));
syncTunerTape(resolvedCoordinate);
if (this._radioTunerValue) {
this._radioTunerValue.textContent = station
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}`
: 'NO STATIONS';
}
if (this._radioTunerStation) this._radioTunerStation.textContent = station?.name || 'NO STATION AVAILABLE';
if (this._radioTunerSlider) {
this._radioTunerSlider.setAttribute('aria-valuetext', station
? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}`
: 'No station available');
}
if (syncStatic) this.radio.previewTuningStation(station?.id || null, { rotate });
return station;
};
const setTunerDirectory = (pool) => {
this._radioTunerPool = [...pool];
this._radioTunerStations = [...pool];
this._radioTunerBandSignature = this._radioTunerStations.map((station) => station.id).join('|');
if (this._radioTunerSlider) {
this._radioTunerSlider.min = '0';
this._radioTunerSlider.max = String(Math.max(0, this._radioTunerStations.length - 1));
this._radioTunerSlider.step = '1';
}
};
const refreshTunerBand = ({ force = false } = {}) => {
if (this._radioTunerDragging || this._radioTuner?.hidden || this._radioTunerSlider?.disabled) return false;
const selectedId = this._radioState?.selected?.id || null;
const pool = this.radio.getTunerStations(750);
const poolSignature = pool.map((station) => station.id).join('|');
const currentPoolSignature = this._radioTunerPool.map((station) => station.id).join('|');
if (!force && poolSignature === currentPoolSignature && selectedId === this._radioTunerSelectedId) return false;
setTunerDirectory(pool);
this._radioTunerSelectedId = selectedId;
const selectedPoolIndex = pool.findIndex((station) => station.id === selectedId);
const slot = radioTunerSlot(selectedPoolIndex >= 0 ? selectedPoolIndex : 0, this._radioTunerStations.length);
this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false });
return true;
};
this._refreshRadioTunerBand = refreshTunerBand;
const beginTuner = () => {
if (this._radioTunerDragging || this._radioTunerSlider?.disabled) return false;
refreshTunerBand();
if (!this._radioTunerStations.length || !this.radio.beginTuning()) return false;
// A tuner-owned camera preview must never replace the frozen directory.
// Only an explicit globe pointer/wheel gesture releases camera pinning.
this._radioTunerBandPinnedForNavigation = true;
this._radioTunerDragging = true;
this._radioTuner?.classList.add('is-dragging');
const selectedIndex = this._radioTunerStations.findIndex((station) => station.id === this._radioState?.selected?.id);
const slot = radioTunerSlot(selectedIndex >= 0 ? selectedIndex : 0, this._radioTunerStations.length);
this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
this._radioTunerDragStartSlot = slot.slot;
this._radioTunerDragSnapshot = {
stations: [...this._radioTunerStations],
bandSignature: this._radioTunerBandSignature,
selectedId: this._radioTunerSelectedId,
slot: slot.slot,
coordinate: this._radioTunerCoordinate,
};
this._radioTunerLastSlot = slot.slot;
this._radioTunerDragDirection = 0;
tunerPreview({ coordinate: this._radioTunerCoordinate });
return true;
};
const finishTuner = (commit) => {
if (!this._radioTunerDragging) return;
const dragSnapshot = this._radioTunerDragSnapshot;
if (!commit && dragSnapshot) {
this._radioTunerStations = [...dragSnapshot.stations];
this._radioTunerPool = [...dragSnapshot.stations];
this._radioTunerBandSignature = dragSnapshot.bandSignature;
this._radioTunerSelectedId = dragSnapshot.selectedId;
if (this._radioTunerSlider) {
const startSlot = radioTunerSlot(dragSnapshot.slot, this._radioTunerStations.length);
this._radioTunerSlider.max = String(startSlot.max);
this._radioTunerSlider.value = String(startSlot.slot);
}
this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate)
? dragSnapshot.coordinate
: dragSnapshot.slot;
} else if (!commit && this._radioTunerSlider) {
this._radioTunerSlider.value = String(this._radioTunerDragStartSlot);
this._radioTunerCoordinate = this._radioTunerDragStartSlot;
}
let station = tunerPreview({ coordinate: this._radioTunerCoordinate, rotate: commit });
if (commit && !station && this._radioTunerSlider) {
const snapped = radioTunerCommitSlot(
this._radioTunerSlider.value,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(snapped.slot);
this._radioTunerCoordinate = snapped.stationIndex;
station = tunerPreview({ coordinate: this._radioTunerCoordinate });
}
let result = null;
if (commit && station) {
// Keep the exact band used by the drag so the selected channel cannot
// jump to a refreshed catalog slot while its camera flight settles.
this._radioTunerBandPinnedForNavigation = true;
result = this.radio.commitTuningStation(station.id, { origin: 'user' });
} else if (!commit) {
this.radio.cancelTuning();
} else {
this.radio.endTuning();
}
// Radio emits selection/tuning state synchronously. Keep both the logical
// drag and the no-transition class active until that state has settled,
// then restore the exact selected slot before permitting CSS motion.
this._radioTunerDragging = false;
this._radioTunerPointerId = null;
this._radioTunerKeyboardKey = null;
if (commit && (!result || result.ok)) refreshTunerBand();
this._radioTunerDragSnapshot = null;
// Flush the snapped position while transitions are still disabled so
// removing the drag class cannot interpolate from the released gap.
void this._radioTunerNeedle?.offsetLeft;
this._radioTuner?.classList.remove('is-dragging');
if (result && !result.ok) {
this._radioTunerBandPinnedForNavigation = false;
if (result.reason === 'station-unavailable') {
if (this._radioTunerValue) this._radioTunerValue.textContent = 'OFF AIR';
if (this._radioTunerStation) this._radioTunerStation.textContent = 'STATION UNAVAILABLE';
this._radioTunerSlider?.setAttribute(
'aria-valuetext',
'Station unavailable after directory refresh',
);
}
}
};
const cycleRadio = (direction, { rotate = true } = {}) => {
this._radioTunerBandPinnedForNavigation = true;
const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations;
const cycled = this.radio.cycleStation(direction, {
rotate,
stationIds: pool.map((station) => station.id),
origin: 'user',
});
if (!cycled) {
this._radioTunerBandPinnedForNavigation = false;
return;
}
};
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');
try {
const toggled = await this.actions.runUserAction(
(notificationToken) => this.actions.setEnabled(enabling, {
origin: 'user',
notificationToken,
}),
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
);
if (this.destroyed || toggled === false) return;
if (enabling && trigger === this._radioEnableBtn
&& !document.getElementById('global-context-panel')?.classList.contains('collapsed')) {
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
}
if (revealAfterEnable) await this._revealRadioControlsAfterExplicitEnable(trigger);
} finally {
if (!this.destroyed) {
trigger.setAttribute('aria-disabled', 'false');
trigger.setAttribute('aria-busy', 'false');
if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true });
}
}
};
this.listen(this._radioEnableBtn, 'click', () => void toggleRadio(this._radioEnableBtn));
this.listen(this._contextRadioMiniEnableBtn, 'click', () => void toggleRadio(this._contextRadioMiniEnableBtn));
this.listen(this._cockpitRadioEnableBtn, 'click', () => void toggleRadio(this._cockpitRadioEnableBtn));
this.listen(this._contextRadioToggleBtn, 'click', () => {
const contextPanel = document.getElementById('global-context-panel');
if (contextPanel && !contextPanel.classList.contains('collapsed')) {
setRadioDisclosure(false);
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
void this._revealRadioPanelInsideContext({
focusTarget: this._radioPanel?.querySelector('[data-collapse-target="radio-panel"]'),
});
return;
}
setRadioDisclosure(!this._contextRadioDock?.classList.contains('disclosure-open'));
});
this.listen(this._contextRadioMiniCloseBtn, 'click', () => {
setRadioDisclosure(false, { returnFocus: true });
});
this.listen(this._contextRadioDetailsBtn, 'click', () => {
if (!this.actions.isCockpitActive()) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true });
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
setRadioDisclosure(false);
this._radioEnableBtn?.focus({ preventScroll: true });
});
this.listen(this._cockpitRadioToggleBtn, 'click', () => {
const open = this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true';
setCockpitDisclosure('radio', !open);
});
this.listen(document, 'pointerdown', (event) => {
if (!this._contextRadioDock?.classList.contains('disclosure-open')) return;
if (event.target?.closest?.('#context-radio-dock')) return;
setRadioDisclosure(false);
}, tunerListenerOptions);
this.listen(document, 'pointerdown', (event) => {
if (!this._cockpitUtilityControls || event.target?.closest?.('#cockpit-utility-controls')) return;
if (event.target?.closest?.('.cockpit-vision-controls')) return;
if (event.target?.closest?.('#left-panel-stack, #cockpit-context')) return;
setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false);
}, tunerListenerOptions);
this.listen(document, 'keydown', (event) => {
if (event.key !== 'Escape' || !this._contextRadioDock?.classList.contains('disclosure-open')) return;
event.preventDefault();
// Immediate: a plain stopPropagation() still lets every LATER listener on
// this same document run, so closing the disclosure ALSO dismissed the
// first-run launcher — one key, two actions. Matches the cockpit
// disclosure handler directly below.
event.stopImmediatePropagation();
const escapedFromDisclosure = event.target === this._contextRadioToggleBtn
|| this._contextRadioToggleBtn?.contains?.(event.target);
setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.();
}, { capture: true, });
this.listen(document, 'keydown', (event) => {
if (event.key !== 'Escape') return;
const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true';
const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true';
if (!displayOpen && !radioOpen) return;
const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)');
if (nestedPanel) return;
event.preventDefault();
event.stopImmediatePropagation();
const kind = displayOpen ? 'display' : 'radio';
const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn;
const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target);
setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) disclosure?.blur?.();
}, { capture: true, });
this.listen(window, 'gev:cockpit-mode-changed', (event) => {
if (event?.detail?.active) return;
setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false);
}, tunerListenerOptions);
this.listen(window, 'gev:cockpit-signal-expanded', () => {
setCockpitDisclosure('display', false);
}, tunerListenerOptions);
this.listen(window, 'gev:cockpit-context-expanded', () => {
this.actions.setPanelCollapsed('data-panel', true);
}, tunerListenerOptions);
this.listen(this._radioFilter, 'change', () => {
const presentation = this.radio.getUIState();
if (!presentation.presentationActive) {
this._radioFilter.value = presentation.filter;
return;
}
if (this._radioTunerDragging) finishTuner(false);
if (!this.actions.setParams({
filter: this._radioFilter.value,
}, { origin: 'user' })) {
this._radioFilter.value = this.radio.getUIState().filter;
return;
}
this._radioTunerBandPinnedForNavigation = false;
this._radioTunerPool = [];
refreshTunerBand({ force: true });
});
this.listen(this._radioPrevBtn, 'click', () => cycleRadio(-1));
this.listen(this._radioNextBtn, 'click', () => cycleRadio(1));
this.listen(this._radioPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' }));
this.listen(this._radioStopBtn, 'click', () => this.radio.stopPlayback({ origin: 'user' }));
this.listen(this._radioVolume, 'input', () => {
const value = Number(this._radioVolume.value);
if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
this.listen(this._contextRadioMiniPrevBtn, 'click', () => cycleRadio(-1));
this.listen(this._contextRadioMiniNextBtn, 'click', () => cycleRadio(1));
this.listen(this._contextRadioMiniPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' }));
// Cockpit owns the Cesium camera even though it intentionally clears
// viewer.trackedEntity. Station changes must never start the map-view
// rotation/fallback flights that would compete with its preUpdate pose.
this.listen(this._cockpitRadioPrevBtn, 'click', () => cycleRadio(-1, { rotate: false }));
this.listen(this._cockpitRadioNextBtn, 'click', () => cycleRadio(1, { rotate: false }));
this.listen(this._cockpitRadioPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' }));
this.listen(this._contextRadioMiniVolume, 'input', () => {
const value = Number(this._contextRadioMiniVolume.value);
if (this._contextRadioMiniVolumeValue) this._contextRadioMiniVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
this.listen(this._cockpitRadioVolume, 'input', () => {
const value = Number(this._cockpitRadioVolume.value);
if (this._cockpitRadioVolumeValue) this._cockpitRadioVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
const updateTunerFromPointer = (event) => {
const rect = this._radioTunerSlider?.getBoundingClientRect();
if (!rect || !this._radioTunerStations.length) return false;
const position = radioTunerPointerPosition(
event.clientX,
rect.left,
rect.width,
this._radioTunerStations.length,
);
if (position.stationIndex > this._radioTunerLastSlot) this._radioTunerDragDirection = 1;
else if (position.stationIndex < this._radioTunerLastSlot) this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = position.stationIndex;
this._radioTunerCoordinate = position.coordinate;
this._radioTunerSlider.value = String(position.stationIndex);
tunerPreview({ coordinate: position.coordinate });
return true;
};
this.listen(this._radioTunerSlider, 'pointerdown', (event) => {
if (!beginTuner()) return;
this._radioTunerPointerId = event.pointerId;
this._radioTunerSlider.focus({ preventScroll: true });
try { this._radioTunerSlider.setPointerCapture(event.pointerId); } catch { /* capture is best effort */ }
updateTunerFromPointer(event);
event.preventDefault();
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'pointermove', (event) => {
if (!this._radioTunerDragging || this._radioTunerPointerId !== event.pointerId) return;
updateTunerFromPointer(event);
event.preventDefault();
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'input', () => {
if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) return;
if (!this._radioTunerDragging && !beginTuner()) return;
const inputSlot = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length);
if (inputSlot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1;
else if (inputSlot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = inputSlot.slot;
this._radioTunerCoordinate = inputSlot.stationIndex;
tunerPreview({ coordinate: this._radioTunerCoordinate });
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'change', () => {
if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey) finishTuner(true);
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'pointerup', (event) => {
if (this._radioTunerPointerId !== event.pointerId) return;
updateTunerFromPointer(event);
finishTuner(true);
try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ }
event.preventDefault();
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'pointercancel', (event) => {
if (this._radioTunerPointerId !== null && this._radioTunerPointerId !== event.pointerId) return;
try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ }
finishTuner(false);
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'lostpointercapture', (event) => {
if (this._radioTunerDragging && this._radioTunerPointerId === event.pointerId) finishTuner(false);
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'keydown', (event) => {
if (event.key === 'Escape' && this._radioTunerDragging) {
event.preventDefault();
finishTuner(false);
return;
}
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return;
if (!this._radioTunerDragging && !beginTuner()) return;
event.preventDefault();
this._radioTunerKeyboardKey = event.key;
const max = Math.max(0, this._radioTunerStations.length - 1);
const current = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length).slot;
const page = Math.max(1, Math.round(max / 10));
const next = event.key === 'Home' ? 0
: event.key === 'End' ? max
: event.key === 'PageUp' ? current + page
: event.key === 'PageDown' ? current - page
: current + (event.key === 'ArrowRight' ? 1 : -1);
const slot = radioTunerSlot(next, this._radioTunerStations.length);
if (slot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1;
else if (slot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = slot.slot;
this._radioTunerCoordinate = slot.stationIndex;
this._radioTunerSlider.value = String(slot.slot);
tunerPreview({ coordinate: this._radioTunerCoordinate });
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'keyup', (event) => {
if (!this._radioTunerKeyboardKey || event.key !== this._radioTunerKeyboardKey) return;
event.preventDefault();
finishTuner(true);
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'blur', () => finishTuner(true), tunerListenerOptions);
const releaseNavigationBand = () => {
this._radioTunerBandPinnedForNavigation = false;
};
this.listen(this.canvas, 'pointerdown', releaseNavigationBand, tunerListenerOptions);
this.listen(this.canvas, 'wheel', releaseNavigationBand, tunerListenerOptions);
// The directory order is catalog/filter authority, not camera authority.
// Globe motion therefore never rebuilds or re-ranks the frequency band.
this._radioSelectedHandler = () => this.actions.setPanelCollapsed('radio-panel', false);
this.listen(document, 'gev:radio-selected', this._radioSelectedHandler);
}
+156
View File
@@ -0,0 +1,156 @@
import { bindRadioControls } from './radioBindings.js';
import { renderRadioState } from './radioPresentation.js';
/** Own Radio DOM, tuner interaction and subscription; receive playback and application actions. */
export class RadioControls {
constructor({ elements, radio, actions, canvas }) {
Object.assign(this, elements);
this.radio = radio;
this.actions = actions;
this.canvas = canvas;
this.destroyed = false;
this.listeners = new AbortController();
this._radioUnsubscribe = null;
this._radioState = null;
this._radioCategorySignature = '';
this._radioTunerStations = [];
this._radioTunerPool = [];
this._radioTunerDragging = false;
this._radioTunerDragStartSlot = 0;
this._radioTunerDragSnapshot = null;
this._radioTunerLastSlot = 0;
this._radioTunerDragDirection = 0;
this._radioTunerCoordinate = 0;
this._radioTunerPointerId = null;
this._radioTunerKeyboardKey = null;
this._radioTunerBandSignature = '';
this._radioTunerSelectedId = null;
this._radioTunerBandPinnedForNavigation = false;
this._refreshRadioTunerBand = null;
bindRadioControls.call(this);
}
listen(target, type, handler, options = {}) {
target?.addEventListener(type, handler, { ...options, signal: this.listeners.signal });
}
connect() {
this._radioUnsubscribe?.();
this._radioUnsubscribe = null;
if (this.destroyed) return;
this._radioUnsubscribe = this.radio.subscribe?.((state) => this._renderRadioState(state));
}
_renderRadioState(state) { renderRadioState.call(this, state); }
/**
* Reveal the newly enabled directory and transport inside Context without
* moving focus, the page, or the globe. Only the expanded Enable path calls
* this helper.
* @param {HTMLElement} trigger Initiating Radio Enable button.
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioControlsAfterExplicitEnable(trigger) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
const directory = this._radioPanel?.querySelector('.radio-directory-row');
const transport = this._radioPanel?.querySelector('.radio-transport');
if (!contextPanel || contextPanel.classList.contains('collapsed')
|| !scroller || !directory || !transport || !this._radioState?.enabled) return false;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
if (this.destroyed || !this._radioState?.enabled || !trigger?.isConnected) return false;
const viewport = scroller.getBoundingClientRect();
const directoryRect = directory.getBoundingClientRect();
const transportRect = transport.getBoundingClientRect();
const margin = 10;
const minimum = scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin);
const maximum = scroller.scrollTop + directoryRect.top - (viewport.top + margin);
const desired = minimum <= maximum
? Math.min(Math.max(scroller.scrollTop, minimum), maximum)
: minimum;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
if (Math.abs(next - scroller.scrollTop) < 1) return false;
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' });
return true;
}
/**
* Bring the embedded Radio section into the expanded Context scroller.
* This never changes Radio power, playback, selection, or Context mode.
* @param {{focusTarget?: HTMLElement|null}} [options]
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
if (!contextPanel || contextPanel.classList.contains('collapsed')
|| !scroller || !this._radioPanel || this._radioPanel.classList.contains('collapsed')) return false;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
if (this.destroyed || contextPanel.classList.contains('collapsed') || this._radioPanel.classList.contains('collapsed')) return false;
const viewport = scroller.getBoundingClientRect();
const radioRect = this._radioPanel.getBoundingClientRect();
const desired = scroller.scrollTop + radioRect.top - viewport.top - 10;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
const moved = Math.abs(next - scroller.scrollTop) >= 1;
if (moved) {
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' });
}
focusTarget?.focus?.({ preventScroll: true });
return moved;
}
/** Keep the Context header Radio shortcut truthful for its current route. */
_syncContextRadioLauncherState() {
if (!this._contextRadioToggleBtn) return;
const contextPanel = document.getElementById('global-context-panel');
const contextExpanded = Boolean(contextPanel && !contextPanel.classList.contains('collapsed'));
if (contextExpanded) {
const radioExpanded = Boolean(this._radioPanel && !this._radioPanel.classList.contains('collapsed'));
this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel');
this._contextRadioToggleBtn.setAttribute('aria-expanded', String(radioExpanded));
const label = radioExpanded ? 'Go to expanded Radio section' : 'Expand Radio section in Context';
this._contextRadioToggleBtn.setAttribute('aria-label', label);
this._contextRadioToggleBtn.title = label;
return;
}
const compactOpen = Boolean(this._contextRadioDock?.classList.contains('disclosure-open'));
this._contextRadioToggleBtn.setAttribute('aria-controls', 'context-radio-mini');
this._contextRadioToggleBtn.setAttribute('aria-expanded', String(compactOpen));
const action = compactOpen ? 'Close' : 'Open';
this._contextRadioToggleBtn.setAttribute('aria-label', `${action} compact Radio controls`);
this._contextRadioToggleBtn.title = `${action} compact Radio controls`;
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.listeners.abort();
this._radioUnsubscribe?.();
this._radioUnsubscribe = null;
const pointer = this._radioTunerPointerId;
this._radioTunerPointerId = null;
this._radioTunerDragging = false;
this._radioTunerKeyboardKey = null;
this._radioTunerDragSnapshot = null;
this._radioTunerPool = [];
this._radioTunerStations = [];
this._radioTunerBandPinnedForNavigation = false;
try { if (pointer !== null) this._radioTunerSlider?.releasePointerCapture(pointer); } catch { /* capture already released */ }
this._radioTuner?.classList.remove('is-dragging', 'is-static');
document.getElementById('title-bar')?.classList.remove('radio-broadcasting');
this.radio.endTuning();
}
}
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { RadioControls } from './radioControls.js';
class Element extends EventTarget {
constructor() {
super();
this.attrs = new Map();
this.classes = new Set();
this.focusCount = 0;
this.isConnected = true;
this.classList = {
add: (...names) => names.forEach((n) => this.classes.add(n)),
remove: (...names) => names.forEach((n) => this.classes.delete(n)),
contains: (name) => this.classes.has(name),
toggle: (name, enabled) => enabled ? this.classes.add(name) : this.classes.delete(name),
};
}
setAttribute(name, value) { this.attrs.set(name, String(value)); }
getAttribute(name) { return this.attrs.get(name); }
querySelector() { return null; }
focus() { this.focusCount++; }
}
function fixture() {
const prior = { document: globalThis.document, window: globalThis.window };
const document = new EventTarget();
document.getElementById = () => null;
globalThis.document = document;
globalThis.window = new EventTarget();
const panel = new Element();
const enable = new Element();
const next = new Element();
const calls = [];
const subscribers = [];
const radio = {
subscribe(callback) { subscribers.push(callback); calls.push('subscribe'); return () => calls.push('unsubscribe'); },
endTuning() { calls.push('endTuning'); },
cycleStation(direction) { calls.push(['cycle', direction]); return true; },
};
const actions = {
isRegistered: () => true, isEnabled: () => false,
runUserAction: (operation) => operation(),
setEnabled: async () => true,
setPanelCollapsed: (...args) => calls.push(['panel', ...args]),
layoutCockpit() {}, isCockpitActive: () => false,
scheduleLayout() {}, getLifecycle: () => null,
preservePanelStateDuringClear: () => false,
};
const controls = new RadioControls({
elements: { _radioPanel: panel, _radioEnableBtn: enable, _radioNextBtn: next },
radio, actions, canvas: new Element(),
});
return { controls, enable, next, actions, calls, subscribers, document,
cleanup() { controls.destroy(); Object.assign(globalThis, prior); } };
}
test('Radio listeners and subscriptions are revoked once before tuning teardown', () => {
const f = fixture();
try {
f.controls.connect();
f.next.dispatchEvent(new Event('click'));
assert.deepEqual(f.calls, ['subscribe', ['cycle', 1]]);
f.controls.destroy(); f.controls.destroy();
const snapshot = structuredClone(f.calls);
f.next.dispatchEvent(new Event('click'));
f.document.dispatchEvent(new Event('gev:radio-selected'));
f.subscribers[0]({ enabled: true });
f.controls.connect();
assert.deepEqual(f.calls, snapshot);
assert.deepEqual(f.calls.slice(-2), ['unsubscribe', 'endTuning']);
} finally { f.cleanup(); }
});
test('Radio reconnect removes its previous state subscription', () => {
const f = fixture();
try {
f.controls.connect(); f.controls.connect();
assert.deepEqual(f.calls, ['subscribe', 'unsubscribe', 'subscribe']);
} finally { f.cleanup(); }
});
test('a delayed Radio enable cannot reveal or refocus controls after destruction', async () => {
const f = fixture();
let resolve;
const pending = new Promise((done) => { resolve = done; });
try {
f.actions.setEnabled = () => pending;
f.enable.dispatchEvent(new Event('click'));
assert.equal(f.enable.getAttribute('aria-busy'), 'true');
f.controls.destroy();
const attrs = new Map(f.enable.attrs);
resolve(true);
await pending; await new Promise((done) => setImmediate(done));
assert.deepEqual(f.enable.attrs, attrs);
assert.equal(f.enable.focusCount, 0);
assert.equal(f.calls.some((call) => Array.isArray(call) && call[0] === 'panel'), false);
} finally { f.cleanup(); }
});
test('destruction releases an active tuner pointer after revoking listeners', () => {
const f = fixture();
try {
const released = [];
f.controls._radioTunerSlider = { releasePointerCapture(id) {
assert.equal(f.controls.listeners.signal.aborted, true);
released.push(id);
} };
f.controls._radioTunerPointerId = 7;
f.controls._radioTunerDragging = true;
f.controls.destroy();
assert.deepEqual(released, [7]);
assert.equal(f.controls._radioTunerDragging, false);
assert.equal(f.controls._radioTunerPointerId, null);
} finally { f.cleanup(); }
});
+245
View File
@@ -0,0 +1,245 @@
/** Render Radio state without making playback or Context decisions. */
export function renderRadioState(state) {
if (this.destroyed || !state || !this._radioPanel) return;
const lifecycle = this.actions.getLifecycle() || null;
const lifecycleState = lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled');
state = {
...state,
enabled: lifecycle ? lifecycle.enabled : state.enabled,
lifecycleState,
lifecycleUncertain: lifecycle?.uncertain || false,
};
this._radioState = state;
const enabled = Boolean(state.enabled);
const transitioning = lifecycleState === 'enabling' || lifecycleState === 'disabling';
const uncertain = Boolean(state.lifecycleUncertain);
const interactive = enabled && !transitioning && !uncertain;
const selected = state.selected || null;
const hasStations = state.filteredCount > 0;
const activePlayback = ['playing', 'buffering'].includes(state.audioState);
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._contextRadioDock?.classList.toggle('active', enabled);
if (this._contextRadioToggleBtn) {
this._contextRadioToggleBtn.classList.toggle('active', enabled);
}
this._syncContextRadioLauncherState();
this._radioLayerState?.classList.toggle('active', enabled);
if (this._radioLayerState) {
this._radioLayerState.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'UNCERTAIN' : (state.loading ? 'SYNC' : (enabled ? `${state.filteredCount}/${state.stationCount}` : 'OFF')));
}
if (this._radioEnableBtn) {
this._radioEnableBtn.classList.toggle('active', enabled);
this._radioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._radioEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE'));
this._radioEnableBtn.setAttribute(
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._radioEnableBtn.disabled = false;
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._contextRadioMiniEnableBtn) {
this._contextRadioMiniEnableBtn.classList.toggle('active', enabled);
this._contextRadioMiniEnableBtn.setAttribute('aria-pressed', String(enabled));
this._contextRadioMiniEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE'));
this._contextRadioMiniEnableBtn.setAttribute(
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._contextRadioMiniEnableBtn.disabled = false;
this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._cockpitRadioEnableBtn) {
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._cockpitRadioEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE'));
this._cockpitRadioEnableBtn.setAttribute(
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._cockpitRadioEnableBtn.disabled = false;
this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
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;
option.textContent = `● ${category.label} (${category.count})`;
option.dataset.radioColor = category.color;
option.style.color = category.color;
option.setAttribute('aria-label', `${category.label} (${category.count})`);
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 tunerAvailable = interactive && state.filteredCount > 0;
if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable;
if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable;
if (this._radioTunerBandLabel) {
const activeCategory = state.categories.find((category) => category.id === state.filter);
this._radioTunerBandLabel.textContent = state.filter === 'all'
? 'DIRECTORY BAND'
: `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`;
}
this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic));
if (tunerAvailable) this._refreshRadioTunerBand?.();
if (!tunerAvailable && this._radioTunerDragging) {
this._radioTunerDragging = false;
this._radioTunerDragSnapshot = null;
this._radioTunerStations = [];
this._radioTuner?.classList.remove('is-static', 'is-dragging');
}
if (!tunerAvailable) {
this._radioTunerStations = [];
this._radioTunerPool = [];
this._radioTunerBandSignature = '';
this._radioTunerSelectedId = null;
}
if (this._radioStationName) this._radioStationName.textContent = selected?.name || '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(' / ') || 'Directory metadata only'
: (state.loading ? 'Loading station directory…' : '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 ? `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');
}
if (this._radioPrevBtn) this._radioPrevBtn.disabled = !interactive || !hasStations;
if (this._radioNextBtn) this._radioNextBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniPrevBtn) this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniNextBtn) this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioPrevBtn) this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioNextBtn) this._cockpitRadioNextBtn.disabled = !interactive || !hasStations;
if (this._radioPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play');
this._radioPlayBtn.disabled = !interactive || !hasStations;
this._radioPlayBtn.classList.toggle('active', activePlayback);
this._radioPlayBtn.textContent = action.toUpperCase();
this._radioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`);
}
if (this._contextRadioMiniPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play');
this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations;
this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback);
this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._contextRadioMiniPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`);
this._contextRadioMiniPlayBtn.title = action;
}
if (this._cockpitRadioPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play');
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._cockpitRadioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`);
this._cockpitRadioPlayBtn.title = action;
}
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)}%`;
}
if (this._contextRadioMiniVolume && document.activeElement !== this._contextRadioMiniVolume) {
this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100));
}
if (this._contextRadioMiniVolume) this._contextRadioMiniVolume.disabled = !interactive;
if (this._contextRadioMiniVolumeValue) {
this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._cockpitRadioVolume && document.activeElement !== this._cockpitRadioVolume) {
this._cockpitRadioVolume.value = String(Math.round(state.volume * 100));
}
if (this._cockpitRadioVolume) this._cockpitRadioVolume.disabled = !interactive;
if (this._cockpitRadioVolumeValue) {
this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._contextRadioMiniStation) {
this._contextRadioMiniStation.textContent = uncertain
? 'RADIO STATE UNCERTAIN'
: (selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY'));
}
if (this._cockpitRadioStation) {
this._cockpitRadioStation.textContent = uncertain
? 'UNCERTAIN'
: (selected?.name || (state.loading ? 'SYNCING' : 'READY'));
}
if (this._radioPlaybackState) {
const catalogSuffix = state.degraded
? (state.stale ? ' · stale/degraded directory' : ' · degraded directory')
: (state.stale ? ' · stale directory' : '');
const outsideFilter = selected && state.selectedIndex < 0 ? ' · outside current filter' : '';
const messages = {
stopped: enabled ? 'Ready — playback starts only from your action' : 'Radio off',
loading: 'Connecting directly to broadcaster…',
buffering: 'Buffering broadcaster stream…',
playing: `Playing ${selected?.name || 'station'}`,
paused: `Paused ${selected?.name || 'station'}`,
error: state.audioError || 'Broadcaster stream unavailable',
};
const voiceSuffix = state.voiceDucked
? ' · muted during voice interaction'
: (state.voiceRestoring ? ' · restoring volume after voice' : '');
const tuningSuffix = state.tuningAwaitingStationId
? (state.audioState === 'error'
? ' · static indicates no broadcaster audio'
: ' · tuning static until broadcaster starts')
: '';
const unavailable = state.tuningUnavailableStationId
? 'Station unavailable after directory refresh — choose another channel'
: null;
const lifecycleMessage = transitioning
? (lifecycleState === 'enabling' ? 'Radio is enabling…' : 'Radio is disabling…')
: null;
const uncertainMessage = uncertain
? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile'
: null;
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
this._radioPlaybackState.classList.toggle('error', Boolean(uncertainMessage || unavailable || state.error || state.audioState === 'error'));
}
if (
!enabled
&& !transitioning
&& !this.actions.preservePanelStateDuringClear()
&& !this._radioPanel.classList.contains('collapsed')
) {
this.actions.setPanelCollapsed('radio-panel', true);
}
this.actions.scheduleLayout();
}
+87
View File
@@ -0,0 +1,87 @@
/** Map an integer tuner slot directly to one available directory station. */
export function radioTunerSlot(value, stationCount) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count) return { slot: 0, max: 0, locked: false, stationIndex: -1, leftIndex: -1, rightIndex: -1 };
const max = Math.max(0, count - 1);
const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0)));
return {
slot,
max,
locked: true,
stationIndex: slot,
leftIndex: slot,
rightIndex: slot,
};
}
/** Snap a tuner release to the nearest available directory station. */
export function radioTunerCommitSlot(value, stationCount) {
return radioTunerSlot(value, stationCount);
}
/** Map one pointer coordinate to continuous absolute directory progress. */
export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 };
if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 };
const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2);
const ratio = Math.min(1, Math.max(0, ((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth));
const coordinate = ratio * (count - 1);
return {
ratio,
coordinate,
stationIndex: Math.min(count - 1, Math.max(0, Math.floor(coordinate + 0.5))),
};
}
/** Build a bounded virtual tuner tape around one continuous directory coordinate. */
export function buildRadioTunerTicks(coordinate, stationCount, width, {
insetPx = 7,
minPitchPx = 14,
speedFactor = 5,
overscan = 2,
labelStep = 6,
} = {}) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
const dialWidth = Math.max(0, Number(width) || 0);
const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(0, dialWidth - inset * 2);
if (!count) return { ticks: [], needleX: inset, pitchPx: 0, ratio: 0 };
const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0));
if (count === 1) {
return {
ticks: [{ stationIndex: 0, channel: 1, xPx: inset + usableWidth / 2, current: true, label: '01' }],
needleX: inset + usableWidth / 2,
pitchPx: Math.max(1, Number(minPitchPx) || 14),
ratio: 0.5,
};
}
const directoryStep = usableWidth / (count - 1);
const pitchPx = Math.max(
Math.max(1, Number(minPitchPx) || 14),
directoryStep * Math.max(1, Number(speedFactor) || 5),
);
const needleX = inset + directoryStep * value;
const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0);
const first = Math.max(0, Math.ceil(value + (-overscanPx - needleX) / pitchPx));
const last = Math.min(count - 1, Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx));
const currentIndex = Math.min(count - 1, Math.max(0, Math.floor(value + 0.5)));
const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6));
const labelWidth = Math.max(2, String(count).length);
const ticks = [];
for (let stationIndex = first; stationIndex <= last; stationIndex += 1) {
const channel = stationIndex + 1;
const current = stationIndex === currentIndex;
const labelled = current || stationIndex === 0 || stationIndex === count - 1 || channel % majorEvery === 0;
ticks.push({
stationIndex,
channel,
xPx: needleX + pitchPx * (stationIndex - value),
current,
label: labelled ? String(channel).padStart(labelWidth, '0') : '',
});
}
return { ticks, needleX, pitchPx, ratio: value / (count - 1) };
}