style: format Radio component boundary

This commit is contained in:
Sameh Khamis committed 2026-09-12 23:25:09 -07:00
1 parent 7b9aca8597
commit bd6cc5abe8
7 files changed
+943 -433

No files matched your search

+53 -163
View File
@@ -10,47 +10,27 @@
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js" "src/data/localGeojsonLod.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"application": { "application": {
"exports": [ "exports": ["./application"],
"./application" "modules": ["src/app/application.js"],
],
"modules": [
"src/app/application.js"
],
"external": [] "external": []
}, },
"viewer": { "viewer": {
"exports": [ "exports": ["./application/viewer"],
"./application/viewer" "modules": ["src/app/viewer.js"],
], "external": ["cesium"]
"modules": [
"src/app/viewer.js"
],
"external": [
"cesium"
]
}, },
"vite-build": { "vite-build": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./build/vite"],
"./build/vite" "modules": ["build/vite.js"],
], "external": ["vite-plugin-cesium"]
"modules": [
"build/vite.js"
],
"external": [
"vite-plugin-cesium"
]
}, },
"live-providers": { "live-providers": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/live"],
"./server/providers/live"
],
"modules": [ "modules": [
"server/providers/live.js", "server/providers/live.js",
"server/providers/common/http.js", "server/providers/common/http.js",
@@ -65,24 +45,16 @@
"src/data/aisStreamAdapter.js", "src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js" "src/data/aisWatchdog.js"
], ],
"external": [ "external": ["ws"]
"ws"
]
}, },
"adsb-lol-source": { "adsb-lol-source": {
"exports": [ "exports": ["./sources/adsb-lol"],
"./sources/adsb-lol" "modules": ["src/data/adsbLolFallback.js"],
],
"modules": [
"src/data/adsbLolFallback.js"
],
"external": [] "external": []
}, },
"place-providers": { "place-providers": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/places"],
"./server/providers/places"
],
"modules": [ "modules": [
"server/providers/places.js", "server/providers/places.js",
"server/providers/places/google.js", "server/providers/places/google.js",
@@ -97,19 +69,13 @@
"external": [] "external": []
}, },
"place-payloads": { "place-payloads": {
"exports": [ "exports": ["./sources/places"],
"./sources/places" "modules": ["src/data/placeProviderPayloads.js"],
],
"modules": [
"src/data/placeProviderPayloads.js"
],
"external": [] "external": []
}, },
"space-providers": { "space-providers": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/space"],
"./server/providers/space"
],
"modules": [ "modules": [
"server/providers/space.js", "server/providers/space.js",
"server/providers/space/celestrak.js", "server/providers/space/celestrak.js",
@@ -120,19 +86,13 @@
"external": [] "external": []
}, },
"space-requests": { "space-requests": {
"exports": [ "exports": ["./sources/space"],
"./sources/space" "modules": ["src/data/spaceProviderRequests.js"],
],
"modules": [
"src/data/spaceProviderRequests.js"
],
"external": [] "external": []
}, },
"terrain-provider": { "terrain-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/terrain"],
"./server/providers/terrain"
],
"modules": [ "modules": [
"server/providers/terrain.js", "server/providers/terrain.js",
"src/data/terrainHeightsProxy.js" "src/data/terrainHeightsProxy.js"
@@ -140,50 +100,30 @@
"external": [] "external": []
}, },
"terrain-source": { "terrain-source": {
"exports": [ "exports": ["./sources/terrain"],
"./sources/terrain" "modules": ["src/data/terrainHeightsProxy.js"],
],
"modules": [
"src/data/terrainHeightsProxy.js"
],
"external": [] "external": []
}, },
"traffic-provider": { "traffic-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/traffic"],
"./server/providers/traffic" "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
],
"modules": [
"server/providers/traffic.js",
"src/data/tomtomTiles.js"
],
"external": [] "external": []
}, },
"traffic-source": { "traffic-source": {
"exports": [ "exports": ["./sources/traffic"],
"./sources/traffic" "modules": ["src/data/tomtomTiles.js"],
],
"modules": [
"src/data/tomtomTiles.js"
],
"external": [] "external": []
}, },
"firms-provider": { "firms-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/firms"],
"./server/providers/firms" "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
],
"modules": [
"server/providers/firms.js",
"src/data/firmsCsv.js"
],
"external": [] "external": []
}, },
"gbfs-provider": { "gbfs-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/gbfs"],
"./server/providers/gbfs"
],
"modules": [ "modules": [
"server/providers/gbfs.js", "server/providers/gbfs.js",
"server/providers/common/http.js", "server/providers/common/http.js",
@@ -192,19 +132,13 @@
"external": [] "external": []
}, },
"gbfs-source": { "gbfs-source": {
"exports": [ "exports": ["./sources/gbfs"],
"./sources/gbfs" "modules": ["src/data/gbfsSource.js"],
],
"modules": [
"src/data/gbfsSource.js"
],
"external": [] "external": []
}, },
"cctv-provider": { "cctv-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/cctv"],
"./server/providers/cctv"
],
"modules": [ "modules": [
"server/providers/cctv.js", "server/providers/cctv.js",
"server/providers/cctv/catalog.js", "server/providers/cctv/catalog.js",
@@ -221,9 +155,7 @@
}, },
"radio-provider": { "radio-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/radio"],
"./server/providers/radio"
],
"modules": [ "modules": [
"server/providers/radio.js", "server/providers/radio.js",
"server/providers/radio/catalog.js", "server/providers/radio/catalog.js",
@@ -237,9 +169,7 @@
}, },
"overpass-provider": { "overpass-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/overpass"],
"./server/providers/overpass"
],
"modules": [ "modules": [
"server/providers/common/geo.js", "server/providers/common/geo.js",
"server/providers/common/http.js", "server/providers/common/http.js",
@@ -258,9 +188,7 @@
}, },
"military-installations-provider": { "military-installations-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/military-installations"],
"./server/providers/military-installations"
],
"modules": [ "modules": [
"server/providers/common/http.js", "server/providers/common/http.js",
"server/providers/common/query.js", "server/providers/common/query.js",
@@ -277,9 +205,7 @@
}, },
"regional-provider": { "regional-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/regional"],
"./server/providers/regional"
],
"modules": [ "modules": [
"server/providers/common/http.js", "server/providers/common/http.js",
"server/providers/common/query.js", "server/providers/common/query.js",
@@ -298,9 +224,7 @@
}, },
"openai-provider": { "openai-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/openai"],
"./server/providers/openai"
],
"modules": [ "modules": [
"server/providers/common/rate-limit.js", "server/providers/common/rate-limit.js",
"server/providers/common/request.js", "server/providers/common/request.js",
@@ -320,9 +244,7 @@
}, },
"standalone-key-setup": { "standalone-key-setup": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/standalone/key-setup"],
"./server/standalone/key-setup"
],
"modules": [ "modules": [
"scripts/pinokio-environment.mjs", "scripts/pinokio-environment.mjs",
"server/providers/common/source-root.js", "server/providers/common/source-root.js",
@@ -333,9 +255,7 @@
"external": [] "external": []
}, },
"place-search": { "place-search": {
"exports": [ "exports": ["./search"],
"./search"
],
"modules": [ "modules": [
"src/search/index.js", "src/search/index.js",
"src/search/placeSearch.js", "src/search/placeSearch.js",
@@ -345,27 +265,17 @@
"external": [] "external": []
}, },
"panel-disclosure": { "panel-disclosure": {
"exports": [ "exports": ["./ui/panels"],
"./ui/panels" "modules": ["src/ui/panelDisclosure.js"],
],
"modules": [
"src/ui/panelDisclosure.js"
],
"external": [] "external": []
}, },
"surface-keyboard": { "surface-keyboard": {
"exports": [ "exports": ["./ui/surfaces"],
"./ui/surfaces" "modules": ["src/ui/surfaceKeyboard.js"],
],
"modules": [
"src/ui/surfaceKeyboard.js"
],
"external": [] "external": []
}, },
"panel-rail-layout": { "panel-rail-layout": {
"exports": [ "exports": ["./ui/layout"],
"./ui/layout"
],
"modules": [ "modules": [
"src/ui/panelRails.js", "src/ui/panelRails.js",
"src/ui/leftPanelRail.js", "src/ui/leftPanelRail.js",
@@ -377,9 +287,7 @@
"external": [] "external": []
}, },
"visual-input": { "visual-input": {
"exports": [ "exports": ["./ui/input"],
"./ui/input"
],
"modules": [ "modules": [
"src/ui/visualInput.js", "src/ui/visualInput.js",
"src/ui/applicationShortcuts.js", "src/ui/applicationShortcuts.js",
@@ -388,19 +296,12 @@
"external": [] "external": []
}, },
"display-controls": { "display-controls": {
"exports": [ "exports": ["./ui/display"],
"./ui/display" "modules": ["src/ui/displayControls.js"],
],
"modules": [
"src/ui/displayControls.js"
],
"external": [] "external": []
}, },
"visual-effects": { "visual-effects": {
"exports": [ "exports": ["./ui/effects", "./ui/effects/bloom"],
"./ui/effects",
"./ui/effects/bloom"
],
"modules": [ "modules": [
"src/ui/effects.js", "src/ui/effects.js",
"src/ui/visualEffects.js", "src/ui/visualEffects.js",
@@ -413,14 +314,10 @@
"src/styles/noir.js", "src/styles/noir.js",
"src/styles/snow.js" "src/styles/snow.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"map-source-controls": { "map-source-controls": {
"exports": [ "exports": ["./ui/maps"],
"./ui/maps"
],
"modules": [ "modules": [
"src/ui/mapSource.js", "src/ui/mapSource.js",
"src/ui/mapSourceControls.js", "src/ui/mapSourceControls.js",
@@ -430,10 +327,7 @@
"external": [] "external": []
}, },
"layer-panel": { "layer-panel": {
"exports": [ "exports": ["./ui/layers", "./ui/layers/feedback"],
"./ui/layers",
"./ui/layers/feedback"
],
"modules": [ "modules": [
"src/ui/layers.js", "src/ui/layers.js",
"src/ui/layerPanel.js", "src/ui/layerPanel.js",
@@ -444,9 +338,7 @@
"external": [] "external": []
}, },
"location-controls": { "location-controls": {
"exports": [ "exports": ["./ui/location"],
"./ui/location"
],
"modules": [ "modules": [
"src/ui/location.js", "src/ui/location.js",
"src/ui/locationControls.js", "src/ui/locationControls.js",
@@ -456,9 +348,7 @@
"external": [] "external": []
}, },
"radio-controls": { "radio-controls": {
"exports": [ "exports": ["./ui/radio"],
"./ui/radio"
],
"modules": [ "modules": [
"src/ui/radio.js", "src/ui/radio.js",
"src/ui/radioControls.js", "src/ui/radioControls.js",
+6 -1
View File
@@ -1,2 +1,7 @@
export { RadioControls } from './radioControls.js'; export { RadioControls } from './radioControls.js';
export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js'; export {
radioTunerSlot,
radioTunerCommitSlot,
radioTunerPointerPosition,
buildRadioTunerTicks,
} from './radioTunerModel.js';
+459 -139
View File
@@ -1,4 +1,9 @@
import { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js'; import {
radioTunerSlot,
radioTunerCommitSlot,
radioTunerPointerPosition,
buildRadioTunerTicks,
} from './radioTunerModel.js';
/** Bind Radio input using a RadioControls receiver and supplied actions. */ /** Bind Radio input using a RadioControls receiver and supplied actions. */
export function bindRadioControls() { export function bindRadioControls() {
@@ -9,41 +14,66 @@ export function bindRadioControls() {
this._contextRadioDock?.classList.toggle('disclosure-open', open); this._contextRadioDock?.classList.toggle('disclosure-open', open);
if (this._contextRadioMini) this._contextRadioMini.hidden = !open; if (this._contextRadioMini) this._contextRadioMini.hidden = !open;
this._syncContextRadioLauncherState(); this._syncContextRadioLauncherState();
if (!open && returnFocus) this._contextRadioToggleBtn?.focus({ preventScroll: true }); if (!open && returnFocus)
this._contextRadioToggleBtn?.focus({ preventScroll: true });
}; };
this._setRadioDisclosure = setRadioDisclosure; this._setRadioDisclosure = setRadioDisclosure;
const setCockpitDisclosure = (kind, expanded, { returnFocus = false } = {}) => { const setCockpitDisclosure = (
kind,
expanded,
{ returnFocus = false } = {},
) => {
const displayOpen = kind === 'display' && Boolean(expanded); const displayOpen = kind === 'display' && Boolean(expanded);
const radioOpen = kind === 'radio' && Boolean(expanded); const radioOpen = kind === 'radio' && Boolean(expanded);
if (displayOpen || radioOpen) this.actions.setSignalCollapsed(true); if (displayOpen || radioOpen) this.actions.setSignalCollapsed(true);
if (this._cockpitDisplayPanel) this._cockpitDisplayPanel.hidden = !displayOpen; if (this._cockpitDisplayPanel)
this._cockpitDisplayPanel.hidden = !displayOpen;
if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen; if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen;
this._cockpitDisplayToggleBtn?.closest('.cockpit-utility-control') this._cockpitDisplayToggleBtn
?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', displayOpen); ?.classList.toggle('is-expanded', displayOpen);
this._cockpitRadioToggleBtn?.closest('.cockpit-utility-control') this._cockpitRadioToggleBtn
?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', radioOpen); ?.classList.toggle('is-expanded', radioOpen);
this._cockpitDisplayToggleBtn?.setAttribute('aria-expanded', String(displayOpen)); this._cockpitDisplayToggleBtn?.setAttribute(
this._cockpitRadioToggleBtn?.setAttribute('aria-expanded', String(radioOpen)); 'aria-expanded',
String(displayOpen),
);
this._cockpitRadioToggleBtn?.setAttribute(
'aria-expanded',
String(radioOpen),
);
if (displayOpen) this.actions.revealStyleParameters(); if (displayOpen) this.actions.revealStyleParameters();
if (this._cockpitDisplayToggleBtn) { if (this._cockpitDisplayToggleBtn) {
const action = displayOpen ? 'Collapse' : 'Expand'; const action = displayOpen ? 'Collapse' : 'Expand';
this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀'; this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀';
this._cockpitDisplayToggleBtn.setAttribute('aria-label', `${action} Cockpit display options`); this._cockpitDisplayToggleBtn.setAttribute(
'aria-label',
`${action} Cockpit display options`,
);
this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`; this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`;
} }
if (this._cockpitRadioToggleBtn) { if (this._cockpitRadioToggleBtn) {
const action = radioOpen ? 'Collapse' : 'Expand'; const action = radioOpen ? 'Collapse' : 'Expand';
this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀'; this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀';
this._cockpitRadioToggleBtn.setAttribute('aria-label', `${action} Cockpit Radio controls`); this._cockpitRadioToggleBtn.setAttribute(
'aria-label',
`${action} Cockpit Radio controls`,
);
this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`; this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`;
} }
if (!expanded && returnFocus) { if (!expanded && returnFocus) {
(kind === 'display' ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn) (kind === 'display'
?.focus({ preventScroll: true }); ? this._cockpitDisplayToggleBtn
: this._cockpitRadioToggleBtn
)?.focus({ preventScroll: true });
} }
if (!displayOpen && !radioOpen if (
&& this.actions.isCockpitActive() !displayOpen &&
&& !this.actions.signalUserCollapsed()) { !radioOpen &&
this.actions.isCockpitActive() &&
!this.actions.signalUserCollapsed()
) {
this.actions.setSignalCollapsed(false); this.actions.setSignalCollapsed(false);
} }
this.actions.layoutCockpit(); this.actions.layoutCockpit();
@@ -63,7 +93,8 @@ export function bindRadioControls() {
tick.className = 'radio-tuner-tick'; tick.className = 'radio-tuner-tick';
scale.append(tick); scale.append(tick);
} }
while (scale.children.length > model.ticks.length) scale.lastElementChild?.remove(); while (scale.children.length > model.ticks.length)
scale.lastElementChild?.remove();
model.ticks.forEach((entry, index) => { model.ticks.forEach((entry, index) => {
const tick = scale.children[index]; const tick = scale.children[index];
tick.style.left = `${entry.xPx}px`; tick.style.left = `${entry.xPx}px`;
@@ -74,54 +105,98 @@ export function bindRadioControls() {
scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`); scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`);
return model; return model;
}; };
const tunerPreview = ({ coordinate = this._radioTunerCoordinate, syncStatic = true, rotate = true } = {}) => { const tunerPreview = ({
const slot = radioTunerSlot(this._radioTunerSlider?.value, this._radioTunerStations.length); coordinate = this._radioTunerCoordinate,
const station = slot.locked ? this._radioTunerStations[slot.stationIndex] || null : null; syncStatic = true,
const resolvedCoordinate = this._radioTunerStations.length <= 1 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 ? 0
: Math.min(this._radioTunerStations.length - 1, Math.max(0, Number(coordinate) || 0)); : Math.min(
const ratio = this._radioTunerStations.length === 1 this._radioTunerStations.length - 1,
Math.max(0, Number(coordinate) || 0),
);
const ratio =
this._radioTunerStations.length === 1
? 0.5 ? 0.5
: resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1); : resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1);
this._radioTunerCoordinate = resolvedCoordinate; this._radioTunerCoordinate = resolvedCoordinate;
this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio)); this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio));
this._radioTuner?.classList.toggle('is-static', syncStatic ? false : Boolean(this._radioState?.tuningStatic)); this._radioTuner?.classList.toggle(
'is-static',
syncStatic ? false : Boolean(this._radioState?.tuningStatic),
);
syncTunerTape(resolvedCoordinate); syncTunerTape(resolvedCoordinate);
if (this._radioTunerValue) { if (this._radioTunerValue) {
this._radioTunerValue.textContent = station this._radioTunerValue.textContent = station
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}` ? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}`
: 'NO STATIONS'; : 'NO STATIONS';
} }
if (this._radioTunerStation) this._radioTunerStation.textContent = station?.name || 'NO STATION AVAILABLE'; if (this._radioTunerStation)
this._radioTunerStation.textContent =
station?.name || 'NO STATION AVAILABLE';
if (this._radioTunerSlider) { if (this._radioTunerSlider) {
this._radioTunerSlider.setAttribute('aria-valuetext', station this._radioTunerSlider.setAttribute(
'aria-valuetext',
station
? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}` ? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}`
: 'No station available'); : 'No station available',
);
} }
if (syncStatic) this.radio.previewTuningStation(station?.id || null, { rotate }); if (syncStatic)
this.radio.previewTuningStation(station?.id || null, { rotate });
return station; return station;
}; };
const setTunerDirectory = (pool) => { const setTunerDirectory = (pool) => {
this._radioTunerPool = [...pool]; this._radioTunerPool = [...pool];
this._radioTunerStations = [...pool]; this._radioTunerStations = [...pool];
this._radioTunerBandSignature = this._radioTunerStations.map((station) => station.id).join('|'); this._radioTunerBandSignature = this._radioTunerStations
.map((station) => station.id)
.join('|');
if (this._radioTunerSlider) { if (this._radioTunerSlider) {
this._radioTunerSlider.min = '0'; this._radioTunerSlider.min = '0';
this._radioTunerSlider.max = String(Math.max(0, this._radioTunerStations.length - 1)); this._radioTunerSlider.max = String(
Math.max(0, this._radioTunerStations.length - 1),
);
this._radioTunerSlider.step = '1'; this._radioTunerSlider.step = '1';
} }
}; };
const refreshTunerBand = ({ force = false } = {}) => { const refreshTunerBand = ({ force = false } = {}) => {
if (this._radioTunerDragging || this._radioTuner?.hidden || this._radioTunerSlider?.disabled) return false; if (
this._radioTunerDragging ||
this._radioTuner?.hidden ||
this._radioTunerSlider?.disabled
)
return false;
const selectedId = this._radioState?.selected?.id || null; const selectedId = this._radioState?.selected?.id || null;
const pool = this.radio.getTunerStations(750); const pool = this.radio.getTunerStations(750);
const poolSignature = pool.map((station) => station.id).join('|'); const poolSignature = pool.map((station) => station.id).join('|');
const currentPoolSignature = this._radioTunerPool.map((station) => station.id).join('|'); const currentPoolSignature = this._radioTunerPool
if (!force && poolSignature === currentPoolSignature && selectedId === this._radioTunerSelectedId) return false; .map((station) => station.id)
.join('|');
if (
!force &&
poolSignature === currentPoolSignature &&
selectedId === this._radioTunerSelectedId
)
return false;
setTunerDirectory(pool); setTunerDirectory(pool);
this._radioTunerSelectedId = selectedId; this._radioTunerSelectedId = selectedId;
const selectedPoolIndex = pool.findIndex((station) => station.id === selectedId); const selectedPoolIndex = pool.findIndex(
const slot = radioTunerSlot(selectedPoolIndex >= 0 ? selectedPoolIndex : 0, this._radioTunerStations.length); (station) => station.id === selectedId,
);
const slot = radioTunerSlot(
selectedPoolIndex >= 0 ? selectedPoolIndex : 0,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(slot.slot); this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false }); tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false });
@@ -129,16 +204,23 @@ export function bindRadioControls() {
}; };
this._refreshRadioTunerBand = refreshTunerBand; this._refreshRadioTunerBand = refreshTunerBand;
const beginTuner = () => { const beginTuner = () => {
if (this._radioTunerDragging || this._radioTunerSlider?.disabled) return false; if (this._radioTunerDragging || this._radioTunerSlider?.disabled)
return false;
refreshTunerBand(); refreshTunerBand();
if (!this._radioTunerStations.length || !this.radio.beginTuning()) return false; if (!this._radioTunerStations.length || !this.radio.beginTuning())
return false;
// A tuner-owned camera preview must never replace the frozen directory. // A tuner-owned camera preview must never replace the frozen directory.
// Only an explicit globe pointer/wheel gesture releases camera pinning. // Only an explicit globe pointer/wheel gesture releases camera pinning.
this._radioTunerBandPinnedForNavigation = true; this._radioTunerBandPinnedForNavigation = true;
this._radioTunerDragging = true; this._radioTunerDragging = true;
this._radioTuner?.classList.add('is-dragging'); this._radioTuner?.classList.add('is-dragging');
const selectedIndex = this._radioTunerStations.findIndex((station) => station.id === this._radioState?.selected?.id); const selectedIndex = this._radioTunerStations.findIndex(
const slot = radioTunerSlot(selectedIndex >= 0 ? selectedIndex : 0, this._radioTunerStations.length); (station) => station.id === this._radioState?.selected?.id,
);
const slot = radioTunerSlot(
selectedIndex >= 0 ? selectedIndex : 0,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(slot.slot); this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0; this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
this._radioTunerDragStartSlot = slot.slot; this._radioTunerDragStartSlot = slot.slot;
@@ -163,7 +245,10 @@ export function bindRadioControls() {
this._radioTunerBandSignature = dragSnapshot.bandSignature; this._radioTunerBandSignature = dragSnapshot.bandSignature;
this._radioTunerSelectedId = dragSnapshot.selectedId; this._radioTunerSelectedId = dragSnapshot.selectedId;
if (this._radioTunerSlider) { if (this._radioTunerSlider) {
const startSlot = radioTunerSlot(dragSnapshot.slot, this._radioTunerStations.length); const startSlot = radioTunerSlot(
dragSnapshot.slot,
this._radioTunerStations.length,
);
this._radioTunerSlider.max = String(startSlot.max); this._radioTunerSlider.max = String(startSlot.max);
this._radioTunerSlider.value = String(startSlot.slot); this._radioTunerSlider.value = String(startSlot.slot);
} }
@@ -174,7 +259,10 @@ export function bindRadioControls() {
this._radioTunerSlider.value = String(this._radioTunerDragStartSlot); this._radioTunerSlider.value = String(this._radioTunerDragStartSlot);
this._radioTunerCoordinate = this._radioTunerDragStartSlot; this._radioTunerCoordinate = this._radioTunerDragStartSlot;
} }
let station = tunerPreview({ coordinate: this._radioTunerCoordinate, rotate: commit }); let station = tunerPreview({
coordinate: this._radioTunerCoordinate,
rotate: commit,
});
if (commit && !station && this._radioTunerSlider) { if (commit && !station && this._radioTunerSlider) {
const snapped = radioTunerCommitSlot( const snapped = radioTunerCommitSlot(
this._radioTunerSlider.value, this._radioTunerSlider.value,
@@ -210,8 +298,10 @@ export function bindRadioControls() {
if (result && !result.ok) { if (result && !result.ok) {
this._radioTunerBandPinnedForNavigation = false; this._radioTunerBandPinnedForNavigation = false;
if (result.reason === 'station-unavailable') { if (result.reason === 'station-unavailable') {
if (this._radioTunerValue) this._radioTunerValue.textContent = 'OFF AIR'; if (this._radioTunerValue)
if (this._radioTunerStation) this._radioTunerStation.textContent = 'STATION UNAVAILABLE'; this._radioTunerValue.textContent = 'OFF AIR';
if (this._radioTunerStation)
this._radioTunerStation.textContent = 'STATION UNAVAILABLE';
this._radioTunerSlider?.setAttribute( this._radioTunerSlider?.setAttribute(
'aria-valuetext', 'aria-valuetext',
'Station unavailable after directory refresh', 'Station unavailable after directory refresh',
@@ -221,7 +311,9 @@ export function bindRadioControls() {
}; };
const cycleRadio = (direction, { rotate = true } = {}) => { const cycleRadio = (direction, { rotate = true } = {}) => {
this._radioTunerBandPinnedForNavigation = true; this._radioTunerBandPinnedForNavigation = true;
const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations; const pool = this._radioTunerPool.length
? this._radioTunerPool
: this._radioTunerStations;
const cycled = this.radio.cycleStation(direction, { const cycled = this.radio.cycleStation(direction, {
rotate, rotate,
stationIds: pool.map((station) => station.id), stationIds: pool.map((station) => station.id),
@@ -241,105 +333,190 @@ export function bindRadioControls() {
trigger.setAttribute('aria-busy', 'true'); trigger.setAttribute('aria-busy', 'true');
try { try {
const toggled = await this.actions.runUserAction( const toggled = await this.actions.runUserAction(
(notificationToken) => this.actions.setEnabled(enabling, { (notificationToken) =>
this.actions.setEnabled(enabling, {
origin: 'user', origin: 'user',
notificationToken, notificationToken,
}), }),
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`, `Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
); );
if (this.destroyed || toggled === false) return; if (this.destroyed || toggled === false) return;
if (enabling && trigger === this._radioEnableBtn if (
&& !document.getElementById('global-context-panel')?.classList.contains('collapsed')) { enabling &&
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true }); 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); if (revealAfterEnable)
await this._revealRadioControlsAfterExplicitEnable(trigger);
} finally { } finally {
if (!this.destroyed) { if (!this.destroyed) {
trigger.setAttribute('aria-disabled', 'false'); trigger.setAttribute('aria-disabled', 'false');
trigger.setAttribute('aria-busy', 'false'); trigger.setAttribute('aria-busy', 'false');
if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true }); if (revealAfterEnable && trigger.isConnected)
trigger.focus({ preventScroll: true });
} }
} }
}; };
this.listen(this._radioEnableBtn, 'click', () => void toggleRadio(this._radioEnableBtn)); this.listen(
this.listen(this._contextRadioMiniEnableBtn, 'click', () => void toggleRadio(this._contextRadioMiniEnableBtn)); this._radioEnableBtn,
this.listen(this._cockpitRadioEnableBtn, 'click', () => void toggleRadio(this._cockpitRadioEnableBtn)); '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', () => { this.listen(this._contextRadioToggleBtn, 'click', () => {
const contextPanel = document.getElementById('global-context-panel'); const contextPanel = document.getElementById('global-context-panel');
if (contextPanel && !contextPanel.classList.contains('collapsed')) { if (contextPanel && !contextPanel.classList.contains('collapsed')) {
setRadioDisclosure(false); setRadioDisclosure(false);
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true }); this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
void this._revealRadioPanelInsideContext({ void this._revealRadioPanelInsideContext({
focusTarget: this._radioPanel?.querySelector('[data-collapse-target="radio-panel"]'), focusTarget: this._radioPanel?.querySelector(
'[data-collapse-target="radio-panel"]',
),
}); });
return; return;
} }
setRadioDisclosure(!this._contextRadioDock?.classList.contains('disclosure-open')); setRadioDisclosure(
!this._contextRadioDock?.classList.contains('disclosure-open'),
);
}); });
this.listen(this._contextRadioMiniCloseBtn, 'click', () => { this.listen(this._contextRadioMiniCloseBtn, 'click', () => {
setRadioDisclosure(false, { returnFocus: true }); setRadioDisclosure(false, { returnFocus: true });
}); });
this.listen(this._contextRadioDetailsBtn, 'click', () => { this.listen(this._contextRadioDetailsBtn, 'click', () => {
if (!this.actions.isCockpitActive()) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true }); if (!this.actions.isCockpitActive())
this.actions.setPanelCollapsed('global-context-panel', false, {
explicit: true,
});
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true }); this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
setRadioDisclosure(false); setRadioDisclosure(false);
this._radioEnableBtn?.focus({ preventScroll: true }); this._radioEnableBtn?.focus({ preventScroll: true });
}); });
this.listen(this._cockpitRadioToggleBtn, 'click', () => { this.listen(this._cockpitRadioToggleBtn, 'click', () => {
const open = this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true'; const open =
this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true';
setCockpitDisclosure('radio', !open); setCockpitDisclosure('radio', !open);
}); });
this.listen(document, 'pointerdown', (event) => { this.listen(
if (!this._contextRadioDock?.classList.contains('disclosure-open')) return; document,
'pointerdown',
(event) => {
if (!this._contextRadioDock?.classList.contains('disclosure-open'))
return;
if (event.target?.closest?.('#context-radio-dock')) return; if (event.target?.closest?.('#context-radio-dock')) return;
setRadioDisclosure(false); setRadioDisclosure(false);
}, tunerListenerOptions); },
this.listen(document, 'pointerdown', (event) => { tunerListenerOptions,
if (!this._cockpitUtilityControls || event.target?.closest?.('#cockpit-utility-controls')) return; );
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?.('.cockpit-vision-controls')) return;
if (event.target?.closest?.('#left-panel-stack, #cockpit-context')) return; if (event.target?.closest?.('#left-panel-stack, #cockpit-context'))
return;
setCockpitDisclosure('display', false); setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false); setCockpitDisclosure('radio', false);
}, tunerListenerOptions); },
this.listen(document, 'keydown', (event) => { tunerListenerOptions,
if (event.key !== 'Escape' || !this._contextRadioDock?.classList.contains('disclosure-open')) return; );
this.listen(
document,
'keydown',
(event) => {
if (
event.key !== 'Escape' ||
!this._contextRadioDock?.classList.contains('disclosure-open')
)
return;
event.preventDefault(); event.preventDefault();
// Immediate: a plain stopPropagation() still lets every LATER listener on // Immediate: a plain stopPropagation() still lets every LATER listener on
// this same document run, so closing the disclosure ALSO dismissed the // this same document run, so closing the disclosure ALSO dismissed the
// first-run launcher — one key, two actions. Matches the cockpit // first-run launcher — one key, two actions. Matches the cockpit
// disclosure handler directly below. // disclosure handler directly below.
event.stopImmediatePropagation(); event.stopImmediatePropagation();
const escapedFromDisclosure = event.target === this._contextRadioToggleBtn const escapedFromDisclosure =
|| this._contextRadioToggleBtn?.contains?.(event.target); event.target === this._contextRadioToggleBtn ||
this._contextRadioToggleBtn?.contains?.(event.target);
setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure }); setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.(); if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.();
}, { capture: true, }); },
this.listen(document, 'keydown', (event) => { { capture: true },
);
this.listen(
document,
'keydown',
(event) => {
if (event.key !== 'Escape') return; if (event.key !== 'Escape') return;
const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true'; const displayOpen =
const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true'; this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true';
const radioOpen =
this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true';
if (!displayOpen && !radioOpen) return; if (!displayOpen && !radioOpen) return;
const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)'); const nestedPanel = event.target?.closest?.(
'.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)',
);
if (nestedPanel) return; if (nestedPanel) return;
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
const kind = displayOpen ? 'display' : 'radio'; const kind = displayOpen ? 'display' : 'radio';
const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn; const disclosure = displayOpen
const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target); ? this._cockpitDisplayToggleBtn
setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure }); : this._cockpitRadioToggleBtn;
const escapedFromDisclosure =
event.target === disclosure || disclosure?.contains?.(event.target);
setCockpitDisclosure(kind, false, {
returnFocus: !escapedFromDisclosure,
});
if (escapedFromDisclosure) disclosure?.blur?.(); if (escapedFromDisclosure) disclosure?.blur?.();
}, { capture: true, }); },
this.listen(window, 'gev:cockpit-mode-changed', (event) => { { capture: true },
);
this.listen(
window,
'gev:cockpit-mode-changed',
(event) => {
if (event?.detail?.active) return; if (event?.detail?.active) return;
setCockpitDisclosure('display', false); setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false); setCockpitDisclosure('radio', false);
}, tunerListenerOptions); },
this.listen(window, 'gev:cockpit-signal-expanded', () => { tunerListenerOptions,
);
this.listen(
window,
'gev:cockpit-signal-expanded',
() => {
setCockpitDisclosure('display', false); setCockpitDisclosure('display', false);
}, tunerListenerOptions); },
this.listen(window, 'gev:cockpit-context-expanded', () => { tunerListenerOptions,
);
this.listen(
window,
'gev:cockpit-context-expanded',
() => {
this.actions.setPanelCollapsed('data-panel', true); this.actions.setPanelCollapsed('data-panel', true);
}, tunerListenerOptions); },
tunerListenerOptions,
);
this.listen(this._radioFilter, 'change', () => { this.listen(this._radioFilter, 'change', () => {
const presentation = this.radio.getUIState(); const presentation = this.radio.getUIState();
if (!presentation.presentationActive) { if (!presentation.presentationActive) {
@@ -347,9 +524,14 @@ export function bindRadioControls() {
return; return;
} }
if (this._radioTunerDragging) finishTuner(false); if (this._radioTunerDragging) finishTuner(false);
if (!this.actions.setParams({ if (
!this.actions.setParams(
{
filter: this._radioFilter.value, filter: this._radioFilter.value,
}, { origin: 'user' })) { },
{ origin: 'user' },
)
) {
this._radioFilter.value = this.radio.getUIState().filter; this._radioFilter.value = this.radio.getUIState().filter;
return; return;
} }
@@ -359,30 +541,51 @@ export function bindRadioControls() {
}); });
this.listen(this._radioPrevBtn, 'click', () => cycleRadio(-1)); this.listen(this._radioPrevBtn, 'click', () => cycleRadio(-1));
this.listen(this._radioNextBtn, 'click', () => cycleRadio(1)); this.listen(this._radioNextBtn, 'click', () => cycleRadio(1));
this.listen(this._radioPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' })); this.listen(
this.listen(this._radioStopBtn, 'click', () => this.radio.stopPlayback({ origin: 'user' })); this._radioPlayBtn,
'click',
() => void this.radio.togglePlayback({ origin: 'user' }),
);
this.listen(this._radioStopBtn, 'click', () =>
this.radio.stopPlayback({ origin: 'user' }),
);
this.listen(this._radioVolume, 'input', () => { this.listen(this._radioVolume, 'input', () => {
const value = Number(this._radioVolume.value); const value = Number(this._radioVolume.value);
if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${value}%`; if (this._radioVolumeValue)
this._radioVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
}); });
this.listen(this._contextRadioMiniPrevBtn, 'click', () => cycleRadio(-1)); this.listen(this._contextRadioMiniPrevBtn, 'click', () => cycleRadio(-1));
this.listen(this._contextRadioMiniNextBtn, 'click', () => cycleRadio(1)); this.listen(this._contextRadioMiniNextBtn, 'click', () => cycleRadio(1));
this.listen(this._contextRadioMiniPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' })); this.listen(
this._contextRadioMiniPlayBtn,
'click',
() => void this.radio.togglePlayback({ origin: 'user' }),
);
// Cockpit owns the Cesium camera even though it intentionally clears // Cockpit owns the Cesium camera even though it intentionally clears
// viewer.trackedEntity. Station changes must never start the map-view // viewer.trackedEntity. Station changes must never start the map-view
// rotation/fallback flights that would compete with its preUpdate pose. // rotation/fallback flights that would compete with its preUpdate pose.
this.listen(this._cockpitRadioPrevBtn, 'click', () => cycleRadio(-1, { rotate: false })); this.listen(this._cockpitRadioPrevBtn, 'click', () =>
this.listen(this._cockpitRadioNextBtn, 'click', () => cycleRadio(1, { rotate: false })); cycleRadio(-1, { rotate: false }),
this.listen(this._cockpitRadioPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' })); );
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', () => { this.listen(this._contextRadioMiniVolume, 'input', () => {
const value = Number(this._contextRadioMiniVolume.value); const value = Number(this._contextRadioMiniVolume.value);
if (this._contextRadioMiniVolumeValue) this._contextRadioMiniVolumeValue.textContent = `${value}%`; if (this._contextRadioMiniVolumeValue)
this._contextRadioMiniVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
}); });
this.listen(this._cockpitRadioVolume, 'input', () => { this.listen(this._cockpitRadioVolume, 'input', () => {
const value = Number(this._cockpitRadioVolume.value); const value = Number(this._cockpitRadioVolume.value);
if (this._cockpitRadioVolumeValue) this._cockpitRadioVolumeValue.textContent = `${value}%`; if (this._cockpitRadioVolumeValue)
this._cockpitRadioVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' }); this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
}); });
const updateTunerFromPointer = (event) => { const updateTunerFromPointer = (event) => {
@@ -394,95 +597,212 @@ export function bindRadioControls() {
rect.width, rect.width,
this._radioTunerStations.length, this._radioTunerStations.length,
); );
if (position.stationIndex > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; if (position.stationIndex > this._radioTunerLastSlot)
else if (position.stationIndex < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; this._radioTunerDragDirection = 1;
else if (position.stationIndex < this._radioTunerLastSlot)
this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = position.stationIndex; this._radioTunerLastSlot = position.stationIndex;
this._radioTunerCoordinate = position.coordinate; this._radioTunerCoordinate = position.coordinate;
this._radioTunerSlider.value = String(position.stationIndex); this._radioTunerSlider.value = String(position.stationIndex);
tunerPreview({ coordinate: position.coordinate }); tunerPreview({ coordinate: position.coordinate });
return true; return true;
}; };
this.listen(this._radioTunerSlider, 'pointerdown', (event) => { this.listen(
this._radioTunerSlider,
'pointerdown',
(event) => {
if (!beginTuner()) return; if (!beginTuner()) return;
this._radioTunerPointerId = event.pointerId; this._radioTunerPointerId = event.pointerId;
this._radioTunerSlider.focus({ preventScroll: true }); this._radioTunerSlider.focus({ preventScroll: true });
try { this._radioTunerSlider.setPointerCapture(event.pointerId); } catch { /* capture is best effort */ } try {
this._radioTunerSlider.setPointerCapture(event.pointerId);
} catch {
/* capture is best effort */
}
updateTunerFromPointer(event); updateTunerFromPointer(event);
event.preventDefault(); event.preventDefault();
}, tunerListenerOptions); },
this.listen(this._radioTunerSlider, 'pointermove', (event) => { tunerListenerOptions,
if (!this._radioTunerDragging || this._radioTunerPointerId !== event.pointerId) return; );
this.listen(
this._radioTunerSlider,
'pointermove',
(event) => {
if (
!this._radioTunerDragging ||
this._radioTunerPointerId !== event.pointerId
)
return;
updateTunerFromPointer(event); updateTunerFromPointer(event);
event.preventDefault(); event.preventDefault();
}, tunerListenerOptions); },
this.listen(this._radioTunerSlider, 'input', () => { tunerListenerOptions,
if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) return; );
this.listen(
this._radioTunerSlider,
'input',
() => {
if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey)
return;
if (!this._radioTunerDragging && !beginTuner()) return; if (!this._radioTunerDragging && !beginTuner()) return;
const inputSlot = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length); const inputSlot = radioTunerSlot(
if (inputSlot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; this._radioTunerSlider.value,
else if (inputSlot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; 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._radioTunerLastSlot = inputSlot.slot;
this._radioTunerCoordinate = inputSlot.stationIndex; this._radioTunerCoordinate = inputSlot.stationIndex;
tunerPreview({ coordinate: this._radioTunerCoordinate }); tunerPreview({ coordinate: this._radioTunerCoordinate });
}, tunerListenerOptions); },
this.listen(this._radioTunerSlider, 'change', () => { tunerListenerOptions,
if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey) finishTuner(true); );
}, tunerListenerOptions); this.listen(
this.listen(this._radioTunerSlider, 'pointerup', (event) => { this._radioTunerSlider,
'change',
() => {
if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey)
finishTuner(true);
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'pointerup',
(event) => {
if (this._radioTunerPointerId !== event.pointerId) return; if (this._radioTunerPointerId !== event.pointerId) return;
updateTunerFromPointer(event); updateTunerFromPointer(event);
finishTuner(true); finishTuner(true);
try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } try {
this._radioTunerSlider.releasePointerCapture(event.pointerId);
} catch {
/* already released */
}
event.preventDefault(); event.preventDefault();
}, tunerListenerOptions); },
this.listen(this._radioTunerSlider, 'pointercancel', (event) => { tunerListenerOptions,
if (this._radioTunerPointerId !== null && this._radioTunerPointerId !== event.pointerId) return; );
try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ } 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); finishTuner(false);
}, tunerListenerOptions); },
this.listen(this._radioTunerSlider, 'lostpointercapture', (event) => { tunerListenerOptions,
if (this._radioTunerDragging && this._radioTunerPointerId === event.pointerId) finishTuner(false); );
}, tunerListenerOptions); this.listen(
this.listen(this._radioTunerSlider, 'keydown', (event) => { 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) { if (event.key === 'Escape' && this._radioTunerDragging) {
event.preventDefault(); event.preventDefault();
finishTuner(false); finishTuner(false);
return; return;
} }
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return; if (
![
'ArrowLeft',
'ArrowRight',
'Home',
'End',
'PageUp',
'PageDown',
].includes(event.key)
)
return;
if (!this._radioTunerDragging && !beginTuner()) return; if (!this._radioTunerDragging && !beginTuner()) return;
event.preventDefault(); event.preventDefault();
this._radioTunerKeyboardKey = event.key; this._radioTunerKeyboardKey = event.key;
const max = Math.max(0, this._radioTunerStations.length - 1); const max = Math.max(0, this._radioTunerStations.length - 1);
const current = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length).slot; const current = radioTunerSlot(
this._radioTunerSlider.value,
this._radioTunerStations.length,
).slot;
const page = Math.max(1, Math.round(max / 10)); const page = Math.max(1, Math.round(max / 10));
const next = event.key === 'Home' ? 0 const next =
: event.key === 'End' ? max event.key === 'Home'
: event.key === 'PageUp' ? current + page ? 0
: event.key === 'PageDown' ? current - page : event.key === 'End'
? max
: event.key === 'PageUp'
? current + page
: event.key === 'PageDown'
? current - page
: current + (event.key === 'ArrowRight' ? 1 : -1); : current + (event.key === 'ArrowRight' ? 1 : -1);
const slot = radioTunerSlot(next, this._radioTunerStations.length); const slot = radioTunerSlot(next, this._radioTunerStations.length);
if (slot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1; if (slot.slot > this._radioTunerLastSlot)
else if (slot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1; this._radioTunerDragDirection = 1;
else if (slot.slot < this._radioTunerLastSlot)
this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = slot.slot; this._radioTunerLastSlot = slot.slot;
this._radioTunerCoordinate = slot.stationIndex; this._radioTunerCoordinate = slot.stationIndex;
this._radioTunerSlider.value = String(slot.slot); this._radioTunerSlider.value = String(slot.slot);
tunerPreview({ coordinate: this._radioTunerCoordinate }); tunerPreview({ coordinate: this._radioTunerCoordinate });
}, tunerListenerOptions); },
this.listen(this._radioTunerSlider, 'keyup', (event) => { tunerListenerOptions,
if (!this._radioTunerKeyboardKey || event.key !== this._radioTunerKeyboardKey) return; );
this.listen(
this._radioTunerSlider,
'keyup',
(event) => {
if (
!this._radioTunerKeyboardKey ||
event.key !== this._radioTunerKeyboardKey
)
return;
event.preventDefault(); event.preventDefault();
finishTuner(true); finishTuner(true);
}, tunerListenerOptions); },
this.listen(this._radioTunerSlider, 'blur', () => finishTuner(true), tunerListenerOptions); tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'blur',
() => finishTuner(true),
tunerListenerOptions,
);
const releaseNavigationBand = () => { const releaseNavigationBand = () => {
this._radioTunerBandPinnedForNavigation = false; this._radioTunerBandPinnedForNavigation = false;
}; };
this.listen(this.canvas, 'pointerdown', releaseNavigationBand, tunerListenerOptions); this.listen(
this.listen(this.canvas, 'wheel', releaseNavigationBand, tunerListenerOptions); this.canvas,
'pointerdown',
releaseNavigationBand,
tunerListenerOptions,
);
this.listen(
this.canvas,
'wheel',
releaseNavigationBand,
tunerListenerOptions,
);
// The directory order is catalog/filter authority, not camera authority. // The directory order is catalog/filter authority, not camera authority.
// Globe motion therefore never rebuilds or re-ranks the frequency band. // Globe motion therefore never rebuilds or re-ranks the frequency band.
this._radioSelectedHandler = () => this.actions.setPanelCollapsed('radio-panel', false); this._radioSelectedHandler = () =>
this.actions.setPanelCollapsed('radio-panel', false);
this.listen(document, 'gev:radio-selected', this._radioSelectedHandler); this.listen(document, 'gev:radio-selected', this._radioSelectedHandler);
} }
+98 -29
View File
@@ -31,17 +31,24 @@ export class RadioControls {
} }
listen(target, type, handler, options = {}) { listen(target, type, handler, options = {}) {
target?.addEventListener(type, handler, { ...options, signal: this.listeners.signal }); target?.addEventListener(type, handler, {
...options,
signal: this.listeners.signal,
});
} }
connect() { connect() {
this._radioUnsubscribe?.(); this._radioUnsubscribe?.();
this._radioUnsubscribe = null; this._radioUnsubscribe = null;
if (this.destroyed) return; if (this.destroyed) return;
this._radioUnsubscribe = this.radio.subscribe?.((state) => this._renderRadioState(state)); this._radioUnsubscribe = this.radio.subscribe?.((state) =>
this._renderRadioState(state),
);
} }
_renderRadioState(state) { renderRadioState.call(this, state); } _renderRadioState(state) {
renderRadioState.call(this, state);
}
/** /**
* Reveal the newly enabled directory and transport inside Context without * Reveal the newly enabled directory and transport inside Context without
@@ -55,19 +62,32 @@ export class RadioControls {
const scroller = contextPanel?.querySelector('.global-context-panel-inner'); const scroller = contextPanel?.querySelector('.global-context-panel-inner');
const directory = this._radioPanel?.querySelector('.radio-directory-row'); const directory = this._radioPanel?.querySelector('.radio-directory-row');
const transport = this._radioPanel?.querySelector('.radio-transport'); const transport = this._radioPanel?.querySelector('.radio-transport');
if (!contextPanel || contextPanel.classList.contains('collapsed') if (
|| !scroller || !directory || !transport || !this._radioState?.enabled) return false; !contextPanel ||
contextPanel.classList.contains('collapsed') ||
!scroller ||
!directory ||
!transport ||
!this._radioState?.enabled
)
return false;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); await new Promise((resolve) =>
if (this.destroyed || !this._radioState?.enabled || !trigger?.isConnected) return false; requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
if (this.destroyed || !this._radioState?.enabled || !trigger?.isConnected)
return false;
const viewport = scroller.getBoundingClientRect(); const viewport = scroller.getBoundingClientRect();
const directoryRect = directory.getBoundingClientRect(); const directoryRect = directory.getBoundingClientRect();
const transportRect = transport.getBoundingClientRect(); const transportRect = transport.getBoundingClientRect();
const margin = 10; const margin = 10;
const minimum = scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin); const minimum =
const maximum = scroller.scrollTop + directoryRect.top - (viewport.top + margin); scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin);
const desired = minimum <= maximum const maximum =
scroller.scrollTop + directoryRect.top - (viewport.top + margin);
const desired =
minimum <= maximum
? Math.min(Math.max(scroller.scrollTop, minimum), maximum) ? Math.min(Math.max(scroller.scrollTop, minimum), maximum)
: minimum; : minimum;
const next = Math.min( const next = Math.min(
@@ -75,8 +95,13 @@ export class RadioControls {
Math.max(0, desired), Math.max(0, desired),
); );
if (Math.abs(next - scroller.scrollTop) < 1) return false; if (Math.abs(next - scroller.scrollTop) < 1) return false;
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; const reducedMotion = window.matchMedia?.(
scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); '(prefers-reduced-motion: reduce)',
)?.matches;
scroller.scrollTo({
top: next,
behavior: reducedMotion ? 'auto' : 'smooth',
});
return true; return true;
} }
@@ -89,11 +114,24 @@ export class RadioControls {
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) { async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
const contextPanel = document.getElementById('global-context-panel'); const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner'); const scroller = contextPanel?.querySelector('.global-context-panel-inner');
if (!contextPanel || contextPanel.classList.contains('collapsed') if (
|| !scroller || !this._radioPanel || this._radioPanel.classList.contains('collapsed')) return false; !contextPanel ||
contextPanel.classList.contains('collapsed') ||
!scroller ||
!this._radioPanel ||
this._radioPanel.classList.contains('collapsed')
)
return false;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); await new Promise((resolve) =>
if (this.destroyed || contextPanel.classList.contains('collapsed') || this._radioPanel.classList.contains('collapsed')) return false; requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
if (
this.destroyed ||
contextPanel.classList.contains('collapsed') ||
this._radioPanel.classList.contains('collapsed')
)
return false;
const viewport = scroller.getBoundingClientRect(); const viewport = scroller.getBoundingClientRect();
const radioRect = this._radioPanel.getBoundingClientRect(); const radioRect = this._radioPanel.getBoundingClientRect();
@@ -104,8 +142,13 @@ export class RadioControls {
); );
const moved = Math.abs(next - scroller.scrollTop) >= 1; const moved = Math.abs(next - scroller.scrollTop) >= 1;
if (moved) { if (moved) {
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; const reducedMotion = window.matchMedia?.(
scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' }); '(prefers-reduced-motion: reduce)',
)?.matches;
scroller.scrollTo({
top: next,
behavior: reducedMotion ? 'auto' : 'smooth',
});
} }
focusTarget?.focus?.({ preventScroll: true }); focusTarget?.focus?.({ preventScroll: true });
return moved; return moved;
@@ -115,25 +158,44 @@ export class RadioControls {
_syncContextRadioLauncherState() { _syncContextRadioLauncherState() {
if (!this._contextRadioToggleBtn) return; if (!this._contextRadioToggleBtn) return;
const contextPanel = document.getElementById('global-context-panel'); const contextPanel = document.getElementById('global-context-panel');
const contextExpanded = Boolean(contextPanel && !contextPanel.classList.contains('collapsed')); const contextExpanded = Boolean(
contextPanel && !contextPanel.classList.contains('collapsed'),
);
if (contextExpanded) { if (contextExpanded) {
const radioExpanded = Boolean(this._radioPanel && !this._radioPanel.classList.contains('collapsed')); const radioExpanded = Boolean(
this._radioPanel && !this._radioPanel.classList.contains('collapsed'),
);
this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel'); this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel');
this._contextRadioToggleBtn.setAttribute('aria-expanded', String(radioExpanded)); this._contextRadioToggleBtn.setAttribute(
const label = radioExpanded ? 'Go to expanded Radio section' : 'Expand Radio section in Context'; '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.setAttribute('aria-label', label);
this._contextRadioToggleBtn.title = label; this._contextRadioToggleBtn.title = label;
return; return;
} }
const compactOpen = Boolean(this._contextRadioDock?.classList.contains('disclosure-open')); const compactOpen = Boolean(
this._contextRadioToggleBtn.setAttribute('aria-controls', 'context-radio-mini'); this._contextRadioDock?.classList.contains('disclosure-open'),
this._contextRadioToggleBtn.setAttribute('aria-expanded', String(compactOpen)); );
this._contextRadioToggleBtn.setAttribute(
'aria-controls',
'context-radio-mini',
);
this._contextRadioToggleBtn.setAttribute(
'aria-expanded',
String(compactOpen),
);
const action = compactOpen ? 'Close' : 'Open'; const action = compactOpen ? 'Close' : 'Open';
this._contextRadioToggleBtn.setAttribute('aria-label', `${action} compact Radio controls`); this._contextRadioToggleBtn.setAttribute(
'aria-label',
`${action} compact Radio controls`,
);
this._contextRadioToggleBtn.title = `${action} compact Radio controls`; this._contextRadioToggleBtn.title = `${action} compact Radio controls`;
} }
destroy() { destroy() {
if (this.destroyed) return; if (this.destroyed) return;
this.destroyed = true; this.destroyed = true;
@@ -148,9 +210,16 @@ export class RadioControls {
this._radioTunerPool = []; this._radioTunerPool = [];
this._radioTunerStations = []; this._radioTunerStations = [];
this._radioTunerBandPinnedForNavigation = false; this._radioTunerBandPinnedForNavigation = false;
try { if (pointer !== null) this._radioTunerSlider?.releasePointerCapture(pointer); } catch { /* capture already released */ } try {
if (pointer !== null)
this._radioTunerSlider?.releasePointerCapture(pointer);
} catch {
/* capture already released */
}
this._radioTuner?.classList.remove('is-dragging', 'is-static'); this._radioTuner?.classList.remove('is-dragging', 'is-static');
document.getElementById('title-bar')?.classList.remove('radio-broadcasting'); document
.getElementById('title-bar')
?.classList.remove('radio-broadcasting');
this.radio.endTuning(); this.radio.endTuning();
} }
} }
+82 -26
View File
@@ -13,13 +13,22 @@ class Element extends EventTarget {
add: (...names) => names.forEach((n) => this.classes.add(n)), add: (...names) => names.forEach((n) => this.classes.add(n)),
remove: (...names) => names.forEach((n) => this.classes.delete(n)), remove: (...names) => names.forEach((n) => this.classes.delete(n)),
contains: (name) => this.classes.has(name), contains: (name) => this.classes.has(name),
toggle: (name, enabled) => enabled ? this.classes.add(name) : this.classes.delete(name), toggle: (name, enabled) =>
enabled ? this.classes.add(name) : this.classes.delete(name),
}; };
} }
setAttribute(name, value) { this.attrs.set(name, String(value)); } setAttribute(name, value) {
getAttribute(name) { return this.attrs.get(name); } this.attrs.set(name, String(value));
querySelector() { return null; } }
focus() { this.focusCount++; } getAttribute(name) {
return this.attrs.get(name);
}
querySelector() {
return null;
}
focus() {
this.focusCount++;
}
} }
function fixture() { function fixture() {
@@ -34,25 +43,54 @@ function fixture() {
const calls = []; const calls = [];
const subscribers = []; const subscribers = [];
const radio = { const radio = {
subscribe(callback) { subscribers.push(callback); calls.push('subscribe'); return () => calls.push('unsubscribe'); }, subscribe(callback) {
endTuning() { calls.push('endTuning'); }, subscribers.push(callback);
cycleStation(direction) { calls.push(['cycle', direction]); return true; }, calls.push('subscribe');
return () => calls.push('unsubscribe');
},
endTuning() {
calls.push('endTuning');
},
cycleStation(direction) {
calls.push(['cycle', direction]);
return true;
},
}; };
const actions = { const actions = {
isRegistered: () => true, isEnabled: () => false, isRegistered: () => true,
isEnabled: () => false,
runUserAction: (operation) => operation(), runUserAction: (operation) => operation(),
setEnabled: async () => true, setEnabled: async () => true,
setPanelCollapsed: (...args) => calls.push(['panel', ...args]), setPanelCollapsed: (...args) => calls.push(['panel', ...args]),
layoutCockpit() {}, isCockpitActive: () => false, layoutCockpit() {},
scheduleLayout() {}, getLifecycle: () => null, isCockpitActive: () => false,
scheduleLayout() {},
getLifecycle: () => null,
preservePanelStateDuringClear: () => false, preservePanelStateDuringClear: () => false,
}; };
const controls = new RadioControls({ const controls = new RadioControls({
elements: { _radioPanel: panel, _radioEnableBtn: enable, _radioNextBtn: next }, elements: {
radio, actions, canvas: new Element(), _radioPanel: panel,
_radioEnableBtn: enable,
_radioNextBtn: next,
},
radio,
actions,
canvas: new Element(),
}); });
return { controls, enable, next, actions, calls, subscribers, document, return {
cleanup() { controls.destroy(); Object.assign(globalThis, prior); } }; 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', () => { test('Radio listeners and subscriptions are revoked once before tuning teardown', () => {
@@ -61,7 +99,8 @@ test('Radio listeners and subscriptions are revoked once before tuning teardown'
f.controls.connect(); f.controls.connect();
f.next.dispatchEvent(new Event('click')); f.next.dispatchEvent(new Event('click'));
assert.deepEqual(f.calls, ['subscribe', ['cycle', 1]]); assert.deepEqual(f.calls, ['subscribe', ['cycle', 1]]);
f.controls.destroy(); f.controls.destroy(); f.controls.destroy();
f.controls.destroy();
const snapshot = structuredClone(f.calls); const snapshot = structuredClone(f.calls);
f.next.dispatchEvent(new Event('click')); f.next.dispatchEvent(new Event('click'));
f.document.dispatchEvent(new Event('gev:radio-selected')); f.document.dispatchEvent(new Event('gev:radio-selected'));
@@ -69,21 +108,28 @@ test('Radio listeners and subscriptions are revoked once before tuning teardown'
f.controls.connect(); f.controls.connect();
assert.deepEqual(f.calls, snapshot); assert.deepEqual(f.calls, snapshot);
assert.deepEqual(f.calls.slice(-2), ['unsubscribe', 'endTuning']); assert.deepEqual(f.calls.slice(-2), ['unsubscribe', 'endTuning']);
} finally { f.cleanup(); } } finally {
f.cleanup();
}
}); });
test('Radio reconnect removes its previous state subscription', () => { test('Radio reconnect removes its previous state subscription', () => {
const f = fixture(); const f = fixture();
try { try {
f.controls.connect(); f.controls.connect(); f.controls.connect();
f.controls.connect();
assert.deepEqual(f.calls, ['subscribe', 'unsubscribe', 'subscribe']); assert.deepEqual(f.calls, ['subscribe', 'unsubscribe', 'subscribe']);
} finally { f.cleanup(); } } finally {
f.cleanup();
}
}); });
test('a delayed Radio enable cannot reveal or refocus controls after destruction', async () => { test('a delayed Radio enable cannot reveal or refocus controls after destruction', async () => {
const f = fixture(); const f = fixture();
let resolve; let resolve;
const pending = new Promise((done) => { resolve = done; }); const pending = new Promise((done) => {
resolve = done;
});
try { try {
f.actions.setEnabled = () => pending; f.actions.setEnabled = () => pending;
f.enable.dispatchEvent(new Event('click')); f.enable.dispatchEvent(new Event('click'));
@@ -91,26 +137,36 @@ test('a delayed Radio enable cannot reveal or refocus controls after destruction
f.controls.destroy(); f.controls.destroy();
const attrs = new Map(f.enable.attrs); const attrs = new Map(f.enable.attrs);
resolve(true); resolve(true);
await pending; await new Promise((done) => setImmediate(done)); await pending;
await new Promise((done) => setImmediate(done));
assert.deepEqual(f.enable.attrs, attrs); assert.deepEqual(f.enable.attrs, attrs);
assert.equal(f.enable.focusCount, 0); assert.equal(f.enable.focusCount, 0);
assert.equal(f.calls.some((call) => Array.isArray(call) && call[0] === 'panel'), false); assert.equal(
} finally { f.cleanup(); } f.calls.some((call) => Array.isArray(call) && call[0] === 'panel'),
false,
);
} finally {
f.cleanup();
}
}); });
test('destruction releases an active tuner pointer after revoking listeners', () => { test('destruction releases an active tuner pointer after revoking listeners', () => {
const f = fixture(); const f = fixture();
try { try {
const released = []; const released = [];
f.controls._radioTunerSlider = { releasePointerCapture(id) { f.controls._radioTunerSlider = {
releasePointerCapture(id) {
assert.equal(f.controls.listeners.signal.aborted, true); assert.equal(f.controls.listeners.signal.aborted, true);
released.push(id); released.push(id);
} }; },
};
f.controls._radioTunerPointerId = 7; f.controls._radioTunerPointerId = 7;
f.controls._radioTunerDragging = true; f.controls._radioTunerDragging = true;
f.controls.destroy(); f.controls.destroy();
assert.deepEqual(released, [7]); assert.deepEqual(released, [7]);
assert.equal(f.controls._radioTunerDragging, false); assert.equal(f.controls._radioTunerDragging, false);
assert.equal(f.controls._radioTunerPointerId, null); assert.equal(f.controls._radioTunerPointerId, null);
} finally { f.cleanup(); } } finally {
f.cleanup();
}
}); });
+185 -63
View File
@@ -1,8 +1,9 @@
/** Render Radio state without making playback or Context decisions. */ /** Render Radio state without making playback or Context decisions. */
export function renderRadioState(state) { export function renderRadioState(state) {
if (this.destroyed || !state || !this._radioPanel) return; if (this.destroyed || !state || !this._radioPanel) return;
const lifecycle = this.actions.getLifecycle() || null; const lifecycle = this.actions.getLifecycle() || null;
const lifecycleState = lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled'); const lifecycleState =
lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled');
state = { state = {
...state, ...state,
enabled: lifecycle ? lifecycle.enabled : state.enabled, enabled: lifecycle ? lifecycle.enabled : state.enabled,
@@ -11,13 +12,16 @@ export function renderRadioState(state) {
}; };
this._radioState = state; this._radioState = state;
const enabled = Boolean(state.enabled); const enabled = Boolean(state.enabled);
const transitioning = lifecycleState === 'enabling' || lifecycleState === 'disabling'; const transitioning =
lifecycleState === 'enabling' || lifecycleState === 'disabling';
const uncertain = Boolean(state.lifecycleUncertain); const uncertain = Boolean(state.lifecycleUncertain);
const interactive = enabled && !transitioning && !uncertain; const interactive = enabled && !transitioning && !uncertain;
const selected = state.selected || null; const selected = state.selected || null;
const hasStations = state.filteredCount > 0; const hasStations = state.filteredCount > 0;
const activePlayback = ['playing', 'buffering'].includes(state.audioState); const activePlayback = ['playing', 'buffering'].includes(state.audioState);
document.getElementById('title-bar')?.classList.toggle('radio-broadcasting', state.audioState === 'playing'); document
.getElementById('title-bar')
?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
this._radioPanel.classList.toggle('radio-enabled', enabled); this._radioPanel.classList.toggle('radio-enabled', enabled);
this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain); this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain);
this._contextRadioDock?.classList.toggle('active', enabled); this._contextRadioDock?.classList.toggle('active', enabled);
@@ -29,17 +33,29 @@ export function renderRadioState(state) {
if (this._radioLayerState) { if (this._radioLayerState) {
this._radioLayerState.textContent = transitioning this._radioLayerState.textContent = transitioning
? lifecycleState.toUpperCase() ? lifecycleState.toUpperCase()
: (uncertain ? 'UNCERTAIN' : (state.loading ? 'SYNC' : (enabled ? `${state.filteredCount}/${state.stationCount}` : 'OFF'))); : uncertain
? 'UNCERTAIN'
: state.loading
? 'SYNC'
: enabled
? `${state.filteredCount}/${state.stationCount}`
: 'OFF';
} }
if (this._radioEnableBtn) { if (this._radioEnableBtn) {
this._radioEnableBtn.classList.toggle('active', enabled); this._radioEnableBtn.classList.toggle('active', enabled);
this._radioEnableBtn.setAttribute('aria-pressed', String(enabled)); this._radioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._radioEnableBtn.textContent = transitioning this._radioEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase() ? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); : uncertain
? 'RECONCILE'
: enabled
? 'DISABLE'
: 'ENABLE';
this._radioEnableBtn.setAttribute( this._radioEnableBtn.setAttribute(
'aria-label', 'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, uncertain
? 'Reconcile Radio — lifecycle uncertain'
: `${enabled ? 'Disable' : 'Enable'} Radio`,
); );
this._radioEnableBtn.disabled = false; this._radioEnableBtn.disabled = false;
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning)); this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
@@ -47,31 +63,58 @@ export function renderRadioState(state) {
} }
if (this._contextRadioMiniEnableBtn) { if (this._contextRadioMiniEnableBtn) {
this._contextRadioMiniEnableBtn.classList.toggle('active', enabled); this._contextRadioMiniEnableBtn.classList.toggle('active', enabled);
this._contextRadioMiniEnableBtn.setAttribute('aria-pressed', String(enabled)); this._contextRadioMiniEnableBtn.setAttribute(
'aria-pressed',
String(enabled),
);
this._contextRadioMiniEnableBtn.textContent = transitioning this._contextRadioMiniEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase() ? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); : uncertain
? 'RECONCILE'
: enabled
? 'DISABLE'
: 'ENABLE';
this._contextRadioMiniEnableBtn.setAttribute( this._contextRadioMiniEnableBtn.setAttribute(
'aria-label', 'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, uncertain
? 'Reconcile Radio — lifecycle uncertain'
: `${enabled ? 'Disable' : 'Enable'} Radio`,
); );
this._contextRadioMiniEnableBtn.disabled = false; this._contextRadioMiniEnableBtn.disabled = false;
this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning)); this._contextRadioMiniEnableBtn.setAttribute(
this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning)); 'aria-disabled',
String(transitioning),
);
this._contextRadioMiniEnableBtn.setAttribute(
'aria-busy',
String(transitioning),
);
} }
if (this._cockpitRadioEnableBtn) { if (this._cockpitRadioEnableBtn) {
this._cockpitRadioEnableBtn.classList.toggle('active', enabled); this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled)); this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._cockpitRadioEnableBtn.textContent = transitioning this._cockpitRadioEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase() ? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE')); : uncertain
? 'RECONCILE'
: enabled
? 'DISABLE'
: 'ENABLE';
this._cockpitRadioEnableBtn.setAttribute( this._cockpitRadioEnableBtn.setAttribute(
'aria-label', 'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`, uncertain
? 'Reconcile Radio — lifecycle uncertain'
: `${enabled ? 'Disable' : 'Enable'} Radio`,
); );
this._cockpitRadioEnableBtn.disabled = false; this._cockpitRadioEnableBtn.disabled = false;
this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning)); this._cockpitRadioEnableBtn.setAttribute(
this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning)); 'aria-disabled',
String(transitioning),
);
this._cockpitRadioEnableBtn.setAttribute(
'aria-busy',
String(transitioning),
);
} }
if (this._radioFilter) { if (this._radioFilter) {
@@ -80,19 +123,26 @@ export function renderRadioState(state) {
.map((category) => `${category.id}:${category.count}:${category.color}`) .map((category) => `${category.id}:${category.count}:${category.color}`)
.join('|'); .join('|');
if (categorySignature !== this._radioCategorySignature) { if (categorySignature !== this._radioCategorySignature) {
this._radioFilter.replaceChildren(...state.categories.map((category) => { this._radioFilter.replaceChildren(
...state.categories.map((category) => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = category.id; option.value = category.id;
option.textContent = `● ${category.label} (${category.count})`; option.textContent = `● ${category.label} (${category.count})`;
option.dataset.radioColor = category.color; option.dataset.radioColor = category.color;
option.style.color = category.color; option.style.color = category.color;
option.setAttribute('aria-label', `${category.label} (${category.count})`); option.setAttribute(
'aria-label',
`${category.label} (${category.count})`,
);
return option; return option;
})); }),
);
this._radioCategorySignature = categorySignature; this._radioCategorySignature = categorySignature;
} }
this._radioFilter.value = prior; this._radioFilter.value = prior;
const activeCategory = state.categories.find((category) => category.id === prior); const activeCategory = state.categories.find(
(category) => category.id === prior,
);
this._radioFilter.style.color = activeCategory?.color || ''; this._radioFilter.style.color = activeCategory?.color || '';
this._radioFilter.disabled = !interactive || !state.stationCount; this._radioFilter.disabled = !interactive || !state.stationCount;
} }
@@ -101,8 +151,11 @@ export function renderRadioState(state) {
if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable; if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable;
if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable; if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable;
if (this._radioTunerBandLabel) { if (this._radioTunerBandLabel) {
const activeCategory = state.categories.find((category) => category.id === state.filter); const activeCategory = state.categories.find(
this._radioTunerBandLabel.textContent = state.filter === 'all' (category) => category.id === state.filter,
);
this._radioTunerBandLabel.textContent =
state.filter === 'all'
? 'DIRECTORY BAND' ? 'DIRECTORY BAND'
: `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`; : `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`;
} }
@@ -121,17 +174,30 @@ export function renderRadioState(state) {
this._radioTunerSelectedId = null; this._radioTunerSelectedId = null;
} }
if (this._radioStationName) this._radioStationName.textContent = selected?.name || 'NO STATION SELECTED'; if (this._radioStationName)
this._radioStationName.textContent =
selected?.name || 'NO STATION SELECTED';
if (this._radioStationMeta) { if (this._radioStationMeta) {
const place = selected ? [selected.state, selected.countryCode].filter(Boolean).join(' · ') : ''; const place = selected
const signal = selected ? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : ''].filter(Boolean).join(' · ') : ''; ? [selected.state, selected.countryCode].filter(Boolean).join(' · ')
: '';
const signal = selected
? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : '']
.filter(Boolean)
.join(' · ')
: '';
this._radioStationMeta.textContent = selected this._radioStationMeta.textContent = selected
? [place, signal].filter(Boolean).join(' / ') || 'Directory metadata only' ? [place, signal].filter(Boolean).join(' / ') ||
: (state.loading ? 'Loading station directory…' : 'Choose a globe marker or use next.'); 'Directory metadata only'
: state.loading
? 'Loading station directory…'
: 'Choose a globe marker or use next.';
} }
if (this._radioStationTags) { if (this._radioStationTags) {
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : []; const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
this._radioStationTags.textContent = tags.length ? `TAGS · ${tags.join(' · ')}` : ''; this._radioStationTags.textContent = tags.length
? `TAGS · ${tags.join(' · ')}`
: '';
} }
if (this._radioStationHomepage) { if (this._radioStationHomepage) {
const homepage = selected?.homepage || ''; const homepage = selected?.homepage || '';
@@ -140,72 +206,117 @@ export function renderRadioState(state) {
else this._radioStationHomepage.removeAttribute('href'); else this._radioStationHomepage.removeAttribute('href');
} }
if (this._radioPrevBtn) this._radioPrevBtn.disabled = !interactive || !hasStations; if (this._radioPrevBtn)
if (this._radioNextBtn) this._radioNextBtn.disabled = !interactive || !hasStations; this._radioPrevBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniPrevBtn) this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations; if (this._radioNextBtn)
if (this._contextRadioMiniNextBtn) this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations; this._radioNextBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioPrevBtn) this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations; if (this._contextRadioMiniPrevBtn)
if (this._cockpitRadioNextBtn) this._cockpitRadioNextBtn.disabled = !interactive || !hasStations; 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) { if (this._radioPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); const action = activePlayback
? 'Pause'
: state.audioState === 'paused'
? 'Resume'
: 'Play';
this._radioPlayBtn.disabled = !interactive || !hasStations; this._radioPlayBtn.disabled = !interactive || !hasStations;
this._radioPlayBtn.classList.toggle('active', activePlayback); this._radioPlayBtn.classList.toggle('active', activePlayback);
this._radioPlayBtn.textContent = action.toUpperCase(); this._radioPlayBtn.textContent = action.toUpperCase();
this._radioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); this._radioPlayBtn.setAttribute(
'aria-label',
`${action} ${selected ? 'selected' : 'nearest'} radio station`,
);
} }
if (this._contextRadioMiniPlayBtn) { if (this._contextRadioMiniPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); const action = activePlayback
? 'Pause'
: state.audioState === 'paused'
? 'Resume'
: 'Play';
this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations; this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations;
this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback); this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback);
this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._contextRadioMiniPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); this._contextRadioMiniPlayBtn.setAttribute(
'aria-label',
`${action} ${selected ? 'selected' : 'nearest'} radio station`,
);
this._contextRadioMiniPlayBtn.title = action; this._contextRadioMiniPlayBtn.title = action;
} }
if (this._cockpitRadioPlayBtn) { if (this._cockpitRadioPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play'); const action = activePlayback
? 'Pause'
: state.audioState === 'paused'
? 'Resume'
: 'Play';
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations; this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback); this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶'; this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._cockpitRadioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`); this._cockpitRadioPlayBtn.setAttribute(
'aria-label',
`${action} ${selected ? 'selected' : 'nearest'} radio station`,
);
this._cockpitRadioPlayBtn.title = action; this._cockpitRadioPlayBtn.title = action;
} }
if (this._radioStopBtn) this._radioStopBtn.disabled = !interactive || state.audioState === 'stopped'; if (this._radioStopBtn)
this._radioStopBtn.disabled =
!interactive || state.audioState === 'stopped';
if (this._radioVolume) this._radioVolume.disabled = !interactive; if (this._radioVolume) this._radioVolume.disabled = !interactive;
if (this._radioVolume && document.activeElement !== this._radioVolume) { if (this._radioVolume && document.activeElement !== this._radioVolume) {
this._radioVolume.value = String(Math.round(state.volume * 100)); this._radioVolume.value = String(Math.round(state.volume * 100));
if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; if (this._radioVolumeValue)
this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
} }
if (this._contextRadioMiniVolume && document.activeElement !== this._contextRadioMiniVolume) { if (
this._contextRadioMiniVolume &&
document.activeElement !== this._contextRadioMiniVolume
) {
this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100)); this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100));
} }
if (this._contextRadioMiniVolume) this._contextRadioMiniVolume.disabled = !interactive; if (this._contextRadioMiniVolume)
this._contextRadioMiniVolume.disabled = !interactive;
if (this._contextRadioMiniVolumeValue) { if (this._contextRadioMiniVolumeValue) {
this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
} }
if (this._cockpitRadioVolume && document.activeElement !== this._cockpitRadioVolume) { if (
this._cockpitRadioVolume &&
document.activeElement !== this._cockpitRadioVolume
) {
this._cockpitRadioVolume.value = String(Math.round(state.volume * 100)); this._cockpitRadioVolume.value = String(Math.round(state.volume * 100));
} }
if (this._cockpitRadioVolume) this._cockpitRadioVolume.disabled = !interactive; if (this._cockpitRadioVolume)
this._cockpitRadioVolume.disabled = !interactive;
if (this._cockpitRadioVolumeValue) { if (this._cockpitRadioVolumeValue) {
this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`; this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
} }
if (this._contextRadioMiniStation) { if (this._contextRadioMiniStation) {
this._contextRadioMiniStation.textContent = uncertain this._contextRadioMiniStation.textContent = uncertain
? 'RADIO STATE UNCERTAIN' ? 'RADIO STATE UNCERTAIN'
: (selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY')); : selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY');
} }
if (this._cockpitRadioStation) { if (this._cockpitRadioStation) {
this._cockpitRadioStation.textContent = uncertain this._cockpitRadioStation.textContent = uncertain
? 'UNCERTAIN' ? 'UNCERTAIN'
: (selected?.name || (state.loading ? 'SYNCING' : 'READY')); : selected?.name || (state.loading ? 'SYNCING' : 'READY');
} }
if (this._radioPlaybackState) { if (this._radioPlaybackState) {
const catalogSuffix = state.degraded const catalogSuffix = state.degraded
? (state.stale ? ' · stale/degraded directory' : ' · degraded directory') ? state.stale
: (state.stale ? ' · stale directory' : ''); ? ' · stale/degraded directory'
const outsideFilter = selected && state.selectedIndex < 0 ? ' · outside current filter' : ''; : ' · degraded directory'
: state.stale
? ' · stale directory'
: '';
const outsideFilter =
selected && state.selectedIndex < 0 ? ' · outside current filter' : '';
const messages = { const messages = {
stopped: enabled ? 'Ready — playback starts only from your action' : 'Radio off', stopped: enabled
? 'Ready — playback starts only from your action'
: 'Radio off',
loading: 'Connecting directly to broadcaster…', loading: 'Connecting directly to broadcaster…',
buffering: 'Buffering broadcaster stream…', buffering: 'Buffering broadcaster stream…',
playing: `Playing ${selected?.name || 'station'}`, playing: `Playing ${selected?.name || 'station'}`,
@@ -214,32 +325,43 @@ export function renderRadioState(state) {
}; };
const voiceSuffix = state.voiceDucked const voiceSuffix = state.voiceDucked
? ' · muted during voice interaction' ? ' · muted during voice interaction'
: (state.voiceRestoring ? ' · restoring volume after voice' : ''); : state.voiceRestoring
? ' · restoring volume after voice'
: '';
const tuningSuffix = state.tuningAwaitingStationId const tuningSuffix = state.tuningAwaitingStationId
? (state.audioState === 'error' ? state.audioState === 'error'
? ' · static indicates no broadcaster audio' ? ' · static indicates no broadcaster audio'
: ' · tuning static until broadcaster starts') : ' · tuning static until broadcaster starts'
: ''; : '';
const unavailable = state.tuningUnavailableStationId const unavailable = state.tuningUnavailableStationId
? 'Station unavailable after directory refresh — choose another channel' ? 'Station unavailable after directory refresh — choose another channel'
: null; : null;
const lifecycleMessage = transitioning const lifecycleMessage = transitioning
? (lifecycleState === 'enabling' ? 'Radio is enabling…' : 'Radio is disabling…') ? lifecycleState === 'enabling'
? 'Radio is enabling…'
: 'Radio is disabling…'
: null; : null;
const uncertainMessage = uncertain const uncertainMessage = uncertain
? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile' ? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile'
: null; : null;
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`; 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')); this._radioPlaybackState.classList.toggle(
'error',
Boolean(
uncertainMessage ||
unavailable ||
state.error ||
state.audioState === 'error',
),
);
} }
if ( if (
!enabled !enabled &&
&& !transitioning !transitioning &&
&& !this.actions.preservePanelStateDuringClear() !this.actions.preservePanelStateDuringClear() &&
&& !this._radioPanel.classList.contains('collapsed') !this._radioPanel.classList.contains('collapsed')
) { ) {
this.actions.setPanelCollapsed('radio-panel', true); this.actions.setPanelCollapsed('radio-panel', true);
} }
this.actions.scheduleLayout(); this.actions.scheduleLayout();
} }
+60 -12
View File
@@ -1,7 +1,15 @@
/** Map an integer tuner slot directly to one available directory station. */ /** Map an integer tuner slot directly to one available directory station. */
export function radioTunerSlot(value, stationCount) { export function radioTunerSlot(value, stationCount) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0)); 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 }; if (!count)
return {
slot: 0,
max: 0,
locked: false,
stationIndex: -1,
leftIndex: -1,
rightIndex: -1,
};
const max = Math.max(0, count - 1); const max = Math.max(0, count - 1);
const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0))); const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0)));
return { return {
@@ -20,29 +28,49 @@ export function radioTunerCommitSlot(value, stationCount) {
} }
/** Map one pointer coordinate to continuous absolute directory progress. */ /** Map one pointer coordinate to continuous absolute directory progress. */
export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) { export function radioTunerPointerPosition(
clientX,
left,
width,
stationCount,
insetPx = 7,
) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0)); const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 }; if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 };
if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 }; if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 };
const inset = Math.max(0, Number(insetPx) || 0); const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2); 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 ratio = Math.min(
1,
Math.max(
0,
((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth,
),
);
const coordinate = ratio * (count - 1); const coordinate = ratio * (count - 1);
return { return {
ratio, ratio,
coordinate, coordinate,
stationIndex: Math.min(count - 1, Math.max(0, Math.floor(coordinate + 0.5))), stationIndex: Math.min(
count - 1,
Math.max(0, Math.floor(coordinate + 0.5)),
),
}; };
} }
/** Build a bounded virtual tuner tape around one continuous directory coordinate. */ /** Build a bounded virtual tuner tape around one continuous directory coordinate. */
export function buildRadioTunerTicks(coordinate, stationCount, width, { export function buildRadioTunerTicks(
coordinate,
stationCount,
width,
{
insetPx = 7, insetPx = 7,
minPitchPx = 14, minPitchPx = 14,
speedFactor = 5, speedFactor = 5,
overscan = 2, overscan = 2,
labelStep = 6, labelStep = 6,
} = {}) { } = {},
) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0)); const count = Math.max(0, Math.floor(Number(stationCount) || 0));
const dialWidth = Math.max(0, Number(width) || 0); const dialWidth = Math.max(0, Number(width) || 0);
const inset = Math.max(0, Number(insetPx) || 0); const inset = Math.max(0, Number(insetPx) || 0);
@@ -51,7 +79,15 @@ export function buildRadioTunerTicks(coordinate, stationCount, width, {
const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0)); const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0));
if (count === 1) { if (count === 1) {
return { return {
ticks: [{ stationIndex: 0, channel: 1, xPx: inset + usableWidth / 2, current: true, label: '01' }], ticks: [
{
stationIndex: 0,
channel: 1,
xPx: inset + usableWidth / 2,
current: true,
label: '01',
},
],
needleX: inset + usableWidth / 2, needleX: inset + usableWidth / 2,
pitchPx: Math.max(1, Number(minPitchPx) || 14), pitchPx: Math.max(1, Number(minPitchPx) || 14),
ratio: 0.5, ratio: 0.5,
@@ -64,16 +100,29 @@ export function buildRadioTunerTicks(coordinate, stationCount, width, {
); );
const needleX = inset + directoryStep * value; const needleX = inset + directoryStep * value;
const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0); const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0);
const first = Math.max(0, Math.ceil(value + (-overscanPx - needleX) / pitchPx)); const first = Math.max(
const last = Math.min(count - 1, Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx)); 0,
const currentIndex = Math.min(count - 1, Math.max(0, Math.floor(value + 0.5))); 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 majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6));
const labelWidth = Math.max(2, String(count).length); const labelWidth = Math.max(2, String(count).length);
const ticks = []; const ticks = [];
for (let stationIndex = first; stationIndex <= last; stationIndex += 1) { for (let stationIndex = first; stationIndex <= last; stationIndex += 1) {
const channel = stationIndex + 1; const channel = stationIndex + 1;
const current = stationIndex === currentIndex; const current = stationIndex === currentIndex;
const labelled = current || stationIndex === 0 || stationIndex === count - 1 || channel % majorEvery === 0; const labelled =
current ||
stationIndex === 0 ||
stationIndex === count - 1 ||
channel % majorEvery === 0;
ticks.push({ ticks.push({
stationIndex, stationIndex,
channel, channel,
@@ -84,4 +133,3 @@ export function buildRadioTunerTicks(coordinate, stationCount, width, {
} }
return { ticks, needleX, pitchPx, ratio: value / (count - 1) }; return { ticks, needleX, pitchPx, ratio: value / (count - 1) };
} }