refactor: isolate Radio controls and tuner presentation

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

No files matched your search

+2
View File
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
- Extract Radio controls and tuner presentation with explicit actions and complete listener/subscription cleanup.
- Extract Location controls and cancellable search presentation; preserve navigation handoff and prevent delayed POI expansion after closing the row.
- Separate Layers panel presentation and clear-control bindings from layer lifecycle operations; revoke listeners and subscriptions on replacement or teardown.
+8
View File
@@ -287,3 +287,11 @@ the existing location-status formatter. Callers supply city data, search and
navigation operations. The component owns DOM listeners and pending expansion;
it imports no geocoder, camera engine, layer or application bootstrap. Existing
camera authority and search providers remain supplied by the application.
### Radio controls
`ui/radio` owns Radio input, disclosures, tuner state and presentation. It
receives the existing Radio port and explicit layer/layout actions, without
importing the renderer or station providers. Pure tuner calculations retain
compatibility exports from the data layer. Disposal revokes DOM listeners and
subscriptions before ending the active tuning interaction.
+10
View File
@@ -1,5 +1,15 @@
# God's Eye View Current State
## Radio controls
Radio panel, compact controls and tuner presentation have a dedicated owner.
It receives playback, layer actions and layout callbacks; station ingestion,
marker placement, audio playback and camera policy stay with their existing
owners. Tuner calculations have a pure entry, with existing layer exports
preserved. Destruction removes listeners and state subscriptions before ending
tuning; a delayed Enable result cannot reveal or refocus removed controls.
## Location control ownership
City/POI rows, search/reset bindings, location readouts and the orbit indicator
+2 -1
View File
@@ -129,6 +129,7 @@
"./ui/maps": "./src/ui/mapSource.js",
"./ui/layers": "./src/ui/layers.js",
"./ui/layers/feedback": "./src/loadingFeedback.js",
"./ui/location": "./src/ui/location.js"
"./ui/location": "./src/ui/location.js",
"./ui/radio": "./src/ui/radio.js"
}
}
+7 -1
View File
@@ -166,5 +166,11 @@
"src/locationStatus.js",
"src/ui/locationControls.test.mjs",
"src/ui/locationSearch.test.mjs",
"scripts/qa-location-controls.mjs"
"scripts/qa-location-controls.mjs",
"src/ui/radio.js",
"src/ui/radioControls.js",
"src/ui/radioBindings.js",
"src/ui/radioPresentation.js",
"src/ui/radioTunerModel.js",
"src/ui/radioControls.test.mjs"
]
+173 -52
View File
@@ -10,27 +10,47 @@
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js"
],
"external": ["cesium"]
"external": [
"cesium"
]
},
"application": {
"exports": ["./application"],
"modules": ["src/app/application.js"],
"exports": [
"./application"
],
"modules": [
"src/app/application.js"
],
"external": []
},
"viewer": {
"exports": ["./application/viewer"],
"modules": ["src/app/viewer.js"],
"external": ["cesium"]
"exports": [
"./application/viewer"
],
"modules": [
"src/app/viewer.js"
],
"external": [
"cesium"
]
},
"vite-build": {
"runtime": "node",
"exports": ["./build/vite"],
"modules": ["build/vite.js"],
"external": ["vite-plugin-cesium"]
"exports": [
"./build/vite"
],
"modules": [
"build/vite.js"
],
"external": [
"vite-plugin-cesium"
]
},
"live-providers": {
"runtime": "node",
"exports": ["./server/providers/live"],
"exports": [
"./server/providers/live"
],
"modules": [
"server/providers/live.js",
"server/providers/common/http.js",
@@ -45,16 +65,24 @@
"src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js"
],
"external": ["ws"]
"external": [
"ws"
]
},
"adsb-lol-source": {
"exports": ["./sources/adsb-lol"],
"modules": ["src/data/adsbLolFallback.js"],
"exports": [
"./sources/adsb-lol"
],
"modules": [
"src/data/adsbLolFallback.js"
],
"external": []
},
"place-providers": {
"runtime": "node",
"exports": ["./server/providers/places"],
"exports": [
"./server/providers/places"
],
"modules": [
"server/providers/places.js",
"server/providers/places/google.js",
@@ -69,13 +97,19 @@
"external": []
},
"place-payloads": {
"exports": ["./sources/places"],
"modules": ["src/data/placeProviderPayloads.js"],
"exports": [
"./sources/places"
],
"modules": [
"src/data/placeProviderPayloads.js"
],
"external": []
},
"space-providers": {
"runtime": "node",
"exports": ["./server/providers/space"],
"exports": [
"./server/providers/space"
],
"modules": [
"server/providers/space.js",
"server/providers/space/celestrak.js",
@@ -86,13 +120,19 @@
"external": []
},
"space-requests": {
"exports": ["./sources/space"],
"modules": ["src/data/spaceProviderRequests.js"],
"exports": [
"./sources/space"
],
"modules": [
"src/data/spaceProviderRequests.js"
],
"external": []
},
"terrain-provider": {
"runtime": "node",
"exports": ["./server/providers/terrain"],
"exports": [
"./server/providers/terrain"
],
"modules": [
"server/providers/terrain.js",
"src/data/terrainHeightsProxy.js"
@@ -100,30 +140,50 @@
"external": []
},
"terrain-source": {
"exports": ["./sources/terrain"],
"modules": ["src/data/terrainHeightsProxy.js"],
"exports": [
"./sources/terrain"
],
"modules": [
"src/data/terrainHeightsProxy.js"
],
"external": []
},
"traffic-provider": {
"runtime": "node",
"exports": ["./server/providers/traffic"],
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
"exports": [
"./server/providers/traffic"
],
"modules": [
"server/providers/traffic.js",
"src/data/tomtomTiles.js"
],
"external": []
},
"traffic-source": {
"exports": ["./sources/traffic"],
"modules": ["src/data/tomtomTiles.js"],
"exports": [
"./sources/traffic"
],
"modules": [
"src/data/tomtomTiles.js"
],
"external": []
},
"firms-provider": {
"runtime": "node",
"exports": ["./server/providers/firms"],
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
"exports": [
"./server/providers/firms"
],
"modules": [
"server/providers/firms.js",
"src/data/firmsCsv.js"
],
"external": []
},
"gbfs-provider": {
"runtime": "node",
"exports": ["./server/providers/gbfs"],
"exports": [
"./server/providers/gbfs"
],
"modules": [
"server/providers/gbfs.js",
"server/providers/common/http.js",
@@ -132,13 +192,19 @@
"external": []
},
"gbfs-source": {
"exports": ["./sources/gbfs"],
"modules": ["src/data/gbfsSource.js"],
"exports": [
"./sources/gbfs"
],
"modules": [
"src/data/gbfsSource.js"
],
"external": []
},
"cctv-provider": {
"runtime": "node",
"exports": ["./server/providers/cctv"],
"exports": [
"./server/providers/cctv"
],
"modules": [
"server/providers/cctv.js",
"server/providers/cctv/catalog.js",
@@ -155,7 +221,9 @@
},
"radio-provider": {
"runtime": "node",
"exports": ["./server/providers/radio"],
"exports": [
"./server/providers/radio"
],
"modules": [
"server/providers/radio.js",
"server/providers/radio/catalog.js",
@@ -169,7 +237,9 @@
},
"overpass-provider": {
"runtime": "node",
"exports": ["./server/providers/overpass"],
"exports": [
"./server/providers/overpass"
],
"modules": [
"server/providers/common/geo.js",
"server/providers/common/http.js",
@@ -188,7 +258,9 @@
},
"military-installations-provider": {
"runtime": "node",
"exports": ["./server/providers/military-installations"],
"exports": [
"./server/providers/military-installations"
],
"modules": [
"server/providers/common/http.js",
"server/providers/common/query.js",
@@ -205,7 +277,9 @@
},
"regional-provider": {
"runtime": "node",
"exports": ["./server/providers/regional"],
"exports": [
"./server/providers/regional"
],
"modules": [
"server/providers/common/http.js",
"server/providers/common/query.js",
@@ -224,7 +298,9 @@
},
"openai-provider": {
"runtime": "node",
"exports": ["./server/providers/openai"],
"exports": [
"./server/providers/openai"
],
"modules": [
"server/providers/common/rate-limit.js",
"server/providers/common/request.js",
@@ -244,7 +320,9 @@
},
"standalone-key-setup": {
"runtime": "node",
"exports": ["./server/standalone/key-setup"],
"exports": [
"./server/standalone/key-setup"
],
"modules": [
"scripts/pinokio-environment.mjs",
"server/providers/common/source-root.js",
@@ -255,7 +333,9 @@
"external": []
},
"place-search": {
"exports": ["./search"],
"exports": [
"./search"
],
"modules": [
"src/search/index.js",
"src/search/placeSearch.js",
@@ -265,17 +345,27 @@
"external": []
},
"panel-disclosure": {
"exports": ["./ui/panels"],
"modules": ["src/ui/panelDisclosure.js"],
"exports": [
"./ui/panels"
],
"modules": [
"src/ui/panelDisclosure.js"
],
"external": []
},
"surface-keyboard": {
"exports": ["./ui/surfaces"],
"modules": ["src/ui/surfaceKeyboard.js"],
"exports": [
"./ui/surfaces"
],
"modules": [
"src/ui/surfaceKeyboard.js"
],
"external": []
},
"panel-rail-layout": {
"exports": ["./ui/layout"],
"exports": [
"./ui/layout"
],
"modules": [
"src/ui/panelRails.js",
"src/ui/leftPanelRail.js",
@@ -287,7 +377,9 @@
"external": []
},
"visual-input": {
"exports": ["./ui/input"],
"exports": [
"./ui/input"
],
"modules": [
"src/ui/visualInput.js",
"src/ui/applicationShortcuts.js",
@@ -296,12 +388,19 @@
"external": []
},
"display-controls": {
"exports": ["./ui/display"],
"modules": ["src/ui/displayControls.js"],
"exports": [
"./ui/display"
],
"modules": [
"src/ui/displayControls.js"
],
"external": []
},
"visual-effects": {
"exports": ["./ui/effects", "./ui/effects/bloom"],
"exports": [
"./ui/effects",
"./ui/effects/bloom"
],
"modules": [
"src/ui/effects.js",
"src/ui/visualEffects.js",
@@ -314,10 +413,14 @@
"src/styles/noir.js",
"src/styles/snow.js"
],
"external": ["cesium"]
"external": [
"cesium"
]
},
"map-source-controls": {
"exports": ["./ui/maps"],
"exports": [
"./ui/maps"
],
"modules": [
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
@@ -327,7 +430,10 @@
"external": []
},
"layer-panel": {
"exports": ["./ui/layers", "./ui/layers/feedback"],
"exports": [
"./ui/layers",
"./ui/layers/feedback"
],
"modules": [
"src/ui/layers.js",
"src/ui/layerPanel.js",
@@ -338,7 +444,9 @@
"external": []
},
"location-controls": {
"exports": ["./ui/location"],
"exports": [
"./ui/location"
],
"modules": [
"src/ui/location.js",
"src/ui/locationControls.js",
@@ -346,5 +454,18 @@
"src/locationStatus.js"
],
"external": []
},
"radio-controls": {
"exports": [
"./ui/radio"
],
"modules": [
"src/ui/radio.js",
"src/ui/radioControls.js",
"src/ui/radioBindings.js",
"src/ui/radioPresentation.js",
"src/ui/radioTunerModel.js"
],
"external": []
}
}
+52 -52
View File
@@ -281,7 +281,7 @@ async function main() {
fullPlayEnabled: !document.getElementById('radio-play-btn').disabled,
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
tunerVisible: !document.getElementById('radio-tuner').hidden,
tunerCount: window.__godsEyeView.styleManager._radioTunerStations.length,
tunerCount: window.__godsEyeView.styleManager._radioControls._radioTunerStations.length,
tunerLabel: document.getElementById('radio-tuner-band-label').textContent,
};
});
@@ -332,19 +332,19 @@ async function main() {
const staticResult = module.setTuningStatic(true);
const filterBefore = module.getUIState().filter;
const filterControl = document.getElementById('radio-filter');
const originalPinned = style._radioTunerBandPinnedForNavigation;
const originalPool = style._radioTunerPool;
style._radioTunerBandPinnedForNavigation = true;
const originalPinned = style._radioControls._radioTunerBandPinnedForNavigation;
const originalPool = style._radioControls._radioTunerPool;
style._radioControls._radioTunerBandPinnedForNavigation = true;
filterControl.value = filterBefore === 'news' ? 'all' : 'news';
filterControl.dispatchEvent(new Event('change', { bubbles: true }));
const rejectedUIFilter = {
moduleFilter: module.getUIState().filter,
controlFilter: filterControl.value,
pinned: style._radioTunerBandPinnedForNavigation,
poolIdentityPreserved: style._radioTunerPool === originalPool,
pinned: style._radioControls._radioTunerBandPinnedForNavigation,
poolIdentityPreserved: style._radioControls._radioTunerPool === originalPool,
};
style._radioTunerBandPinnedForNavigation = originalPinned;
style._radioTunerPool = originalPool;
style._radioControls._radioTunerBandPinnedForNavigation = originalPinned;
style._radioControls._radioTunerPool = originalPool;
const filterResult = module.setFilter('news');
const toggleResult = await module.togglePlayback({ origin: 'user' });
const directSelect = module.selectStation(stationId, {
@@ -2719,7 +2719,7 @@ async function main() {
const dialRect = document.querySelector('.radio-tuner-dial').getBoundingClientRect();
const sliderRect = slider.getBoundingClientRect();
const panelRect = document.querySelector('.radio-panel-inner').getBoundingClientRect();
const count = style._radioTunerStations.length;
const count = style._radioControls._radioTunerStations.length;
const usable = sliderRect.width - 14;
const xFor = (coordinate) => sliderRect.left + 7 + usable * coordinate / Math.max(1, count - 1);
const pointer = (type, x, pointerId = 71) => slider.dispatchEvent(new PointerEvent(type, {
@@ -2745,7 +2745,7 @@ async function main() {
};
};
const playsBefore = window.__qaRadioPlayCalls.length;
const frozenSignature = style._radioTunerBandSignature;
const frozenSignature = style._radioControls._radioTunerBandSignature;
pointer('pointerdown', xFor(0));
const left = snapshot();
const centerCoordinate = (count - 1) / 2;
@@ -2765,14 +2765,14 @@ async function main() {
visible: !document.getElementById('radio-tuner').hidden,
stationCount: count,
sliderMax: Number(slider.max),
bandFrozen: style._radioTunerBandSignature === frozenSignature,
bandFrozen: style._radioControls._radioTunerBandSignature === frozenSignature,
left,
center,
shifted,
right,
leftExpectedId: style._radioTunerStations[0]?.id || null,
centerExpectedId: style._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null,
rightExpectedId: style._radioTunerStations[count - 1]?.id || null,
leftExpectedId: style._radioControls._radioTunerStations[0]?.id || null,
centerExpectedId: style._radioControls._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null,
rightExpectedId: style._radioControls._radioTunerStations[count - 1]?.id || null,
centerExpected: Math.floor(centerCoordinate + 0.5),
tapeDelta,
needleDelta,
@@ -2817,7 +2817,7 @@ async function main() {
const slider = document.getElementById('radio-tuner-slider');
// Capture a fresh gesture's complete presentation anchor before moving
// to a different absolute directory station.
const beforeStations = [...style._radioTunerStations];
const beforeStations = [...style._radioControls._radioTunerStations];
const sliderRect = slider.getBoundingClientRect();
const xFor = (index) => sliderRect.left + 7
+ (sliderRect.width - 14) * index / Math.max(1, beforeStations.length - 1);
@@ -2834,7 +2834,7 @@ async function main() {
const flyToCalls = [];
camera.flyTo = (options) => flyToCalls.push(options);
const before = {
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
ids: beforeStations.map((station) => station.id),
slot: Number(slider.value),
selectedId: radio.getUIState().selected?.id || null,
@@ -2848,10 +2848,10 @@ async function main() {
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
const flyToCallsBeforeCancel = flyToCalls.length;
pointer('pointercancel', targetIndex);
const afterStations = style._radioTunerStations;
const afterStations = style._radioControls._radioTunerStations;
const afterState = radio.getUIState();
const after = {
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
ids: afterStations.map((station) => station.id),
slot: Number(slider.value),
selectedId: afterState.selected?.id || null,
@@ -2972,7 +2972,7 @@ async function main() {
const tunerRefreshTarget = await page.evaluate(() => {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
const stations = gev.styleManager._radioTunerStations;
const stations = gev.styleManager._radioControls._radioTunerStations;
const targetIndex = 1;
const rect = slider.getBoundingClientRect();
const clientX = rect.left + 7 + (rect.width - 14) * targetIndex / Math.max(1, stations.length - 1);
@@ -3055,7 +3055,7 @@ async function main() {
const max = Number(slider.max);
const direction = startSlot < max ? 1 : -1;
const targetIndex = startSlot + direction;
const targetId = gev.styleManager._radioTunerStations[targetIndex]?.id || null;
const targetId = gev.styleManager._radioControls._radioTunerStations[targetIndex]?.id || null;
const rect = slider.getBoundingClientRect();
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, max);
const pointer = (type, index) => slider.dispatchEvent(new PointerEvent(type, {
@@ -3106,18 +3106,18 @@ async function main() {
const slider = document.getElementById('radio-tuner-slider');
const targetIndex = 11;
slider.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, pointerId: 1 }));
const station = gev.styleManager._radioTunerStations[targetIndex];
const station = gev.styleManager._radioControls._radioTunerStations[targetIndex];
const rect = slider.getBoundingClientRect();
const currentRatio = Number(slider.value) / Math.max(1, Number(slider.max));
window.__qaRadioDelayNextPlay = true;
return {
id: station.id,
targetIndex,
count: gev.styleManager._radioTunerStations.length,
frozenSignature: gev.styleManager._radioTunerBandSignature,
count: gev.styleManager._radioControls._radioTunerStations.length,
frozenSignature: gev.styleManager._radioControls._radioTunerBandSignature,
startX: rect.left + 7 + (rect.width - 14) * currentRatio,
targetX: rect.left + 7 + (rect.width - 14) * targetIndex
/ Math.max(1, gev.styleManager._radioTunerStations.length - 1),
/ Math.max(1, gev.styleManager._radioControls._radioTunerStations.length - 1),
y: rect.top + rect.height / 2,
};
});
@@ -3135,11 +3135,11 @@ async function main() {
audioState: state.audioState,
tuningStatic: state.tuningStatic,
awaiting: state.tuningAwaitingStationId,
signatureStable: gev.styleManager._radioTunerBandSignature === target.frozenSignature,
selectedIndex: gev.styleManager._radioTunerStations.findIndex((item) => item.id === state.selected?.id),
signatureStable: gev.styleManager._radioControls._radioTunerBandSignature === target.frozenSignature,
selectedIndex: gev.styleManager._radioControls._radioTunerStations.findIndex((item) => item.id === state.selected?.id),
sliderSlot: Number(slider.value),
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
pinned: gev.styleManager._radioTunerBandPinnedForNavigation,
pinned: gev.styleManager._radioControls._radioTunerBandPinnedForNavigation,
};
}, tunerCommitTarget);
check(
@@ -3183,7 +3183,7 @@ async function main() {
y: rect.top + rect.height / 2,
ratio,
usableWidth: rect.width - 14,
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
};
});
const microDragSamples = [];
@@ -3199,7 +3199,7 @@ async function main() {
pixelDelta,
slot: Number(slider.value),
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
};
}, delta));
}
@@ -3238,9 +3238,9 @@ async function main() {
const gev = window.__godsEyeView;
const style = gev.styleManager;
const before = {
signature: style._radioTunerBandSignature,
ids: style._radioTunerStations.map((station) => station.id),
selectedIndex: style._radioTunerStations.findIndex((station) => station.id === selectedId),
signature: style._radioControls._radioTunerBandSignature,
ids: style._radioControls._radioTunerStations.map((station) => station.id),
selectedIndex: style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId),
needle: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
};
const current = gev.viewer.camera.positionCartographic;
@@ -3256,11 +3256,11 @@ async function main() {
});
gev.viewer.camera.changed.raiseEvent();
await new Promise((resolve) => setTimeout(resolve, 400));
const stations = gev.styleManager._radioTunerStations;
const stations = gev.styleManager._radioControls._radioTunerStations;
const selectedIndex = stations.findIndex((station) => station.id === selectedId);
return {
before,
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
ids: stations.map((station) => station.id),
count: stations.length,
selectedIndex,
@@ -3279,14 +3279,14 @@ async function main() {
const nextNeedleBefore = await page.evaluate(() => {
const style = window.__godsEyeView.styleManager;
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId);
const selectedPoolIndex = style._radioTunerPool.findIndex((station) => station.id === selectedId);
const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioTunerPool.length;
const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId);
const selectedPoolIndex = style._radioControls._radioTunerPool.findIndex((station) => station.id === selectedId);
const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioControls._radioTunerPool.length;
return {
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
selectedIndex,
count: style._radioTunerStations.length,
expectedId: style._radioTunerPool[expectedPoolIndex]?.id || null,
count: style._radioControls._radioTunerStations.length,
expectedId: style._radioControls._radioTunerPool[expectedPoolIndex]?.id || null,
};
});
await page.$eval('#radio-next-btn', (button) => button.click());
@@ -3294,15 +3294,15 @@ async function main() {
const nextNeedleAfter = await page.evaluate(() => {
const style = window.__godsEyeView.styleManager;
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId);
const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId);
return {
signature: style._radioTunerBandSignature,
signature: style._radioControls._radioTunerBandSignature,
selectedId,
selectedIndex,
count: style._radioTunerStations.length,
count: style._radioControls._radioTunerStations.length,
sliderSlot: Number(document.getElementById('radio-tuner-slider').value),
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
pinned: style._radioTunerBandPinnedForNavigation,
pinned: style._radioControls._radioTunerBandPinnedForNavigation,
};
});
check(
@@ -3327,8 +3327,8 @@ async function main() {
const markerId = () => gev.viewer.entities.values
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
const startIndex = Number(slider.value);
const expectedIndex = Math.min(style._radioTunerStations.length - 1, startIndex + 1);
const expectedId = style._radioTunerStations[expectedIndex]?.id || null;
const expectedIndex = Math.min(style._radioControls._radioTunerStations.length - 1, startIndex + 1);
const expectedId = style._radioControls._radioTunerStations[expectedIndex]?.id || null;
const playsBefore = window.__qaRadioPlayCalls.length;
key('keydown', 'ArrowRight');
const preview = {
@@ -3353,7 +3353,7 @@ async function main() {
tuningActive: radio.getUIState().tuningActive,
playDelta: window.__qaRadioPlayCalls.length - committedPlays,
};
const count = style._radioTunerStations.length;
const count = style._radioControls._radioTunerStations.length;
const pageStep = Math.max(1, Math.round((count - 1) / 10));
const previewAndCancel = (value, expectedSlot) => {
const beforePlays = window.__qaRadioPlayCalls.length;
@@ -3405,7 +3405,7 @@ async function main() {
const radio = gev.dataManager.layers.get('radio').module;
const slider = document.getElementById('radio-tuner-slider');
const rect = slider.getBoundingClientRect();
const count = style._radioTunerStations.length;
const count = style._radioControls._radioTunerStations.length;
const centerIndex = Math.floor((count - 1) / 2 + 0.5);
const clientX = rect.left + rect.width / 2;
slider.dispatchEvent(new PointerEvent('pointerdown', {
@@ -3425,7 +3425,7 @@ async function main() {
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
markerId: gev.viewer.entities.values
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null,
expectedId: style._radioTunerStations[centerIndex]?.id || null,
expectedId: style._radioControls._radioTunerStations[centerIndex]?.id || null,
};
slider.dispatchEvent(new PointerEvent('pointercancel', {
bubbles: true,
@@ -3450,7 +3450,7 @@ async function main() {
const fullPoolNavigation = await page.evaluate(() => {
const gev = window.__godsEyeView;
const radio = gev.dataManager.layers.get('radio').module;
const pool = gev.styleManager._radioTunerPool;
const pool = gev.styleManager._radioControls._radioTunerPool;
const ids = pool.map((station) => station.id);
const selectWithoutPlayback = (index) => radio.selectStation(ids[index], {
autoplay: false,
@@ -3500,7 +3500,7 @@ async function main() {
const gev = window.__godsEyeView;
const slider = document.getElementById('radio-tuner-slider');
const rect = slider.getBoundingClientRect();
const count = gev.styleManager._radioTunerStations.length;
const count = gev.styleManager._radioControls._radioTunerStations.length;
const current = Number(slider.value);
const target = Math.min(count - 1, current + 4);
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, count - 1);
@@ -3695,7 +3695,7 @@ async function main() {
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
const pool = gev.styleManager._radioTunerPool;
const pool = gev.styleManager._radioControls._radioTunerPool;
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
window.__qaRadioExpectedFallback = pool[(selectedIndex + 2) % pool.length];
+24 -26
View File
@@ -1,3 +1,6 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
@@ -248,8 +251,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, \.42\)/);
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-launcher\s*\{[\s\S]*?border:\s*0;[\s\S]*?background:\s*transparent;[\s\S]*?box-shadow:\s*none;/);
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, \.22\);/);
assert.match(ui, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
assert.match(ui, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
assert.match(radioBindings, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
assert.match(radioBindings, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
assert.match(css, /#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/);
assert.doesNotMatch(ui, /--cockpit-display-tab-width/);
// These two widths size Cockpit Radio and nothing else. They were named for
@@ -307,7 +310,7 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
assert.match(ui, /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
assert.match(ui, /!this\._preservePanelStateDuringLayerClear[\s\S]*?this\.setPanelCollapsed\('radio-panel', true\)/);
assert.match(radioPresentation, /!this\.actions\.preservePanelStateDuringClear\(\)[\s\S]*?this\.actions\.setPanelCollapsed\('radio-panel', true\)/);
assert.match(ui, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
});
@@ -320,16 +323,13 @@ test('Cockpit Display portal retains both scroll owners across round trips', ()
});
test('Cockpit side surfaces behave as two single-expanded accordions', () => {
assert.match(
ui,
/if \(displayOpen \|\| radioOpen\) this\.cockpitView\?\.setSignalCollapsed\(true\);/,
assert.match(radioBindings,
/if \(displayOpen \|\| radioOpen\) this\.actions\.setSignalCollapsed\(true\);/,
);
assert.match(
ui,
assert.match(radioBindings,
/'gev:cockpit-signal-expanded'[\s\S]*?setCockpitDisclosure\('display', false\);/,
);
assert.match(
ui,
assert.match(radioBindings,
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
);
assert.match(
@@ -341,13 +341,11 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
/nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
'closing Data Layers must restore Contact only after an automatic collapse',
);
assert.match(
ui,
assert.match(ui,
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
'Contact expansion must notify only on a collapsed-to-expanded transition',
);
assert.match(
ui,
assert.match(ui,
/const wasCollapsed = this\.signalCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.signalCollapsed[\s\S]*?'gev:cockpit-signal-expanded'/,
'Live Signals expansion must notify only on a collapsed-to-expanded transition',
);
@@ -356,12 +354,12 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
/setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/,
);
assert.match(
ui,
/!displayOpen && !radioOpen[\s\S]*?this\.cockpitView\?\.active[\s\S]*?!this\.cockpitView\.signalUserCollapsed[\s\S]*?setSignalCollapsed\(false\)/,
radioBindings,
/!displayOpen && !radioOpen[\s\S]*?this\.actions\.isCockpitActive\(\)[\s\S]*?!this\.actions\.signalUserCollapsed\(\)[\s\S]*?setSignalCollapsed\(false\)/,
'Live Signals should reopen only after both utility panels close and no manual collapse is retained',
);
assert.match(
ui,
radioBindings,
/event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\) return;[\s\S]*?setCockpitDisclosure\('display', false\);/,
'left-side interactions must not collapse the independent Cockpit utilities',
);
@@ -505,18 +503,18 @@ test('Location navigation releases immediate routes before flight and deferred r
});
test('Cockpit Radio station changes preserve first-person camera ownership', () => {
const cycleHelper = ui.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/);
const cycleHelper = radioBindings.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/);
assert.ok(cycleHelper, 'shared Radio cycle helper is missing');
assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/);
assert.match(ui, /_radioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1\)\)/);
assert.match(ui, /_contextRadioMiniNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1\)\)/);
assert.match(radioBindings, /listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/);
assert.match(radioBindings, /listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/);
assert.match(
ui,
/_cockpitRadioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/,
radioBindings,
/listen\(this\._cockpitRadioPrevBtn, 'click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/,
);
assert.match(
ui,
/_cockpitRadioNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/,
radioBindings,
/listen\(this\._cockpitRadioNextBtn, 'click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/,
);
});
@@ -653,9 +651,9 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1);
assert.match(html, /data-cockpit-display-slot="detection"[\s\S]*?data-cockpit-display-slot="parameters"[\s\S]*?data-cockpit-display-slot="models3d"/);
assert.match(ui, /_revealStyleParameters\(\) \{[\s\S]*?if \(this\._cockpitDisplayPortalActive\) return;/);
assert.match(ui, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
assert.match(radioBindings, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
assert.match(ui, /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/);
assert.match(ui, /if \(displayOpen\) this\._revealCockpitStyleParameters\(\);/);
assert.match(radioBindings, /if \(displayOpen\) this\.actions\.revealStyleParameters\(\);/);
assert.match(ui, /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/);
assert.match(css, /\.cockpit-display-slot\s*\{[\s\S]*?display:\s*block;[\s\S]*?min-width:\s*0;/);
assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{\s*width:\s*100%/);
+14 -9
View File
@@ -1,3 +1,7 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
// Source-contract pins for StyleManager's Context-session handler ordering.
// ui.js cannot be imported headlessly (it touches the DOM at module scope), so
// these read the shipped source — the idiom established by cockpitMarkup /
@@ -173,23 +177,24 @@ test('every user-facing Context exit route settles through the failure surface',
});
test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => {
const radioControls = src.slice(
src.indexOf('const toggleRadio = async (trigger) => {'),
src.indexOf("this._radioFilter?.addEventListener('change'"),
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
);
assert.match(radioControls, /await this\._runUserFacingContextAction\(/);
assert.match(radioControls, /await this\.actions\.runUserAction\(/);
assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/);
assert.match(radioControls, /if \(toggled === false\) return/);
assert.match(radioControls, /if \(this\.destroyed \|\| toggled === false\) return/);
});
test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => {
const radioControls = src.slice(
src.indexOf('const toggleRadio = async (trigger) => {'),
src.indexOf("this._radioFilter?.addEventListener('change'"),
const radioControls = radioBindings.slice(
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
);
assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/);
assert.match(radioControls, /if \(revealAfterEnable\) await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
assert.equal((src.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 2);
assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1);
assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/);
});
test('right-rail context entry is transactional: activation result gates the mode', () => {
+6 -8
View File
@@ -1,3 +1,7 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
@@ -136,14 +140,8 @@ test('Context async action buttons remain focused while busy', () => {
ui.indexOf('_initGlobalContextPanel() {'),
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
);
const radio = ui.slice(
ui.indexOf('const toggleRadio = async (trigger) => {'),
ui.indexOf('this._contextRadioToggleBtn?.addEventListener', ui.indexOf('const toggleRadio = async (trigger) => {')),
);
const radioSync = ui.slice(
ui.indexOf('_renderRadioState(state) {'),
ui.indexOf('if (this._radioFilter)', ui.indexOf('_renderRadioState(state) {')),
);
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
const clear = ui.slice(
ui.indexOf('clearSelectedLayers() {'),
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
+1 -87
View File
@@ -1,3 +1,4 @@
export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from '../ui/radioTunerModel.js';
/**
* Internet-radio station directory and direct-playback layer.
*
@@ -945,93 +946,6 @@ export function radioStationIdFromPick(picked) {
return null;
}
/** Map an integer tuner slot directly to one available directory station. */
export function radioTunerSlot(value, stationCount) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count) return { slot: 0, max: 0, locked: false, stationIndex: -1, leftIndex: -1, rightIndex: -1 };
const max = Math.max(0, count - 1);
const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0)));
return {
slot,
max,
locked: true,
stationIndex: slot,
leftIndex: slot,
rightIndex: slot,
};
}
/** Snap a tuner release to the nearest available directory station. */
export function radioTunerCommitSlot(value, stationCount) {
return radioTunerSlot(value, stationCount);
}
/** Map one pointer coordinate to continuous absolute directory progress. */
export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 };
if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 };
const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2);
const ratio = Math.min(1, Math.max(0, ((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth));
const coordinate = ratio * (count - 1);
return {
ratio,
coordinate,
stationIndex: Math.min(count - 1, Math.max(0, Math.floor(coordinate + 0.5))),
};
}
/** Build a bounded virtual tuner tape around one continuous directory coordinate. */
export function buildRadioTunerTicks(coordinate, stationCount, width, {
insetPx = 7,
minPitchPx = 14,
speedFactor = 5,
overscan = 2,
labelStep = 6,
} = {}) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
const dialWidth = Math.max(0, Number(width) || 0);
const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(0, dialWidth - inset * 2);
if (!count) return { ticks: [], needleX: inset, pitchPx: 0, ratio: 0 };
const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0));
if (count === 1) {
return {
ticks: [{ stationIndex: 0, channel: 1, xPx: inset + usableWidth / 2, current: true, label: '01' }],
needleX: inset + usableWidth / 2,
pitchPx: Math.max(1, Number(minPitchPx) || 14),
ratio: 0.5,
};
}
const directoryStep = usableWidth / (count - 1);
const pitchPx = Math.max(
Math.max(1, Number(minPitchPx) || 14),
directoryStep * Math.max(1, Number(speedFactor) || 5),
);
const needleX = inset + directoryStep * value;
const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0);
const first = Math.max(0, Math.ceil(value + (-overscanPx - needleX) / pitchPx));
const last = Math.min(count - 1, Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx));
const currentIndex = Math.min(count - 1, Math.max(0, Math.floor(value + 0.5)));
const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6));
const labelWidth = Math.max(2, String(count).length);
const ticks = [];
for (let stationIndex = first; stationIndex <= last; stationIndex += 1) {
const channel = stationIndex + 1;
const current = stationIndex === currentIndex;
const labelled = current || stationIndex === 0 || stationIndex === count - 1 || channel % majorEvery === 0;
ticks.push({
stationIndex,
channel,
xPx: needleX + pitchPx * (stationIndex - value),
current,
label: labelled ? String(channel).padStart(labelWidth, '0') : '',
});
}
return { ticks, needleX, pitchPx, ratio: value / (count - 1) };
}
/** Plan a station-centered camera move that preserves altitude and view angle. */
export function radioStationCameraPlan(station, cameraState = {}) {
const targetLat = Number(station?.lat);
+1 -1
View File
@@ -263,7 +263,7 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
});
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
// stopPropagation() does NOT stop later listeners on the SAME document, so the
+7 -3
View File
@@ -1,3 +1,7 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -155,16 +159,16 @@ test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit',
test('Cockpit utility Escape leaves an expanded nested Parameters panel to the shared handler', () => {
assert.match(
source,
radioBindings,
/const nestedPanel = event\.target\?\.closest\?\.\('\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)'\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/,
);
assert.match(
source,
radioBindings,
/const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/,
'Cockpit utility disclosures clear their own focus when Escape closes them',
);
assert.match(
source,
radioBindings,
/_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/,
'compact Radio disclosure clears its own focus when Escape closes it',
);
+30 -30
View File
@@ -1,3 +1,7 @@
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
@@ -249,19 +253,19 @@ test('Radio is nested inside Context with separate disclosure and power controls
assert.match(css, /--radio-broadcast-opacity: \.17/);
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.radio-tuner-needle,[\s\S]*?\.radio-tuner-tick\s*\{\s*transition: none;/);
assert.doesNotMatch(ui, /_radioTunerCameraRemove = this\.viewer\?\.camera\?\.changed/);
assert.match(ui, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/);
assert.match(ui, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/);
const cycleStart = ui.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>');
const cycleMethod = ui.slice(cycleStart, ui.indexOf('const toggleRadio', cycleStart));
assert.match(radioPresentation, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/);
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/);
const cycleStart = radioBindings.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>');
const cycleMethod = radioBindings.slice(cycleStart, radioBindings.indexOf('const toggleRadio', cycleStart));
assert.doesNotMatch(cycleMethod, /refreshTunerBand/);
assert.match(ui, /_radioTunerBandPinnedForNavigation = true/);
assert.match(ui, /viewer\?\.canvas\?\.addEventListener\('pointerdown', releaseNavigationBand/);
assert.match(ui, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/);
assert.match(ui, /tunerPreview\(\{ coordinate: this\._radioTunerCoordinate, rotate: commit \}\)/);
assert.match(ui, /radioLayer\.cancelTuning\(\)/);
assert.match(ui, /classList\.remove\('radio-broadcasting'\)/);
assert.match(ui, /radioLayer\.getTunerStations\(750\)/);
assert.match(ui, /radioTunerPointerPosition\(/);
assert.match(radioBindings, /_radioTunerBandPinnedForNavigation = true/);
assert.match(radioBindings, /listen\(this\.canvas, 'pointerdown', releaseNavigationBand/);
assert.match(radioBindings, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/);
assert.match(radioBindings, /tunerPreview\(\{ coordinate: this\._radioTunerCoordinate, rotate: commit \}\)/);
assert.match(radioBindings, /this\.radio\.cancelTuning\(\)/);
assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/);
assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/);
assert.match(radioBindings, /radioTunerPointerPosition\(/);
assert.doesNotMatch(css, /#right-context-rail\s*>\s*#radio-panel/);
assert.match(css, /#global-context-panel #radio-panel\.collapsed/);
assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/);
@@ -277,16 +281,15 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
assert.match(voice, /'radio'/);
assert.match(voice, /name:\s*'control_radio'/);
assert.deepEqual(realtimeTools().find(tool => tool.name === 'control_radio').parameters.properties.action.enum, ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status']);
const enableStart = ui.lastIndexOf('\n _initRadioPanel()');
const enableMethod = ui.slice(enableStart, ui.indexOf('\n _renderRadioState(state)', enableStart));
const enableMethod = radioBindings;
assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i);
assert.match(enableMethod, /contextRadioToggleBtn/);
assert.match(enableMethod, /contextRadioMiniEnableBtn/);
assert.match(enableMethod, /contextRadioDetailsBtn/);
assert.match(enableMethod, /contextRadioMiniCloseBtn/);
assert.match(enableMethod, /contextRadioMiniPlayBtn/);
const disclosureStart = enableMethod.indexOf('this._contextRadioToggleBtn?.addEventListener');
const disclosureEnd = enableMethod.indexOf("this._radioFilter?.addEventListener", disclosureStart);
const disclosureStart = enableMethod.indexOf('this.listen(this._contextRadioToggleBtn,');
const disclosureEnd = enableMethod.indexOf("this.listen(this._radioFilter,", disclosureStart);
const disclosureBindings = enableMethod.slice(disclosureStart, disclosureEnd);
assert.ok(disclosureStart >= 0 && disclosureEnd > disclosureStart, 'compact Radio disclosure bindings are missing');
assert.doesNotMatch(disclosureBindings, /toggleRadio\(this\._contextRadioToggleBtn\)/);
@@ -304,32 +307,29 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
});
test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => {
const revealStart = ui.indexOf('\n async _revealRadioPanelInsideContext');
const revealEnd = ui.indexOf('\n /**', revealStart + 10);
const revealMethod = ui.slice(revealStart, revealEnd);
const revealStart = radioControlsSource.indexOf('\n async _revealRadioPanelInsideContext');
const revealEnd = radioControlsSource.indexOf('\n /**', revealStart + 10);
const revealMethod = radioControlsSource.slice(revealStart, revealEnd);
assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing');
assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/);
assert.match(revealMethod, /scroller\.scrollTo\(\{ top: next, behavior: reducedMotion \? 'auto' : 'smooth' \}\)/);
assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/);
assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/);
const syncStart = ui.indexOf('\n _syncContextRadioLauncherState()');
const syncEnd = ui.indexOf('\n /**', syncStart + 10);
const syncMethod = ui.slice(syncStart, syncEnd);
const syncStart = radioControlsSource.indexOf('\n _syncContextRadioLauncherState()');
const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10);
const syncMethod = radioControlsSource.slice(syncStart, syncEnd);
assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing');
assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded', String\(radioExpanded\)/);
assert.match(syncMethod, /aria-controls', 'context-radio-mini'[\s\S]*?aria-expanded', String\(compactOpen\)/);
const renderStart = ui.indexOf('\n _renderRadioState(state)');
const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart));
const renderMethod = radioPresentation;
assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/);
assert.doesNotMatch(renderMethod, /compact Radio controls/);
assert.doesNotMatch(renderMethod, /_contextRadioToggleBtn\.setAttribute\('aria-(?:controls|expanded|label)'/);
});
test('Radio disclosure is explicit, starts closed while off, and preserves playback state', () => {
const renderStart = ui.indexOf('\n _renderRadioState(state)');
const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart));
assert.ok(renderStart >= 0, 'Radio render method is missing');
const renderMethod = radioPresentation;
assert.doesNotMatch(renderMethod, /setPanelCollapsed\('radio-panel', true\).*stopPlayback/s);
assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s);
assert.match(ui, /contextRadioDetailsBtn/);
@@ -346,8 +346,8 @@ test('successful explicit user playback hands the speaker from voice to Radio',
assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying);
assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/);
assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/);
assert.match(ui, /togglePlayback\(\{ origin: 'user' \}\)/);
assert.match(ui, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
assert.match(ui, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/);
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
assert.match(realtime, /event\.origin === 'user' && event\.action === 'play' && this\.isActive\(\)[\s\S]*?this\.stop\(\{ preserveRadioPlayback: true \}\)/);
});
+76 -864
View File
@@ -1,3 +1,4 @@
import { RadioControls } from './ui/radio.js';
import { LocationControls, LocationSearch } from './ui/location.js';
import { bindClearLayersControl } from './ui/layers.js';
import { createMapSourceControls } from './ui/mapSource.js';
@@ -61,12 +62,7 @@ import militaryFlightsLayer from './data/militaryFlights.js';
import { isTr3b, toggleTr3b } from './data/tr3bRegistry.js';
import satellitesLayer from './data/satellites.js';
import cctvLayer from './data/cctv.js';
import radioLayer, {
buildRadioTunerTicks,
radioTunerCommitSlot,
radioTunerPointerPosition,
radioTunerSlot,
} from './data/radio.js';
import radioLayer from './data/radio.js';
import bikeshareLayer from './data/bikeshare.js';
import aisLiveVesselsLayer from './data/aisLiveVessels.js';
import militaryAwarenessLayer from './data/militaryAwareness.js';
@@ -2089,25 +2085,6 @@ export class StyleManager {
this._clearSelectedLayersManagerPromise = null;
this._dataManager = null;
this._cctvUnsubscribe = null;
this._radioUnsubscribe = null;
this._radioState = null;
this._radioCategorySignature = '';
this._radioTunerStations = [];
this._radioTunerPool = [];
this._radioTunerDragging = false;
this._radioTunerDragStartSlot = 0;
this._radioTunerDragSnapshot = null;
this._radioTunerLastSlot = 0;
this._radioTunerDragDirection = 0;
this._radioTunerCoordinate = 0;
this._radioTunerPointerId = null;
this._radioTunerKeyboardKey = null;
this._radioTunerAbort = null;
this._radioTunerBandSignature = '';
this._radioTunerSelectedId = null;
this._radioTunerBandPinnedForNavigation = false;
this._radioTunerCameraRemove = null;
this._refreshRadioTunerBand = null;
this._cctvState = null;
this._cctvSummaryTypingTimer = null;
this._lastCctvSummaryText = '';
@@ -4208,15 +4185,7 @@ export class StyleManager {
if (typeof cctvLayer.getUIState === 'function') {
this._renderCctvState(cctvLayer.getUIState());
}
if (this._radioUnsubscribe) {
this._radioUnsubscribe();
this._radioUnsubscribe = null;
}
if (typeof radioLayer.subscribe === 'function') {
this._radioUnsubscribe = radioLayer.subscribe((state) => {
this._renderRadioState(state);
});
}
this._radioControls.connect();
if (!this._awarenessSelectedHandler) {
this._awarenessSelectedHandler = (event) => this._persistAwarenessSelection(event, false);
this._awarenessClearedHandler = (event) => this._persistAwarenessSelection(event, true);
@@ -5030,826 +4999,83 @@ export class StyleManager {
/** Wire the independent Radio companion controls. */
_initRadioPanel() {
if (!this._radioPanel) return;
this._radioTunerAbort?.abort();
this._radioTunerAbort = new AbortController();
const tunerListenerOptions = { signal: this._radioTunerAbort.signal };
const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => {
const open = Boolean(expanded);
this._contextRadioDock?.classList.toggle('disclosure-open', open);
if (this._contextRadioMini) this._contextRadioMini.hidden = !open;
this._syncContextRadioLauncherState();
if (!open && returnFocus) this._contextRadioToggleBtn?.focus({ preventScroll: true });
};
this._setRadioDisclosure = setRadioDisclosure;
const setCockpitDisclosure = (kind, expanded, { returnFocus = false } = {}) => {
const displayOpen = kind === 'display' && Boolean(expanded);
const radioOpen = kind === 'radio' && Boolean(expanded);
if (displayOpen || radioOpen) this.cockpitView?.setSignalCollapsed(true);
if (this._cockpitDisplayPanel) this._cockpitDisplayPanel.hidden = !displayOpen;
if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen;
this._cockpitDisplayToggleBtn?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', displayOpen);
this._cockpitRadioToggleBtn?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', radioOpen);
this._cockpitDisplayToggleBtn?.setAttribute('aria-expanded', String(displayOpen));
this._cockpitRadioToggleBtn?.setAttribute('aria-expanded', String(radioOpen));
if (displayOpen) this._revealCockpitStyleParameters();
if (this._cockpitDisplayToggleBtn) {
const action = displayOpen ? 'Collapse' : 'Expand';
this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀';
this._cockpitDisplayToggleBtn.setAttribute('aria-label', `${action} Cockpit display options`);
this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`;
}
if (this._cockpitRadioToggleBtn) {
const action = radioOpen ? 'Collapse' : 'Expand';
this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀';
this._cockpitRadioToggleBtn.setAttribute('aria-label', `${action} Cockpit Radio controls`);
this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`;
}
if (!expanded && returnFocus) {
(kind === 'display' ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn)
?.focus({ preventScroll: true });
}
if (!displayOpen && !radioOpen
&& this.cockpitView?.active
&& !this.cockpitView.signalUserCollapsed) {
this.cockpitView.setSignalCollapsed(false);
}
this.cockpitView?.scheduleContextLayout();
};
this._setCockpitDisclosure = setCockpitDisclosure;
const syncTunerTape = (coordinate) => {
const scale = this._radioTuner?.querySelector('.radio-tuner-scale');
const dial = this._radioTuner?.querySelector('.radio-tuner-dial');
if (!scale || !dial) return null;
const model = buildRadioTunerTicks(
coordinate,
this._radioTunerStations.length,
dial.getBoundingClientRect().width,
);
while (scale.children.length < model.ticks.length) {
const tick = document.createElement('span');
tick.className = 'radio-tuner-tick';
scale.append(tick);
}
while (scale.children.length > model.ticks.length) scale.lastElementChild?.remove();
model.ticks.forEach((entry, index) => {
const tick = scale.children[index];
tick.style.left = `${entry.xPx}px`;
tick.textContent = entry.label;
tick.dataset.stationIndex = String(entry.stationIndex);
tick.classList.toggle('is-current', entry.current);
});
scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`);
return model;
};
const tunerPreview = ({ coordinate = this._radioTunerCoordinate, syncStatic = true, rotate = true } = {}) => {
const slot = radioTunerSlot(this._radioTunerSlider?.value, this._radioTunerStations.length);
const station = slot.locked ? this._radioTunerStations[slot.stationIndex] || null : null;
const resolvedCoordinate = this._radioTunerStations.length <= 1
? 0
: Math.min(this._radioTunerStations.length - 1, Math.max(0, Number(coordinate) || 0));
const ratio = this._radioTunerStations.length === 1
? 0.5
: resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1);
this._radioTunerCoordinate = resolvedCoordinate;
this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio));
this._radioTuner?.classList.toggle('is-static', syncStatic ? false : Boolean(this._radioState?.tuningStatic));
syncTunerTape(resolvedCoordinate);
if (this._radioTunerValue) {
this._radioTunerValue.textContent = station
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}`
: 'NO STATIONS';
}
if (this._radioTunerStation) this._radioTunerStation.textContent = station?.name || 'NO STATION AVAILABLE';
if (this._radioTunerSlider) {
this._radioTunerSlider.setAttribute('aria-valuetext', station
? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}`
: 'No station available');
}
if (syncStatic) radioLayer.previewTuningStation(station?.id || null, { rotate });
return station;
};
const setTunerDirectory = (pool) => {
this._radioTunerPool = [...pool];
this._radioTunerStations = [...pool];
this._radioTunerBandSignature = this._radioTunerStations.map((station) => station.id).join('|');
if (this._radioTunerSlider) {
this._radioTunerSlider.min = '0';
this._radioTunerSlider.max = String(Math.max(0, this._radioTunerStations.length - 1));
this._radioTunerSlider.step = '1';
}
};
const refreshTunerBand = ({ force = false } = {}) => {
if (this._radioTunerDragging || this._radioTuner?.hidden || this._radioTunerSlider?.disabled) return false;
const selectedId = this._radioState?.selected?.id || null;
const pool = radioLayer.getTunerStations(750);
const poolSignature = pool.map((station) => station.id).join('|');
const currentPoolSignature = this._radioTunerPool.map((station) => station.id).join('|');
if (!force && poolSignature === currentPoolSignature && selectedId === this._radioTunerSelectedId) return false;
setTunerDirectory(pool);
this._radioTunerSelectedId = selectedId;
const selectedPoolIndex = pool.findIndex((station) => station.id === selectedId);
const slot = radioTunerSlot(selectedPoolIndex >= 0 ? selectedPoolIndex : 0, this._radioTunerStations.length);
this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false });
return true;
};
this._refreshRadioTunerBand = refreshTunerBand;
const beginTuner = () => {
if (this._radioTunerDragging || this._radioTunerSlider?.disabled) return false;
refreshTunerBand();
if (!this._radioTunerStations.length || !radioLayer.beginTuning()) return false;
// A tuner-owned camera preview must never replace the frozen directory.
// Only an explicit globe pointer/wheel gesture releases camera pinning.
this._radioTunerBandPinnedForNavigation = true;
this._radioTunerDragging = true;
this._radioTuner?.classList.add('is-dragging');
const selectedIndex = this._radioTunerStations.findIndex((station) => station.id === this._radioState?.selected?.id);
const slot = radioTunerSlot(selectedIndex >= 0 ? selectedIndex : 0, this._radioTunerStations.length);
this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
this._radioTunerDragStartSlot = slot.slot;
this._radioTunerDragSnapshot = {
stations: [...this._radioTunerStations],
bandSignature: this._radioTunerBandSignature,
selectedId: this._radioTunerSelectedId,
slot: slot.slot,
coordinate: this._radioTunerCoordinate,
};
this._radioTunerLastSlot = slot.slot;
this._radioTunerDragDirection = 0;
tunerPreview({ coordinate: this._radioTunerCoordinate });
return true;
};
const finishTuner = (commit) => {
if (!this._radioTunerDragging) return;
const dragSnapshot = this._radioTunerDragSnapshot;
if (!commit && dragSnapshot) {
this._radioTunerStations = [...dragSnapshot.stations];
this._radioTunerPool = [...dragSnapshot.stations];
this._radioTunerBandSignature = dragSnapshot.bandSignature;
this._radioTunerSelectedId = dragSnapshot.selectedId;
if (this._radioTunerSlider) {
const startSlot = radioTunerSlot(dragSnapshot.slot, this._radioTunerStations.length);
this._radioTunerSlider.max = String(startSlot.max);
this._radioTunerSlider.value = String(startSlot.slot);
}
this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate)
? dragSnapshot.coordinate
: dragSnapshot.slot;
} else if (!commit && this._radioTunerSlider) {
this._radioTunerSlider.value = String(this._radioTunerDragStartSlot);
this._radioTunerCoordinate = this._radioTunerDragStartSlot;
}
let station = tunerPreview({ coordinate: this._radioTunerCoordinate, rotate: commit });
if (commit && !station && this._radioTunerSlider) {
const snapped = radioTunerCommitSlot(
this._radioTunerSlider.value,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(snapped.slot);
this._radioTunerCoordinate = snapped.stationIndex;
station = tunerPreview({ coordinate: this._radioTunerCoordinate });
}
let result = null;
if (commit && station) {
// Keep the exact band used by the drag so the selected channel cannot
// jump to a refreshed catalog slot while its camera flight settles.
this._radioTunerBandPinnedForNavigation = true;
result = radioLayer.commitTuningStation(station.id, { origin: 'user' });
} else if (!commit) {
radioLayer.cancelTuning();
} else {
radioLayer.endTuning();
}
// Radio emits selection/tuning state synchronously. Keep both the logical
// drag and the no-transition class active until that state has settled,
// then restore the exact selected slot before permitting CSS motion.
this._radioTunerDragging = false;
this._radioTunerPointerId = null;
this._radioTunerKeyboardKey = null;
if (commit && (!result || result.ok)) refreshTunerBand();
this._radioTunerDragSnapshot = null;
// Flush the snapped position while transitions are still disabled so
// removing the drag class cannot interpolate from the released gap.
void this._radioTunerNeedle?.offsetLeft;
this._radioTuner?.classList.remove('is-dragging');
if (result && !result.ok) {
this._radioTunerBandPinnedForNavigation = false;
if (result.reason === 'station-unavailable') {
if (this._radioTunerValue) this._radioTunerValue.textContent = 'OFF AIR';
if (this._radioTunerStation) this._radioTunerStation.textContent = 'STATION UNAVAILABLE';
this._radioTunerSlider?.setAttribute(
'aria-valuetext',
'Station unavailable after directory refresh',
);
}
}
};
const cycleRadio = (direction, { rotate = true } = {}) => {
this._radioTunerBandPinnedForNavigation = true;
const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations;
const cycled = radioLayer.cycleStation(direction, {
rotate,
stationIds: pool.map((station) => station.id),
origin: 'user',
});
if (!cycled) {
this._radioTunerBandPinnedForNavigation = false;
return;
}
};
const toggleRadio = async (trigger) => {
if (!this._dataManager?.layers?.has('radio')) return;
if (trigger.getAttribute('aria-busy') === 'true') return;
const enabling = !this._dataManager.isEnabled('radio');
const revealAfterEnable = enabling && trigger === this._radioEnableBtn;
trigger.setAttribute('aria-disabled', 'true');
trigger.setAttribute('aria-busy', 'true');
try {
const toggled = await this._runUserFacingContextAction(
(notificationToken) => this._dataManager.setEnabled('radio', enabling, {
origin: 'user',
notificationToken,
}),
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
);
if (toggled === false) return;
if (enabling && trigger === this._radioEnableBtn
&& !document.getElementById('global-context-panel')?.classList.contains('collapsed')) {
this.setPanelCollapsed('radio-panel', false, { explicit: true });
}
if (revealAfterEnable) await this._revealRadioControlsAfterExplicitEnable(trigger);
} finally {
trigger.setAttribute('aria-disabled', 'false');
trigger.setAttribute('aria-busy', 'false');
if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true });
}
};
this._radioEnableBtn?.addEventListener('click', () => void toggleRadio(this._radioEnableBtn));
this._contextRadioMiniEnableBtn?.addEventListener('click', () => void toggleRadio(this._contextRadioMiniEnableBtn));
this._cockpitRadioEnableBtn?.addEventListener('click', () => void toggleRadio(this._cockpitRadioEnableBtn));
this._contextRadioToggleBtn?.addEventListener('click', () => {
const contextPanel = document.getElementById('global-context-panel');
if (contextPanel && !contextPanel.classList.contains('collapsed')) {
setRadioDisclosure(false);
this.setPanelCollapsed('radio-panel', false, { explicit: true });
void this._revealRadioPanelInsideContext({
focusTarget: this._radioPanel?.querySelector('[data-collapse-target="radio-panel"]'),
});
return;
}
setRadioDisclosure(!this._contextRadioDock?.classList.contains('disclosure-open'));
this._radioControls?.destroy();
this._radioControls = new RadioControls({
elements: {
_cockpitDisplayPanel: this._cockpitDisplayPanel,
_cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn,
_cockpitRadioEnableBtn: this._cockpitRadioEnableBtn,
_cockpitRadioNextBtn: this._cockpitRadioNextBtn,
_cockpitRadioPanel: this._cockpitRadioPanel,
_cockpitRadioPlayBtn: this._cockpitRadioPlayBtn,
_cockpitRadioPrevBtn: this._cockpitRadioPrevBtn,
_cockpitRadioStation: this._cockpitRadioStation,
_cockpitRadioToggleBtn: this._cockpitRadioToggleBtn,
_cockpitRadioVolume: this._cockpitRadioVolume,
_cockpitRadioVolumeValue: this._cockpitRadioVolumeValue,
_cockpitUtilityControls: this._cockpitUtilityControls,
_contextRadioDetailsBtn: this._contextRadioDetailsBtn,
_contextRadioDock: this._contextRadioDock,
_contextRadioMini: this._contextRadioMini,
_contextRadioMiniCloseBtn: this._contextRadioMiniCloseBtn,
_contextRadioMiniEnableBtn: this._contextRadioMiniEnableBtn,
_contextRadioMiniNextBtn: this._contextRadioMiniNextBtn,
_contextRadioMiniPlayBtn: this._contextRadioMiniPlayBtn,
_contextRadioMiniPrevBtn: this._contextRadioMiniPrevBtn,
_contextRadioMiniStation: this._contextRadioMiniStation,
_contextRadioMiniVolume: this._contextRadioMiniVolume,
_contextRadioMiniVolumeValue: this._contextRadioMiniVolumeValue,
_contextRadioToggleBtn: this._contextRadioToggleBtn,
_radioEnableBtn: this._radioEnableBtn,
_radioFilter: this._radioFilter,
_radioLayerState: this._radioLayerState,
_radioNextBtn: this._radioNextBtn,
_radioPanel: this._radioPanel,
_radioPlayBtn: this._radioPlayBtn,
_radioPlaybackState: this._radioPlaybackState,
_radioPrevBtn: this._radioPrevBtn,
_radioStationHomepage: this._radioStationHomepage,
_radioStationMeta: this._radioStationMeta,
_radioStationName: this._radioStationName,
_radioStationTags: this._radioStationTags,
_radioStopBtn: this._radioStopBtn,
_radioTuner: this._radioTuner,
_radioTunerBandLabel: this._radioTunerBandLabel,
_radioTunerNeedle: this._radioTunerNeedle,
_radioTunerSlider: this._radioTunerSlider,
_radioTunerStation: this._radioTunerStation,
_radioTunerValue: this._radioTunerValue,
_radioVolume: this._radioVolume,
_radioVolumeValue: this._radioVolumeValue,
},
radio: radioLayer,
canvas: this.viewer?.canvas,
actions: {
isRegistered: () => this._dataManager?.layers?.has('radio'),
isEnabled: () => this._dataManager?.isEnabled('radio'),
setEnabled: (enabled, options) => this._dataManager.setEnabled('radio', enabled, options),
setParams: (params, options) => this._dataManager?.setLayerParams('radio', params, options),
getLifecycle: () => this._dataManager?.getLayerLifecycleState?.('radio'),
runUserAction: (...args) => this._runUserFacingContextAction(...args),
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
revealStyleParameters: () => this._revealCockpitStyleParameters(),
setSignalCollapsed: (value) => this.cockpitView?.setSignalCollapsed(value),
isCockpitActive: () => this.cockpitView?.active,
signalUserCollapsed: () => this.cockpitView?.signalUserCollapsed,
layoutCockpit: () => this.cockpitView?.scheduleContextLayout(),
preservePanelStateDuringClear: () => this._preservePanelStateDuringLayerClear,
scheduleLayout: () => this._scheduleRightPanelLayout(),
},
});
this._contextRadioMiniCloseBtn?.addEventListener('click', () => {
setRadioDisclosure(false, { returnFocus: true });
});
this._contextRadioDetailsBtn?.addEventListener('click', () => {
if (!this.cockpitView?.active) this.setPanelCollapsed('global-context-panel', false, { explicit: true });
this.setPanelCollapsed('radio-panel', false, { explicit: true });
setRadioDisclosure(false);
this._radioEnableBtn?.focus({ preventScroll: true });
});
this._cockpitRadioToggleBtn?.addEventListener('click', () => {
const open = this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true';
setCockpitDisclosure('radio', !open);
});
document.addEventListener('pointerdown', (event) => {
if (!this._contextRadioDock?.classList.contains('disclosure-open')) return;
if (event.target?.closest?.('#context-radio-dock')) return;
setRadioDisclosure(false);
}, tunerListenerOptions);
document.addEventListener('pointerdown', (event) => {
if (!this._cockpitUtilityControls || event.target?.closest?.('#cockpit-utility-controls')) return;
if (event.target?.closest?.('.cockpit-vision-controls')) return;
if (event.target?.closest?.('#left-panel-stack, #cockpit-context')) return;
setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false);
}, tunerListenerOptions);
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !this._contextRadioDock?.classList.contains('disclosure-open')) return;
event.preventDefault();
// Immediate: a plain stopPropagation() still lets every LATER listener on
// this same document run, so closing the disclosure ALSO dismissed the
// first-run launcher — one key, two actions. Matches the cockpit
// disclosure handler directly below.
event.stopImmediatePropagation();
const escapedFromDisclosure = event.target === this._contextRadioToggleBtn
|| this._contextRadioToggleBtn?.contains?.(event.target);
setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.();
}, { capture: true, signal: this._radioTunerAbort.signal });
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true';
const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true';
if (!displayOpen && !radioOpen) return;
const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)');
if (nestedPanel) return;
event.preventDefault();
event.stopImmediatePropagation();
const kind = displayOpen ? 'display' : 'radio';
const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn;
const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target);
setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) disclosure?.blur?.();
}, { capture: true, signal: this._radioTunerAbort.signal });
window.addEventListener('gev:cockpit-mode-changed', (event) => {
if (event?.detail?.active) return;
setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false);
}, tunerListenerOptions);
window.addEventListener('gev:cockpit-signal-expanded', () => {
setCockpitDisclosure('display', false);
}, tunerListenerOptions);
window.addEventListener('gev:cockpit-context-expanded', () => {
this.setPanelCollapsed('data-panel', true);
}, tunerListenerOptions);
this._radioFilter?.addEventListener('change', () => {
const presentation = radioLayer.getUIState();
if (!presentation.presentationActive) {
this._radioFilter.value = presentation.filter;
return;
}
if (this._radioTunerDragging) finishTuner(false);
if (!this._dataManager?.setLayerParams('radio', {
filter: this._radioFilter.value,
}, { origin: 'user' })) {
this._radioFilter.value = radioLayer.getUIState().filter;
return;
}
this._radioTunerBandPinnedForNavigation = false;
this._radioTunerPool = [];
refreshTunerBand({ force: true });
});
this._radioPrevBtn?.addEventListener('click', () => cycleRadio(-1));
this._radioNextBtn?.addEventListener('click', () => cycleRadio(1));
this._radioPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' }));
this._radioStopBtn?.addEventListener('click', () => radioLayer.stopPlayback({ origin: 'user' }));
this._radioVolume?.addEventListener('input', () => {
const value = Number(this._radioVolume.value);
if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${value}%`;
this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' });
});
this._contextRadioMiniPrevBtn?.addEventListener('click', () => cycleRadio(-1));
this._contextRadioMiniNextBtn?.addEventListener('click', () => cycleRadio(1));
this._contextRadioMiniPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' }));
// Cockpit owns the Cesium camera even though it intentionally clears
// viewer.trackedEntity. Station changes must never start the map-view
// rotation/fallback flights that would compete with its preUpdate pose.
this._cockpitRadioPrevBtn?.addEventListener('click', () => cycleRadio(-1, { rotate: false }));
this._cockpitRadioNextBtn?.addEventListener('click', () => cycleRadio(1, { rotate: false }));
this._cockpitRadioPlayBtn?.addEventListener('click', () => void radioLayer.togglePlayback({ origin: 'user' }));
this._contextRadioMiniVolume?.addEventListener('input', () => {
const value = Number(this._contextRadioMiniVolume.value);
if (this._contextRadioMiniVolumeValue) this._contextRadioMiniVolumeValue.textContent = `${value}%`;
this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' });
});
this._cockpitRadioVolume?.addEventListener('input', () => {
const value = Number(this._cockpitRadioVolume.value);
if (this._cockpitRadioVolumeValue) this._cockpitRadioVolumeValue.textContent = `${value}%`;
this._dataManager?.setLayerParams('radio', { volume: value / 100 }, { origin: 'user' });
});
const updateTunerFromPointer = (event) => {
const rect = this._radioTunerSlider?.getBoundingClientRect();
if (!rect || !this._radioTunerStations.length) return false;
const position = radioTunerPointerPosition(
event.clientX,
rect.left,
rect.width,
this._radioTunerStations.length,
);
if (position.stationIndex > this._radioTunerLastSlot) this._radioTunerDragDirection = 1;
else if (position.stationIndex < this._radioTunerLastSlot) this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = position.stationIndex;
this._radioTunerCoordinate = position.coordinate;
this._radioTunerSlider.value = String(position.stationIndex);
tunerPreview({ coordinate: position.coordinate });
return true;
};
this._radioTunerSlider?.addEventListener('pointerdown', (event) => {
if (!beginTuner()) return;
this._radioTunerPointerId = event.pointerId;
this._radioTunerSlider.focus({ preventScroll: true });
try { this._radioTunerSlider.setPointerCapture(event.pointerId); } catch { /* capture is best effort */ }
updateTunerFromPointer(event);
event.preventDefault();
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('pointermove', (event) => {
if (!this._radioTunerDragging || this._radioTunerPointerId !== event.pointerId) return;
updateTunerFromPointer(event);
event.preventDefault();
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('input', () => {
if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) return;
if (!this._radioTunerDragging && !beginTuner()) return;
const inputSlot = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length);
if (inputSlot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1;
else if (inputSlot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = inputSlot.slot;
this._radioTunerCoordinate = inputSlot.stationIndex;
tunerPreview({ coordinate: this._radioTunerCoordinate });
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('change', () => {
if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey) finishTuner(true);
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('pointerup', (event) => {
if (this._radioTunerPointerId !== event.pointerId) return;
updateTunerFromPointer(event);
finishTuner(true);
try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ }
event.preventDefault();
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('pointercancel', (event) => {
if (this._radioTunerPointerId !== null && this._radioTunerPointerId !== event.pointerId) return;
try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ }
finishTuner(false);
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('lostpointercapture', (event) => {
if (this._radioTunerDragging && this._radioTunerPointerId === event.pointerId) finishTuner(false);
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && this._radioTunerDragging) {
event.preventDefault();
finishTuner(false);
return;
}
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return;
if (!this._radioTunerDragging && !beginTuner()) return;
event.preventDefault();
this._radioTunerKeyboardKey = event.key;
const max = Math.max(0, this._radioTunerStations.length - 1);
const current = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length).slot;
const page = Math.max(1, Math.round(max / 10));
const next = event.key === 'Home' ? 0
: event.key === 'End' ? max
: event.key === 'PageUp' ? current + page
: event.key === 'PageDown' ? current - page
: current + (event.key === 'ArrowRight' ? 1 : -1);
const slot = radioTunerSlot(next, this._radioTunerStations.length);
if (slot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1;
else if (slot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = slot.slot;
this._radioTunerCoordinate = slot.stationIndex;
this._radioTunerSlider.value = String(slot.slot);
tunerPreview({ coordinate: this._radioTunerCoordinate });
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('keyup', (event) => {
if (!this._radioTunerKeyboardKey || event.key !== this._radioTunerKeyboardKey) return;
event.preventDefault();
finishTuner(true);
}, tunerListenerOptions);
this._radioTunerSlider?.addEventListener('blur', () => finishTuner(true), tunerListenerOptions);
const releaseNavigationBand = () => {
this._radioTunerBandPinnedForNavigation = false;
};
this.viewer?.canvas?.addEventListener('pointerdown', releaseNavigationBand, tunerListenerOptions);
this.viewer?.canvas?.addEventListener('wheel', releaseNavigationBand, tunerListenerOptions);
// The directory order is catalog/filter authority, not camera authority.
// Globe motion therefore never rebuilds or re-ranks the frequency band.
this._radioTunerCameraRemove?.();
this._radioTunerCameraRemove = null;
this._radioSelectedHandler = () => this.setPanelCollapsed('radio-panel', false);
document.addEventListener('gev:radio-selected', this._radioSelectedHandler);
}
/**
* Reveal the newly enabled directory and transport inside Context without
* moving focus, the page, or the globe. Only the expanded Enable path calls
* this helper.
* @param {HTMLElement} trigger Initiating Radio Enable button.
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioControlsAfterExplicitEnable(trigger) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
const directory = this._radioPanel?.querySelector('.radio-directory-row');
const transport = this._radioPanel?.querySelector('.radio-transport');
if (!contextPanel || contextPanel.classList.contains('collapsed')
|| !scroller || !directory || !transport || !this._radioState?.enabled) return false;
_setCockpitDisclosure(...args) { return this._radioControls?._setCockpitDisclosure?.(...args); }
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
if (!this._radioState?.enabled || !trigger?.isConnected) return false;
_setRadioDisclosure(...args) { return this._radioControls?._setRadioDisclosure?.(...args); }
const viewport = scroller.getBoundingClientRect();
const directoryRect = directory.getBoundingClientRect();
const transportRect = transport.getBoundingClientRect();
const margin = 10;
const minimum = scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin);
const maximum = scroller.scrollTop + directoryRect.top - (viewport.top + margin);
const desired = minimum <= maximum
? Math.min(Math.max(scroller.scrollTop, minimum), maximum)
: minimum;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
if (Math.abs(next - scroller.scrollTop) < 1) return false;
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' });
return true;
}
_syncContextRadioLauncherState(...args) { return this._radioControls?._syncContextRadioLauncherState?.(...args); }
/**
* Bring the embedded Radio section into the expanded Context scroller.
* This never changes Radio power, playback, selection, or Context mode.
* @param {{focusTarget?: HTMLElement|null}} [options]
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
if (!contextPanel || contextPanel.classList.contains('collapsed')
|| !scroller || !this._radioPanel || this._radioPanel.classList.contains('collapsed')) return false;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
if (contextPanel.classList.contains('collapsed') || this._radioPanel.classList.contains('collapsed')) return false;
const viewport = scroller.getBoundingClientRect();
const radioRect = this._radioPanel.getBoundingClientRect();
const desired = scroller.scrollTop + radioRect.top - viewport.top - 10;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
const moved = Math.abs(next - scroller.scrollTop) >= 1;
if (moved) {
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' });
}
focusTarget?.focus?.({ preventScroll: true });
return moved;
}
/** Keep the Context header Radio shortcut truthful for its current route. */
_syncContextRadioLauncherState() {
if (!this._contextRadioToggleBtn) return;
const contextPanel = document.getElementById('global-context-panel');
const contextExpanded = Boolean(contextPanel && !contextPanel.classList.contains('collapsed'));
if (contextExpanded) {
const radioExpanded = Boolean(this._radioPanel && !this._radioPanel.classList.contains('collapsed'));
this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel');
this._contextRadioToggleBtn.setAttribute('aria-expanded', String(radioExpanded));
const label = radioExpanded ? 'Go to expanded Radio section' : 'Expand Radio section in Context';
this._contextRadioToggleBtn.setAttribute('aria-label', label);
this._contextRadioToggleBtn.title = label;
return;
}
const compactOpen = Boolean(this._contextRadioDock?.classList.contains('disclosure-open'));
this._contextRadioToggleBtn.setAttribute('aria-controls', 'context-radio-mini');
this._contextRadioToggleBtn.setAttribute('aria-expanded', String(compactOpen));
const action = compactOpen ? 'Close' : 'Open';
this._contextRadioToggleBtn.setAttribute('aria-label', `${action} compact Radio controls`);
this._contextRadioToggleBtn.title = `${action} compact Radio controls`;
}
/** Render Radio state without making playback or Context decisions. */
_renderRadioState(state) {
if (!state || !this._radioPanel) return;
const lifecycle = this._dataManager?.getLayerLifecycleState?.('radio') || null;
const lifecycleState = lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled');
state = {
...state,
enabled: lifecycle ? lifecycle.enabled : state.enabled,
lifecycleState,
lifecycleUncertain: lifecycle?.uncertain || false,
};
this._radioState = state;
const enabled = Boolean(state.enabled);
const transitioning = lifecycleState === 'enabling' || lifecycleState === 'disabling';
const uncertain = Boolean(state.lifecycleUncertain);
const interactive = enabled && !transitioning && !uncertain;
const selected = state.selected || null;
const hasStations = state.filteredCount > 0;
const activePlayback = ['playing', 'buffering'].includes(state.audioState);
document.getElementById('title-bar')?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
this._radioPanel.classList.toggle('radio-enabled', enabled);
this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain);
this._contextRadioDock?.classList.toggle('active', enabled);
if (this._contextRadioToggleBtn) {
this._contextRadioToggleBtn.classList.toggle('active', enabled);
}
this._syncContextRadioLauncherState();
this._radioLayerState?.classList.toggle('active', enabled);
if (this._radioLayerState) {
this._radioLayerState.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'UNCERTAIN' : (state.loading ? 'SYNC' : (enabled ? `${state.filteredCount}/${state.stationCount}` : 'OFF')));
}
if (this._radioEnableBtn) {
this._radioEnableBtn.classList.toggle('active', enabled);
this._radioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._radioEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE'));
this._radioEnableBtn.setAttribute(
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._radioEnableBtn.disabled = false;
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._contextRadioMiniEnableBtn) {
this._contextRadioMiniEnableBtn.classList.toggle('active', enabled);
this._contextRadioMiniEnableBtn.setAttribute('aria-pressed', String(enabled));
this._contextRadioMiniEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE'));
this._contextRadioMiniEnableBtn.setAttribute(
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._contextRadioMiniEnableBtn.disabled = false;
this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._cockpitRadioEnableBtn) {
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._cockpitRadioEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE'));
this._cockpitRadioEnableBtn.setAttribute(
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._cockpitRadioEnableBtn.disabled = false;
this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._radioFilter) {
const prior = state.filter || 'all';
const categorySignature = state.categories
.map((category) => `${category.id}:${category.count}:${category.color}`)
.join('|');
if (categorySignature !== this._radioCategorySignature) {
this._radioFilter.replaceChildren(...state.categories.map((category) => {
const option = document.createElement('option');
option.value = category.id;
option.textContent = `● ${category.label} (${category.count})`;
option.dataset.radioColor = category.color;
option.style.color = category.color;
option.setAttribute('aria-label', `${category.label} (${category.count})`);
return option;
}));
this._radioCategorySignature = categorySignature;
}
this._radioFilter.value = prior;
const activeCategory = state.categories.find((category) => category.id === prior);
this._radioFilter.style.color = activeCategory?.color || '';
this._radioFilter.disabled = !interactive || !state.stationCount;
}
const tunerAvailable = interactive && state.filteredCount > 0;
if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable;
if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable;
if (this._radioTunerBandLabel) {
const activeCategory = state.categories.find((category) => category.id === state.filter);
this._radioTunerBandLabel.textContent = state.filter === 'all'
? 'DIRECTORY BAND'
: `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`;
}
this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic));
if (tunerAvailable) this._refreshRadioTunerBand?.();
if (!tunerAvailable && this._radioTunerDragging) {
this._radioTunerDragging = false;
this._radioTunerDragSnapshot = null;
this._radioTunerStations = [];
this._radioTuner?.classList.remove('is-static', 'is-dragging');
}
if (!tunerAvailable) {
this._radioTunerStations = [];
this._radioTunerPool = [];
this._radioTunerBandSignature = '';
this._radioTunerSelectedId = null;
}
if (this._radioStationName) this._radioStationName.textContent = selected?.name || 'NO STATION SELECTED';
if (this._radioStationMeta) {
const place = selected ? [selected.state, selected.countryCode].filter(Boolean).join(' · ') : '';
const signal = selected ? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : ''].filter(Boolean).join(' · ') : '';
this._radioStationMeta.textContent = selected
? [place, signal].filter(Boolean).join(' / ') || 'Directory metadata only'
: (state.loading ? 'Loading station directory…' : 'Choose a globe marker or use next.');
}
if (this._radioStationTags) {
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
this._radioStationTags.textContent = tags.length ? `TAGS · ${tags.join(' · ')}` : '';
}
if (this._radioStationHomepage) {
const homepage = selected?.homepage || '';
this._radioStationHomepage.hidden = !homepage;
if (homepage) this._radioStationHomepage.href = homepage;
else this._radioStationHomepage.removeAttribute('href');
}
if (this._radioPrevBtn) this._radioPrevBtn.disabled = !interactive || !hasStations;
if (this._radioNextBtn) this._radioNextBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniPrevBtn) this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniNextBtn) this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioPrevBtn) this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioNextBtn) this._cockpitRadioNextBtn.disabled = !interactive || !hasStations;
if (this._radioPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play');
this._radioPlayBtn.disabled = !interactive || !hasStations;
this._radioPlayBtn.classList.toggle('active', activePlayback);
this._radioPlayBtn.textContent = action.toUpperCase();
this._radioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`);
}
if (this._contextRadioMiniPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play');
this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations;
this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback);
this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._contextRadioMiniPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`);
this._contextRadioMiniPlayBtn.title = action;
}
if (this._cockpitRadioPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play');
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._cockpitRadioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`);
this._cockpitRadioPlayBtn.title = action;
}
if (this._radioStopBtn) this._radioStopBtn.disabled = !interactive || state.audioState === 'stopped';
if (this._radioVolume) this._radioVolume.disabled = !interactive;
if (this._radioVolume && document.activeElement !== this._radioVolume) {
this._radioVolume.value = String(Math.round(state.volume * 100));
if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._contextRadioMiniVolume && document.activeElement !== this._contextRadioMiniVolume) {
this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100));
}
if (this._contextRadioMiniVolume) this._contextRadioMiniVolume.disabled = !interactive;
if (this._contextRadioMiniVolumeValue) {
this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._cockpitRadioVolume && document.activeElement !== this._cockpitRadioVolume) {
this._cockpitRadioVolume.value = String(Math.round(state.volume * 100));
}
if (this._cockpitRadioVolume) this._cockpitRadioVolume.disabled = !interactive;
if (this._cockpitRadioVolumeValue) {
this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._contextRadioMiniStation) {
this._contextRadioMiniStation.textContent = uncertain
? 'RADIO STATE UNCERTAIN'
: (selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY'));
}
if (this._cockpitRadioStation) {
this._cockpitRadioStation.textContent = uncertain
? 'UNCERTAIN'
: (selected?.name || (state.loading ? 'SYNCING' : 'READY'));
}
if (this._radioPlaybackState) {
const catalogSuffix = state.degraded
? (state.stale ? ' · stale/degraded directory' : ' · degraded directory')
: (state.stale ? ' · stale directory' : '');
const outsideFilter = selected && state.selectedIndex < 0 ? ' · outside current filter' : '';
const messages = {
stopped: enabled ? 'Ready — playback starts only from your action' : 'Radio off',
loading: 'Connecting directly to broadcaster…',
buffering: 'Buffering broadcaster stream…',
playing: `Playing ${selected?.name || 'station'}`,
paused: `Paused ${selected?.name || 'station'}`,
error: state.audioError || 'Broadcaster stream unavailable',
};
const voiceSuffix = state.voiceDucked
? ' · muted during voice interaction'
: (state.voiceRestoring ? ' · restoring volume after voice' : '');
const tuningSuffix = state.tuningAwaitingStationId
? (state.audioState === 'error'
? ' · static indicates no broadcaster audio'
: ' · tuning static until broadcaster starts')
: '';
const unavailable = state.tuningUnavailableStationId
? 'Station unavailable after directory refresh — choose another channel'
: null;
const lifecycleMessage = transitioning
? (lifecycleState === 'enabling' ? 'Radio is enabling…' : 'Radio is disabling…')
: null;
const uncertainMessage = uncertain
? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile'
: null;
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
this._radioPlaybackState.classList.toggle('error', Boolean(uncertainMessage || unavailable || state.error || state.audioState === 'error'));
}
if (
!enabled
&& !transitioning
&& !this._preservePanelStateDuringLayerClear
&& !this._radioPanel.classList.contains('collapsed')
) {
this.setPanelCollapsed('radio-panel', true);
}
this._scheduleRightPanelLayout();
}
_renderRadioState(...args) { return this._radioControls?._renderRadioState?.(...args); }
/**
* Activates an explicit CCTV target, then releases tracking before its camera
@@ -9282,6 +8508,7 @@ export class StyleManager {
this._mapSourceControls?.destroy();
this._clearLayersControl?.destroy();
this._locationControls?.destroy();
this._radioControls?.destroy();
this._visualEffects.stop();
this._styleParameters?.destroy();
for (const control of this._panelDisclosureControls || []) control.destroy();
@@ -9419,22 +8646,7 @@ export class StyleManager {
window.removeEventListener('gev:cockpit-mode-changed', this._leftStackCockpitModeHandler);
this._leftStackCockpitModeHandler = null;
}
this._radioUnsubscribe?.();
this._radioUnsubscribe = null;
this._radioTunerAbort?.abort();
this._radioTunerAbort = null;
this._radioTunerBandPinnedForNavigation = false;
this._radioTunerDragSnapshot = null;
this._radioTunerPool = [];
this._radioTunerCameraRemove?.();
this._radioTunerCameraRemove = null;
this._refreshRadioTunerBand = null;
document.getElementById('title-bar')?.classList.remove('radio-broadcasting');
radioLayer.endTuning();
if (this._radioSelectedHandler) {
document.removeEventListener('gev:radio-selected', this._radioSelectedHandler);
this._radioSelectedHandler = null;
}
destroyTrackedReadout();
destroyDetection();
destroyWorldOverlay();
+2
View File
@@ -0,0 +1,2 @@
export { RadioControls } from './radioControls.js';
export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js';
+488
View File
@@ -0,0 +1,488 @@
import { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js';
/** Bind Radio input using a RadioControls receiver and supplied actions. */
export function bindRadioControls() {
if (!this._radioPanel) return;
const tunerListenerOptions = {};
const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => {
const open = Boolean(expanded);
this._contextRadioDock?.classList.toggle('disclosure-open', open);
if (this._contextRadioMini) this._contextRadioMini.hidden = !open;
this._syncContextRadioLauncherState();
if (!open && returnFocus) this._contextRadioToggleBtn?.focus({ preventScroll: true });
};
this._setRadioDisclosure = setRadioDisclosure;
const setCockpitDisclosure = (kind, expanded, { returnFocus = false } = {}) => {
const displayOpen = kind === 'display' && Boolean(expanded);
const radioOpen = kind === 'radio' && Boolean(expanded);
if (displayOpen || radioOpen) this.actions.setSignalCollapsed(true);
if (this._cockpitDisplayPanel) this._cockpitDisplayPanel.hidden = !displayOpen;
if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen;
this._cockpitDisplayToggleBtn?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', displayOpen);
this._cockpitRadioToggleBtn?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', radioOpen);
this._cockpitDisplayToggleBtn?.setAttribute('aria-expanded', String(displayOpen));
this._cockpitRadioToggleBtn?.setAttribute('aria-expanded', String(radioOpen));
if (displayOpen) this.actions.revealStyleParameters();
if (this._cockpitDisplayToggleBtn) {
const action = displayOpen ? 'Collapse' : 'Expand';
this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀';
this._cockpitDisplayToggleBtn.setAttribute('aria-label', `${action} Cockpit display options`);
this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`;
}
if (this._cockpitRadioToggleBtn) {
const action = radioOpen ? 'Collapse' : 'Expand';
this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀';
this._cockpitRadioToggleBtn.setAttribute('aria-label', `${action} Cockpit Radio controls`);
this._cockpitRadioToggleBtn.title = `${action} Cockpit Radio controls`;
}
if (!expanded && returnFocus) {
(kind === 'display' ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn)
?.focus({ preventScroll: true });
}
if (!displayOpen && !radioOpen
&& this.actions.isCockpitActive()
&& !this.actions.signalUserCollapsed()) {
this.actions.setSignalCollapsed(false);
}
this.actions.layoutCockpit();
};
this._setCockpitDisclosure = setCockpitDisclosure;
const syncTunerTape = (coordinate) => {
const scale = this._radioTuner?.querySelector('.radio-tuner-scale');
const dial = this._radioTuner?.querySelector('.radio-tuner-dial');
if (!scale || !dial) return null;
const model = buildRadioTunerTicks(
coordinate,
this._radioTunerStations.length,
dial.getBoundingClientRect().width,
);
while (scale.children.length < model.ticks.length) {
const tick = document.createElement('span');
tick.className = 'radio-tuner-tick';
scale.append(tick);
}
while (scale.children.length > model.ticks.length) scale.lastElementChild?.remove();
model.ticks.forEach((entry, index) => {
const tick = scale.children[index];
tick.style.left = `${entry.xPx}px`;
tick.textContent = entry.label;
tick.dataset.stationIndex = String(entry.stationIndex);
tick.classList.toggle('is-current', entry.current);
});
scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`);
return model;
};
const tunerPreview = ({ coordinate = this._radioTunerCoordinate, syncStatic = true, rotate = true } = {}) => {
const slot = radioTunerSlot(this._radioTunerSlider?.value, this._radioTunerStations.length);
const station = slot.locked ? this._radioTunerStations[slot.stationIndex] || null : null;
const resolvedCoordinate = this._radioTunerStations.length <= 1
? 0
: Math.min(this._radioTunerStations.length - 1, Math.max(0, Number(coordinate) || 0));
const ratio = this._radioTunerStations.length === 1
? 0.5
: resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1);
this._radioTunerCoordinate = resolvedCoordinate;
this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio));
this._radioTuner?.classList.toggle('is-static', syncStatic ? false : Boolean(this._radioState?.tuningStatic));
syncTunerTape(resolvedCoordinate);
if (this._radioTunerValue) {
this._radioTunerValue.textContent = station
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}`
: 'NO STATIONS';
}
if (this._radioTunerStation) this._radioTunerStation.textContent = station?.name || 'NO STATION AVAILABLE';
if (this._radioTunerSlider) {
this._radioTunerSlider.setAttribute('aria-valuetext', station
? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}`
: 'No station available');
}
if (syncStatic) this.radio.previewTuningStation(station?.id || null, { rotate });
return station;
};
const setTunerDirectory = (pool) => {
this._radioTunerPool = [...pool];
this._radioTunerStations = [...pool];
this._radioTunerBandSignature = this._radioTunerStations.map((station) => station.id).join('|');
if (this._radioTunerSlider) {
this._radioTunerSlider.min = '0';
this._radioTunerSlider.max = String(Math.max(0, this._radioTunerStations.length - 1));
this._radioTunerSlider.step = '1';
}
};
const refreshTunerBand = ({ force = false } = {}) => {
if (this._radioTunerDragging || this._radioTuner?.hidden || this._radioTunerSlider?.disabled) return false;
const selectedId = this._radioState?.selected?.id || null;
const pool = this.radio.getTunerStations(750);
const poolSignature = pool.map((station) => station.id).join('|');
const currentPoolSignature = this._radioTunerPool.map((station) => station.id).join('|');
if (!force && poolSignature === currentPoolSignature && selectedId === this._radioTunerSelectedId) return false;
setTunerDirectory(pool);
this._radioTunerSelectedId = selectedId;
const selectedPoolIndex = pool.findIndex((station) => station.id === selectedId);
const slot = radioTunerSlot(selectedPoolIndex >= 0 ? selectedPoolIndex : 0, this._radioTunerStations.length);
this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false });
return true;
};
this._refreshRadioTunerBand = refreshTunerBand;
const beginTuner = () => {
if (this._radioTunerDragging || this._radioTunerSlider?.disabled) return false;
refreshTunerBand();
if (!this._radioTunerStations.length || !this.radio.beginTuning()) return false;
// A tuner-owned camera preview must never replace the frozen directory.
// Only an explicit globe pointer/wheel gesture releases camera pinning.
this._radioTunerBandPinnedForNavigation = true;
this._radioTunerDragging = true;
this._radioTuner?.classList.add('is-dragging');
const selectedIndex = this._radioTunerStations.findIndex((station) => station.id === this._radioState?.selected?.id);
const slot = radioTunerSlot(selectedIndex >= 0 ? selectedIndex : 0, this._radioTunerStations.length);
this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
this._radioTunerDragStartSlot = slot.slot;
this._radioTunerDragSnapshot = {
stations: [...this._radioTunerStations],
bandSignature: this._radioTunerBandSignature,
selectedId: this._radioTunerSelectedId,
slot: slot.slot,
coordinate: this._radioTunerCoordinate,
};
this._radioTunerLastSlot = slot.slot;
this._radioTunerDragDirection = 0;
tunerPreview({ coordinate: this._radioTunerCoordinate });
return true;
};
const finishTuner = (commit) => {
if (!this._radioTunerDragging) return;
const dragSnapshot = this._radioTunerDragSnapshot;
if (!commit && dragSnapshot) {
this._radioTunerStations = [...dragSnapshot.stations];
this._radioTunerPool = [...dragSnapshot.stations];
this._radioTunerBandSignature = dragSnapshot.bandSignature;
this._radioTunerSelectedId = dragSnapshot.selectedId;
if (this._radioTunerSlider) {
const startSlot = radioTunerSlot(dragSnapshot.slot, this._radioTunerStations.length);
this._radioTunerSlider.max = String(startSlot.max);
this._radioTunerSlider.value = String(startSlot.slot);
}
this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate)
? dragSnapshot.coordinate
: dragSnapshot.slot;
} else if (!commit && this._radioTunerSlider) {
this._radioTunerSlider.value = String(this._radioTunerDragStartSlot);
this._radioTunerCoordinate = this._radioTunerDragStartSlot;
}
let station = tunerPreview({ coordinate: this._radioTunerCoordinate, rotate: commit });
if (commit && !station && this._radioTunerSlider) {
const snapped = radioTunerCommitSlot(
this._radioTunerSlider.value,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(snapped.slot);
this._radioTunerCoordinate = snapped.stationIndex;
station = tunerPreview({ coordinate: this._radioTunerCoordinate });
}
let result = null;
if (commit && station) {
// Keep the exact band used by the drag so the selected channel cannot
// jump to a refreshed catalog slot while its camera flight settles.
this._radioTunerBandPinnedForNavigation = true;
result = this.radio.commitTuningStation(station.id, { origin: 'user' });
} else if (!commit) {
this.radio.cancelTuning();
} else {
this.radio.endTuning();
}
// Radio emits selection/tuning state synchronously. Keep both the logical
// drag and the no-transition class active until that state has settled,
// then restore the exact selected slot before permitting CSS motion.
this._radioTunerDragging = false;
this._radioTunerPointerId = null;
this._radioTunerKeyboardKey = null;
if (commit && (!result || result.ok)) refreshTunerBand();
this._radioTunerDragSnapshot = null;
// Flush the snapped position while transitions are still disabled so
// removing the drag class cannot interpolate from the released gap.
void this._radioTunerNeedle?.offsetLeft;
this._radioTuner?.classList.remove('is-dragging');
if (result && !result.ok) {
this._radioTunerBandPinnedForNavigation = false;
if (result.reason === 'station-unavailable') {
if (this._radioTunerValue) this._radioTunerValue.textContent = 'OFF AIR';
if (this._radioTunerStation) this._radioTunerStation.textContent = 'STATION UNAVAILABLE';
this._radioTunerSlider?.setAttribute(
'aria-valuetext',
'Station unavailable after directory refresh',
);
}
}
};
const cycleRadio = (direction, { rotate = true } = {}) => {
this._radioTunerBandPinnedForNavigation = true;
const pool = this._radioTunerPool.length ? this._radioTunerPool : this._radioTunerStations;
const cycled = this.radio.cycleStation(direction, {
rotate,
stationIds: pool.map((station) => station.id),
origin: 'user',
});
if (!cycled) {
this._radioTunerBandPinnedForNavigation = false;
return;
}
};
const toggleRadio = async (trigger) => {
if (!this.actions.isRegistered()) return;
if (trigger.getAttribute('aria-busy') === 'true') return;
const enabling = !this.actions.isEnabled();
const revealAfterEnable = enabling && trigger === this._radioEnableBtn;
trigger.setAttribute('aria-disabled', 'true');
trigger.setAttribute('aria-busy', 'true');
try {
const toggled = await this.actions.runUserAction(
(notificationToken) => this.actions.setEnabled(enabling, {
origin: 'user',
notificationToken,
}),
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
);
if (this.destroyed || toggled === false) return;
if (enabling && trigger === this._radioEnableBtn
&& !document.getElementById('global-context-panel')?.classList.contains('collapsed')) {
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
}
if (revealAfterEnable) await this._revealRadioControlsAfterExplicitEnable(trigger);
} finally {
if (!this.destroyed) {
trigger.setAttribute('aria-disabled', 'false');
trigger.setAttribute('aria-busy', 'false');
if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true });
}
}
};
this.listen(this._radioEnableBtn, 'click', () => void toggleRadio(this._radioEnableBtn));
this.listen(this._contextRadioMiniEnableBtn, 'click', () => void toggleRadio(this._contextRadioMiniEnableBtn));
this.listen(this._cockpitRadioEnableBtn, 'click', () => void toggleRadio(this._cockpitRadioEnableBtn));
this.listen(this._contextRadioToggleBtn, 'click', () => {
const contextPanel = document.getElementById('global-context-panel');
if (contextPanel && !contextPanel.classList.contains('collapsed')) {
setRadioDisclosure(false);
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
void this._revealRadioPanelInsideContext({
focusTarget: this._radioPanel?.querySelector('[data-collapse-target="radio-panel"]'),
});
return;
}
setRadioDisclosure(!this._contextRadioDock?.classList.contains('disclosure-open'));
});
this.listen(this._contextRadioMiniCloseBtn, 'click', () => {
setRadioDisclosure(false, { returnFocus: true });
});
this.listen(this._contextRadioDetailsBtn, 'click', () => {
if (!this.actions.isCockpitActive()) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true });
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
setRadioDisclosure(false);
this._radioEnableBtn?.focus({ preventScroll: true });
});
this.listen(this._cockpitRadioToggleBtn, 'click', () => {
const open = this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true';
setCockpitDisclosure('radio', !open);
});
this.listen(document, 'pointerdown', (event) => {
if (!this._contextRadioDock?.classList.contains('disclosure-open')) return;
if (event.target?.closest?.('#context-radio-dock')) return;
setRadioDisclosure(false);
}, tunerListenerOptions);
this.listen(document, 'pointerdown', (event) => {
if (!this._cockpitUtilityControls || event.target?.closest?.('#cockpit-utility-controls')) return;
if (event.target?.closest?.('.cockpit-vision-controls')) return;
if (event.target?.closest?.('#left-panel-stack, #cockpit-context')) return;
setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false);
}, tunerListenerOptions);
this.listen(document, 'keydown', (event) => {
if (event.key !== 'Escape' || !this._contextRadioDock?.classList.contains('disclosure-open')) return;
event.preventDefault();
// Immediate: a plain stopPropagation() still lets every LATER listener on
// this same document run, so closing the disclosure ALSO dismissed the
// first-run launcher — one key, two actions. Matches the cockpit
// disclosure handler directly below.
event.stopImmediatePropagation();
const escapedFromDisclosure = event.target === this._contextRadioToggleBtn
|| this._contextRadioToggleBtn?.contains?.(event.target);
setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.();
}, { capture: true, });
this.listen(document, 'keydown', (event) => {
if (event.key !== 'Escape') return;
const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true';
const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true';
if (!displayOpen && !radioOpen) return;
const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)');
if (nestedPanel) return;
event.preventDefault();
event.stopImmediatePropagation();
const kind = displayOpen ? 'display' : 'radio';
const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn;
const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target);
setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) disclosure?.blur?.();
}, { capture: true, });
this.listen(window, 'gev:cockpit-mode-changed', (event) => {
if (event?.detail?.active) return;
setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false);
}, tunerListenerOptions);
this.listen(window, 'gev:cockpit-signal-expanded', () => {
setCockpitDisclosure('display', false);
}, tunerListenerOptions);
this.listen(window, 'gev:cockpit-context-expanded', () => {
this.actions.setPanelCollapsed('data-panel', true);
}, tunerListenerOptions);
this.listen(this._radioFilter, 'change', () => {
const presentation = this.radio.getUIState();
if (!presentation.presentationActive) {
this._radioFilter.value = presentation.filter;
return;
}
if (this._radioTunerDragging) finishTuner(false);
if (!this.actions.setParams({
filter: this._radioFilter.value,
}, { origin: 'user' })) {
this._radioFilter.value = this.radio.getUIState().filter;
return;
}
this._radioTunerBandPinnedForNavigation = false;
this._radioTunerPool = [];
refreshTunerBand({ force: true });
});
this.listen(this._radioPrevBtn, 'click', () => cycleRadio(-1));
this.listen(this._radioNextBtn, 'click', () => cycleRadio(1));
this.listen(this._radioPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' }));
this.listen(this._radioStopBtn, 'click', () => this.radio.stopPlayback({ origin: 'user' }));
this.listen(this._radioVolume, 'input', () => {
const value = Number(this._radioVolume.value);
if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
this.listen(this._contextRadioMiniPrevBtn, 'click', () => cycleRadio(-1));
this.listen(this._contextRadioMiniNextBtn, 'click', () => cycleRadio(1));
this.listen(this._contextRadioMiniPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' }));
// Cockpit owns the Cesium camera even though it intentionally clears
// viewer.trackedEntity. Station changes must never start the map-view
// rotation/fallback flights that would compete with its preUpdate pose.
this.listen(this._cockpitRadioPrevBtn, 'click', () => cycleRadio(-1, { rotate: false }));
this.listen(this._cockpitRadioNextBtn, 'click', () => cycleRadio(1, { rotate: false }));
this.listen(this._cockpitRadioPlayBtn, 'click', () => void this.radio.togglePlayback({ origin: 'user' }));
this.listen(this._contextRadioMiniVolume, 'input', () => {
const value = Number(this._contextRadioMiniVolume.value);
if (this._contextRadioMiniVolumeValue) this._contextRadioMiniVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
this.listen(this._cockpitRadioVolume, 'input', () => {
const value = Number(this._cockpitRadioVolume.value);
if (this._cockpitRadioVolumeValue) this._cockpitRadioVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
const updateTunerFromPointer = (event) => {
const rect = this._radioTunerSlider?.getBoundingClientRect();
if (!rect || !this._radioTunerStations.length) return false;
const position = radioTunerPointerPosition(
event.clientX,
rect.left,
rect.width,
this._radioTunerStations.length,
);
if (position.stationIndex > this._radioTunerLastSlot) this._radioTunerDragDirection = 1;
else if (position.stationIndex < this._radioTunerLastSlot) this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = position.stationIndex;
this._radioTunerCoordinate = position.coordinate;
this._radioTunerSlider.value = String(position.stationIndex);
tunerPreview({ coordinate: position.coordinate });
return true;
};
this.listen(this._radioTunerSlider, 'pointerdown', (event) => {
if (!beginTuner()) return;
this._radioTunerPointerId = event.pointerId;
this._radioTunerSlider.focus({ preventScroll: true });
try { this._radioTunerSlider.setPointerCapture(event.pointerId); } catch { /* capture is best effort */ }
updateTunerFromPointer(event);
event.preventDefault();
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'pointermove', (event) => {
if (!this._radioTunerDragging || this._radioTunerPointerId !== event.pointerId) return;
updateTunerFromPointer(event);
event.preventDefault();
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'input', () => {
if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey) return;
if (!this._radioTunerDragging && !beginTuner()) return;
const inputSlot = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length);
if (inputSlot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1;
else if (inputSlot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = inputSlot.slot;
this._radioTunerCoordinate = inputSlot.stationIndex;
tunerPreview({ coordinate: this._radioTunerCoordinate });
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'change', () => {
if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey) finishTuner(true);
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'pointerup', (event) => {
if (this._radioTunerPointerId !== event.pointerId) return;
updateTunerFromPointer(event);
finishTuner(true);
try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ }
event.preventDefault();
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'pointercancel', (event) => {
if (this._radioTunerPointerId !== null && this._radioTunerPointerId !== event.pointerId) return;
try { this._radioTunerSlider.releasePointerCapture(event.pointerId); } catch { /* already released */ }
finishTuner(false);
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'lostpointercapture', (event) => {
if (this._radioTunerDragging && this._radioTunerPointerId === event.pointerId) finishTuner(false);
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'keydown', (event) => {
if (event.key === 'Escape' && this._radioTunerDragging) {
event.preventDefault();
finishTuner(false);
return;
}
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return;
if (!this._radioTunerDragging && !beginTuner()) return;
event.preventDefault();
this._radioTunerKeyboardKey = event.key;
const max = Math.max(0, this._radioTunerStations.length - 1);
const current = radioTunerSlot(this._radioTunerSlider.value, this._radioTunerStations.length).slot;
const page = Math.max(1, Math.round(max / 10));
const next = event.key === 'Home' ? 0
: event.key === 'End' ? max
: event.key === 'PageUp' ? current + page
: event.key === 'PageDown' ? current - page
: current + (event.key === 'ArrowRight' ? 1 : -1);
const slot = radioTunerSlot(next, this._radioTunerStations.length);
if (slot.slot > this._radioTunerLastSlot) this._radioTunerDragDirection = 1;
else if (slot.slot < this._radioTunerLastSlot) this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = slot.slot;
this._radioTunerCoordinate = slot.stationIndex;
this._radioTunerSlider.value = String(slot.slot);
tunerPreview({ coordinate: this._radioTunerCoordinate });
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'keyup', (event) => {
if (!this._radioTunerKeyboardKey || event.key !== this._radioTunerKeyboardKey) return;
event.preventDefault();
finishTuner(true);
}, tunerListenerOptions);
this.listen(this._radioTunerSlider, 'blur', () => finishTuner(true), tunerListenerOptions);
const releaseNavigationBand = () => {
this._radioTunerBandPinnedForNavigation = false;
};
this.listen(this.canvas, 'pointerdown', releaseNavigationBand, tunerListenerOptions);
this.listen(this.canvas, 'wheel', releaseNavigationBand, tunerListenerOptions);
// The directory order is catalog/filter authority, not camera authority.
// Globe motion therefore never rebuilds or re-ranks the frequency band.
this._radioSelectedHandler = () => this.actions.setPanelCollapsed('radio-panel', false);
this.listen(document, 'gev:radio-selected', this._radioSelectedHandler);
}
+156
View File
@@ -0,0 +1,156 @@
import { bindRadioControls } from './radioBindings.js';
import { renderRadioState } from './radioPresentation.js';
/** Own Radio DOM, tuner interaction and subscription; receive playback and application actions. */
export class RadioControls {
constructor({ elements, radio, actions, canvas }) {
Object.assign(this, elements);
this.radio = radio;
this.actions = actions;
this.canvas = canvas;
this.destroyed = false;
this.listeners = new AbortController();
this._radioUnsubscribe = null;
this._radioState = null;
this._radioCategorySignature = '';
this._radioTunerStations = [];
this._radioTunerPool = [];
this._radioTunerDragging = false;
this._radioTunerDragStartSlot = 0;
this._radioTunerDragSnapshot = null;
this._radioTunerLastSlot = 0;
this._radioTunerDragDirection = 0;
this._radioTunerCoordinate = 0;
this._radioTunerPointerId = null;
this._radioTunerKeyboardKey = null;
this._radioTunerBandSignature = '';
this._radioTunerSelectedId = null;
this._radioTunerBandPinnedForNavigation = false;
this._refreshRadioTunerBand = null;
bindRadioControls.call(this);
}
listen(target, type, handler, options = {}) {
target?.addEventListener(type, handler, { ...options, signal: this.listeners.signal });
}
connect() {
this._radioUnsubscribe?.();
this._radioUnsubscribe = null;
if (this.destroyed) return;
this._radioUnsubscribe = this.radio.subscribe?.((state) => this._renderRadioState(state));
}
_renderRadioState(state) { renderRadioState.call(this, state); }
/**
* Reveal the newly enabled directory and transport inside Context without
* moving focus, the page, or the globe. Only the expanded Enable path calls
* this helper.
* @param {HTMLElement} trigger Initiating Radio Enable button.
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioControlsAfterExplicitEnable(trigger) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
const directory = this._radioPanel?.querySelector('.radio-directory-row');
const transport = this._radioPanel?.querySelector('.radio-transport');
if (!contextPanel || contextPanel.classList.contains('collapsed')
|| !scroller || !directory || !transport || !this._radioState?.enabled) return false;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
if (this.destroyed || !this._radioState?.enabled || !trigger?.isConnected) return false;
const viewport = scroller.getBoundingClientRect();
const directoryRect = directory.getBoundingClientRect();
const transportRect = transport.getBoundingClientRect();
const margin = 10;
const minimum = scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin);
const maximum = scroller.scrollTop + directoryRect.top - (viewport.top + margin);
const desired = minimum <= maximum
? Math.min(Math.max(scroller.scrollTop, minimum), maximum)
: minimum;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
if (Math.abs(next - scroller.scrollTop) < 1) return false;
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' });
return true;
}
/**
* Bring the embedded Radio section into the expanded Context scroller.
* This never changes Radio power, playback, selection, or Context mode.
* @param {{focusTarget?: HTMLElement|null}} [options]
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
if (!contextPanel || contextPanel.classList.contains('collapsed')
|| !scroller || !this._radioPanel || this._radioPanel.classList.contains('collapsed')) return false;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
if (this.destroyed || contextPanel.classList.contains('collapsed') || this._radioPanel.classList.contains('collapsed')) return false;
const viewport = scroller.getBoundingClientRect();
const radioRect = this._radioPanel.getBoundingClientRect();
const desired = scroller.scrollTop + radioRect.top - viewport.top - 10;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
const moved = Math.abs(next - scroller.scrollTop) >= 1;
if (moved) {
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
scroller.scrollTo({ top: next, behavior: reducedMotion ? 'auto' : 'smooth' });
}
focusTarget?.focus?.({ preventScroll: true });
return moved;
}
/** Keep the Context header Radio shortcut truthful for its current route. */
_syncContextRadioLauncherState() {
if (!this._contextRadioToggleBtn) return;
const contextPanel = document.getElementById('global-context-panel');
const contextExpanded = Boolean(contextPanel && !contextPanel.classList.contains('collapsed'));
if (contextExpanded) {
const radioExpanded = Boolean(this._radioPanel && !this._radioPanel.classList.contains('collapsed'));
this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel');
this._contextRadioToggleBtn.setAttribute('aria-expanded', String(radioExpanded));
const label = radioExpanded ? 'Go to expanded Radio section' : 'Expand Radio section in Context';
this._contextRadioToggleBtn.setAttribute('aria-label', label);
this._contextRadioToggleBtn.title = label;
return;
}
const compactOpen = Boolean(this._contextRadioDock?.classList.contains('disclosure-open'));
this._contextRadioToggleBtn.setAttribute('aria-controls', 'context-radio-mini');
this._contextRadioToggleBtn.setAttribute('aria-expanded', String(compactOpen));
const action = compactOpen ? 'Close' : 'Open';
this._contextRadioToggleBtn.setAttribute('aria-label', `${action} compact Radio controls`);
this._contextRadioToggleBtn.title = `${action} compact Radio controls`;
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.listeners.abort();
this._radioUnsubscribe?.();
this._radioUnsubscribe = null;
const pointer = this._radioTunerPointerId;
this._radioTunerPointerId = null;
this._radioTunerDragging = false;
this._radioTunerKeyboardKey = null;
this._radioTunerDragSnapshot = null;
this._radioTunerPool = [];
this._radioTunerStations = [];
this._radioTunerBandPinnedForNavigation = false;
try { if (pointer !== null) this._radioTunerSlider?.releasePointerCapture(pointer); } catch { /* capture already released */ }
this._radioTuner?.classList.remove('is-dragging', 'is-static');
document.getElementById('title-bar')?.classList.remove('radio-broadcasting');
this.radio.endTuning();
}
}
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { RadioControls } from './radioControls.js';
class Element extends EventTarget {
constructor() {
super();
this.attrs = new Map();
this.classes = new Set();
this.focusCount = 0;
this.isConnected = true;
this.classList = {
add: (...names) => names.forEach((n) => this.classes.add(n)),
remove: (...names) => names.forEach((n) => this.classes.delete(n)),
contains: (name) => this.classes.has(name),
toggle: (name, enabled) => enabled ? this.classes.add(name) : this.classes.delete(name),
};
}
setAttribute(name, value) { this.attrs.set(name, String(value)); }
getAttribute(name) { return this.attrs.get(name); }
querySelector() { return null; }
focus() { this.focusCount++; }
}
function fixture() {
const prior = { document: globalThis.document, window: globalThis.window };
const document = new EventTarget();
document.getElementById = () => null;
globalThis.document = document;
globalThis.window = new EventTarget();
const panel = new Element();
const enable = new Element();
const next = new Element();
const calls = [];
const subscribers = [];
const radio = {
subscribe(callback) { subscribers.push(callback); calls.push('subscribe'); return () => calls.push('unsubscribe'); },
endTuning() { calls.push('endTuning'); },
cycleStation(direction) { calls.push(['cycle', direction]); return true; },
};
const actions = {
isRegistered: () => true, isEnabled: () => false,
runUserAction: (operation) => operation(),
setEnabled: async () => true,
setPanelCollapsed: (...args) => calls.push(['panel', ...args]),
layoutCockpit() {}, isCockpitActive: () => false,
scheduleLayout() {}, getLifecycle: () => null,
preservePanelStateDuringClear: () => false,
};
const controls = new RadioControls({
elements: { _radioPanel: panel, _radioEnableBtn: enable, _radioNextBtn: next },
radio, actions, canvas: new Element(),
});
return { controls, enable, next, actions, calls, subscribers, document,
cleanup() { controls.destroy(); Object.assign(globalThis, prior); } };
}
test('Radio listeners and subscriptions are revoked once before tuning teardown', () => {
const f = fixture();
try {
f.controls.connect();
f.next.dispatchEvent(new Event('click'));
assert.deepEqual(f.calls, ['subscribe', ['cycle', 1]]);
f.controls.destroy(); f.controls.destroy();
const snapshot = structuredClone(f.calls);
f.next.dispatchEvent(new Event('click'));
f.document.dispatchEvent(new Event('gev:radio-selected'));
f.subscribers[0]({ enabled: true });
f.controls.connect();
assert.deepEqual(f.calls, snapshot);
assert.deepEqual(f.calls.slice(-2), ['unsubscribe', 'endTuning']);
} finally { f.cleanup(); }
});
test('Radio reconnect removes its previous state subscription', () => {
const f = fixture();
try {
f.controls.connect(); f.controls.connect();
assert.deepEqual(f.calls, ['subscribe', 'unsubscribe', 'subscribe']);
} finally { f.cleanup(); }
});
test('a delayed Radio enable cannot reveal or refocus controls after destruction', async () => {
const f = fixture();
let resolve;
const pending = new Promise((done) => { resolve = done; });
try {
f.actions.setEnabled = () => pending;
f.enable.dispatchEvent(new Event('click'));
assert.equal(f.enable.getAttribute('aria-busy'), 'true');
f.controls.destroy();
const attrs = new Map(f.enable.attrs);
resolve(true);
await pending; await new Promise((done) => setImmediate(done));
assert.deepEqual(f.enable.attrs, attrs);
assert.equal(f.enable.focusCount, 0);
assert.equal(f.calls.some((call) => Array.isArray(call) && call[0] === 'panel'), false);
} finally { f.cleanup(); }
});
test('destruction releases an active tuner pointer after revoking listeners', () => {
const f = fixture();
try {
const released = [];
f.controls._radioTunerSlider = { releasePointerCapture(id) {
assert.equal(f.controls.listeners.signal.aborted, true);
released.push(id);
} };
f.controls._radioTunerPointerId = 7;
f.controls._radioTunerDragging = true;
f.controls.destroy();
assert.deepEqual(released, [7]);
assert.equal(f.controls._radioTunerDragging, false);
assert.equal(f.controls._radioTunerPointerId, null);
} finally { f.cleanup(); }
});
+245
View File
@@ -0,0 +1,245 @@
/** Render Radio state without making playback or Context decisions. */
export function renderRadioState(state) {
if (this.destroyed || !state || !this._radioPanel) return;
const lifecycle = this.actions.getLifecycle() || null;
const lifecycleState = lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled');
state = {
...state,
enabled: lifecycle ? lifecycle.enabled : state.enabled,
lifecycleState,
lifecycleUncertain: lifecycle?.uncertain || false,
};
this._radioState = state;
const enabled = Boolean(state.enabled);
const transitioning = lifecycleState === 'enabling' || lifecycleState === 'disabling';
const uncertain = Boolean(state.lifecycleUncertain);
const interactive = enabled && !transitioning && !uncertain;
const selected = state.selected || null;
const hasStations = state.filteredCount > 0;
const activePlayback = ['playing', 'buffering'].includes(state.audioState);
document.getElementById('title-bar')?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
this._radioPanel.classList.toggle('radio-enabled', enabled);
this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain);
this._contextRadioDock?.classList.toggle('active', enabled);
if (this._contextRadioToggleBtn) {
this._contextRadioToggleBtn.classList.toggle('active', enabled);
}
this._syncContextRadioLauncherState();
this._radioLayerState?.classList.toggle('active', enabled);
if (this._radioLayerState) {
this._radioLayerState.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'UNCERTAIN' : (state.loading ? 'SYNC' : (enabled ? `${state.filteredCount}/${state.stationCount}` : 'OFF')));
}
if (this._radioEnableBtn) {
this._radioEnableBtn.classList.toggle('active', enabled);
this._radioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._radioEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE'));
this._radioEnableBtn.setAttribute(
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._radioEnableBtn.disabled = false;
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._contextRadioMiniEnableBtn) {
this._contextRadioMiniEnableBtn.classList.toggle('active', enabled);
this._contextRadioMiniEnableBtn.setAttribute('aria-pressed', String(enabled));
this._contextRadioMiniEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE'));
this._contextRadioMiniEnableBtn.setAttribute(
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._contextRadioMiniEnableBtn.disabled = false;
this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._cockpitRadioEnableBtn) {
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
this._cockpitRadioEnableBtn.textContent = transitioning
? lifecycleState.toUpperCase()
: (uncertain ? 'RECONCILE' : (enabled ? 'DISABLE' : 'ENABLE'));
this._cockpitRadioEnableBtn.setAttribute(
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._cockpitRadioEnableBtn.disabled = false;
this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._radioFilter) {
const prior = state.filter || 'all';
const categorySignature = state.categories
.map((category) => `${category.id}:${category.count}:${category.color}`)
.join('|');
if (categorySignature !== this._radioCategorySignature) {
this._radioFilter.replaceChildren(...state.categories.map((category) => {
const option = document.createElement('option');
option.value = category.id;
option.textContent = `● ${category.label} (${category.count})`;
option.dataset.radioColor = category.color;
option.style.color = category.color;
option.setAttribute('aria-label', `${category.label} (${category.count})`);
return option;
}));
this._radioCategorySignature = categorySignature;
}
this._radioFilter.value = prior;
const activeCategory = state.categories.find((category) => category.id === prior);
this._radioFilter.style.color = activeCategory?.color || '';
this._radioFilter.disabled = !interactive || !state.stationCount;
}
const tunerAvailable = interactive && state.filteredCount > 0;
if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable;
if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable;
if (this._radioTunerBandLabel) {
const activeCategory = state.categories.find((category) => category.id === state.filter);
this._radioTunerBandLabel.textContent = state.filter === 'all'
? 'DIRECTORY BAND'
: `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`;
}
this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic));
if (tunerAvailable) this._refreshRadioTunerBand?.();
if (!tunerAvailable && this._radioTunerDragging) {
this._radioTunerDragging = false;
this._radioTunerDragSnapshot = null;
this._radioTunerStations = [];
this._radioTuner?.classList.remove('is-static', 'is-dragging');
}
if (!tunerAvailable) {
this._radioTunerStations = [];
this._radioTunerPool = [];
this._radioTunerBandSignature = '';
this._radioTunerSelectedId = null;
}
if (this._radioStationName) this._radioStationName.textContent = selected?.name || 'NO STATION SELECTED';
if (this._radioStationMeta) {
const place = selected ? [selected.state, selected.countryCode].filter(Boolean).join(' · ') : '';
const signal = selected ? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : ''].filter(Boolean).join(' · ') : '';
this._radioStationMeta.textContent = selected
? [place, signal].filter(Boolean).join(' / ') || 'Directory metadata only'
: (state.loading ? 'Loading station directory…' : 'Choose a globe marker or use next.');
}
if (this._radioStationTags) {
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
this._radioStationTags.textContent = tags.length ? `TAGS · ${tags.join(' · ')}` : '';
}
if (this._radioStationHomepage) {
const homepage = selected?.homepage || '';
this._radioStationHomepage.hidden = !homepage;
if (homepage) this._radioStationHomepage.href = homepage;
else this._radioStationHomepage.removeAttribute('href');
}
if (this._radioPrevBtn) this._radioPrevBtn.disabled = !interactive || !hasStations;
if (this._radioNextBtn) this._radioNextBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniPrevBtn) this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations;
if (this._contextRadioMiniNextBtn) this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioPrevBtn) this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
if (this._cockpitRadioNextBtn) this._cockpitRadioNextBtn.disabled = !interactive || !hasStations;
if (this._radioPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play');
this._radioPlayBtn.disabled = !interactive || !hasStations;
this._radioPlayBtn.classList.toggle('active', activePlayback);
this._radioPlayBtn.textContent = action.toUpperCase();
this._radioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`);
}
if (this._contextRadioMiniPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play');
this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations;
this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback);
this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._contextRadioMiniPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`);
this._contextRadioMiniPlayBtn.title = action;
}
if (this._cockpitRadioPlayBtn) {
const action = activePlayback ? 'Pause' : (state.audioState === 'paused' ? 'Resume' : 'Play');
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
this._cockpitRadioPlayBtn.setAttribute('aria-label', `${action} ${selected ? 'selected' : 'nearest'} radio station`);
this._cockpitRadioPlayBtn.title = action;
}
if (this._radioStopBtn) this._radioStopBtn.disabled = !interactive || state.audioState === 'stopped';
if (this._radioVolume) this._radioVolume.disabled = !interactive;
if (this._radioVolume && document.activeElement !== this._radioVolume) {
this._radioVolume.value = String(Math.round(state.volume * 100));
if (this._radioVolumeValue) this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._contextRadioMiniVolume && document.activeElement !== this._contextRadioMiniVolume) {
this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100));
}
if (this._contextRadioMiniVolume) this._contextRadioMiniVolume.disabled = !interactive;
if (this._contextRadioMiniVolumeValue) {
this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._cockpitRadioVolume && document.activeElement !== this._cockpitRadioVolume) {
this._cockpitRadioVolume.value = String(Math.round(state.volume * 100));
}
if (this._cockpitRadioVolume) this._cockpitRadioVolume.disabled = !interactive;
if (this._cockpitRadioVolumeValue) {
this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
}
if (this._contextRadioMiniStation) {
this._contextRadioMiniStation.textContent = uncertain
? 'RADIO STATE UNCERTAIN'
: (selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY'));
}
if (this._cockpitRadioStation) {
this._cockpitRadioStation.textContent = uncertain
? 'UNCERTAIN'
: (selected?.name || (state.loading ? 'SYNCING' : 'READY'));
}
if (this._radioPlaybackState) {
const catalogSuffix = state.degraded
? (state.stale ? ' · stale/degraded directory' : ' · degraded directory')
: (state.stale ? ' · stale directory' : '');
const outsideFilter = selected && state.selectedIndex < 0 ? ' · outside current filter' : '';
const messages = {
stopped: enabled ? 'Ready — playback starts only from your action' : 'Radio off',
loading: 'Connecting directly to broadcaster…',
buffering: 'Buffering broadcaster stream…',
playing: `Playing ${selected?.name || 'station'}`,
paused: `Paused ${selected?.name || 'station'}`,
error: state.audioError || 'Broadcaster stream unavailable',
};
const voiceSuffix = state.voiceDucked
? ' · muted during voice interaction'
: (state.voiceRestoring ? ' · restoring volume after voice' : '');
const tuningSuffix = state.tuningAwaitingStationId
? (state.audioState === 'error'
? ' · static indicates no broadcaster audio'
: ' · tuning static until broadcaster starts')
: '';
const unavailable = state.tuningUnavailableStationId
? 'Station unavailable after directory refresh — choose another channel'
: null;
const lifecycleMessage = transitioning
? (lifecycleState === 'enabling' ? 'Radio is enabling…' : 'Radio is disabling…')
: null;
const uncertainMessage = uncertain
? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile'
: null;
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
this._radioPlaybackState.classList.toggle('error', Boolean(uncertainMessage || unavailable || state.error || state.audioState === 'error'));
}
if (
!enabled
&& !transitioning
&& !this.actions.preservePanelStateDuringClear()
&& !this._radioPanel.classList.contains('collapsed')
) {
this.actions.setPanelCollapsed('radio-panel', true);
}
this.actions.scheduleLayout();
}
+87
View File
@@ -0,0 +1,87 @@
/** Map an integer tuner slot directly to one available directory station. */
export function radioTunerSlot(value, stationCount) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count) return { slot: 0, max: 0, locked: false, stationIndex: -1, leftIndex: -1, rightIndex: -1 };
const max = Math.max(0, count - 1);
const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0)));
return {
slot,
max,
locked: true,
stationIndex: slot,
leftIndex: slot,
rightIndex: slot,
};
}
/** Snap a tuner release to the nearest available directory station. */
export function radioTunerCommitSlot(value, stationCount) {
return radioTunerSlot(value, stationCount);
}
/** Map one pointer coordinate to continuous absolute directory progress. */
export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 };
if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 };
const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2);
const ratio = Math.min(1, Math.max(0, ((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth));
const coordinate = ratio * (count - 1);
return {
ratio,
coordinate,
stationIndex: Math.min(count - 1, Math.max(0, Math.floor(coordinate + 0.5))),
};
}
/** Build a bounded virtual tuner tape around one continuous directory coordinate. */
export function buildRadioTunerTicks(coordinate, stationCount, width, {
insetPx = 7,
minPitchPx = 14,
speedFactor = 5,
overscan = 2,
labelStep = 6,
} = {}) {
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
const dialWidth = Math.max(0, Number(width) || 0);
const inset = Math.max(0, Number(insetPx) || 0);
const usableWidth = Math.max(0, dialWidth - inset * 2);
if (!count) return { ticks: [], needleX: inset, pitchPx: 0, ratio: 0 };
const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0));
if (count === 1) {
return {
ticks: [{ stationIndex: 0, channel: 1, xPx: inset + usableWidth / 2, current: true, label: '01' }],
needleX: inset + usableWidth / 2,
pitchPx: Math.max(1, Number(minPitchPx) || 14),
ratio: 0.5,
};
}
const directoryStep = usableWidth / (count - 1);
const pitchPx = Math.max(
Math.max(1, Number(minPitchPx) || 14),
directoryStep * Math.max(1, Number(speedFactor) || 5),
);
const needleX = inset + directoryStep * value;
const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0);
const first = Math.max(0, Math.ceil(value + (-overscanPx - needleX) / pitchPx));
const last = Math.min(count - 1, Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx));
const currentIndex = Math.min(count - 1, Math.max(0, Math.floor(value + 0.5)));
const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6));
const labelWidth = Math.max(2, String(count).length);
const ticks = [];
for (let stationIndex = first; stationIndex <= last; stationIndex += 1) {
const channel = stationIndex + 1;
const current = stationIndex === currentIndex;
const labelled = current || stationIndex === 0 || stationIndex === count - 1 || channel % majorEvery === 0;
ticks.push({
stationIndex,
channel,
xPx: needleX + pitchPx * (stationIndex - value),
current,
label: labelled ? String(channel).padStart(labelWidth, '0') : '',
});
}
return { ticks, needleX, pitchPx, ratio: value / (count - 1) };
}