From 7b9aca859739732b205ea1223911c12f850aabdb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:23:20 -0700 Subject: [PATCH 01/10] refactor: isolate Radio controls and tuner presentation --- CHANGELOG.md | 2 + docs/CODE-BOUNDARIES.md | 8 + docs/CURRENT-STATE.md | 10 + package.json | 3 +- scripts/format-scope.json | 8 +- scripts/package-boundaries.json | 225 +++++-- scripts/qa-radio.mjs | 104 +-- src/cockpitMarkup.test.mjs | 50 +- src/contextSessionOrdering.test.mjs | 23 +- src/contextTabKeyboard.test.mjs | 14 +- src/data/radio.js | 88 +-- src/firstRunExperience.test.mjs | 2 +- src/panelEscape.test.mjs | 10 +- src/radioMarkup.test.mjs | 60 +- src/ui.js | 940 +++------------------------- src/ui/radio.js | 2 + src/ui/radioBindings.js | 488 +++++++++++++++ src/ui/radioControls.js | 156 +++++ src/ui/radioControls.test.mjs | 116 ++++ src/ui/radioPresentation.js | 245 ++++++++ src/ui/radioTunerModel.js | 87 +++ 21 files changed, 1507 insertions(+), 1134 deletions(-) create mode 100644 src/ui/radio.js create mode 100644 src/ui/radioBindings.js create mode 100644 src/ui/radioControls.js create mode 100644 src/ui/radioControls.test.mjs create mode 100644 src/ui/radioPresentation.js create mode 100644 src/ui/radioTunerModel.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d16fc822..1bddf56bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 78f04247d..f11735a43 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 85d03ca5c..dc911cb8d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/package.json b/package.json index 57d264802..c88533b76 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8aa998b56..5e0a8b660 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9804b619e..6e9b94dce 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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": [] } } diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 50d5b50c6..bcc5524d1 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -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]; diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 515d7da64..355dfc040 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -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%/); diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 8aff666fd..e9f8ef8d7 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -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', () => { diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 1e58efa06..092d15fa3 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -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() {')), diff --git a/src/data/radio.js b/src/data/radio.js index bc7c57f65..0f28ce8d2 100644 --- a/src/data/radio.js +++ b/src/data/radio.js @@ -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); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index c45446183..f127e88b5 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -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 diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index 3c7245688..6b44052fc 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -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', ); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index d360ec2f1..91a86b465 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -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 \}\)/); }); diff --git a/src/ui.js b/src/ui.js index a7abdff95..634c0e5b3 100644 --- a/src/ui.js +++ b/src/ui.js @@ -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} 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} 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(); diff --git a/src/ui/radio.js b/src/ui/radio.js new file mode 100644 index 000000000..496c23444 --- /dev/null +++ b/src/ui/radio.js @@ -0,0 +1,2 @@ +export { RadioControls } from './radioControls.js'; +export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js'; diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js new file mode 100644 index 000000000..ef1af2d41 --- /dev/null +++ b/src/ui/radioBindings.js @@ -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); + } + diff --git a/src/ui/radioControls.js b/src/ui/radioControls.js new file mode 100644 index 000000000..a0379509f --- /dev/null +++ b/src/ui/radioControls.js @@ -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} 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} 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(); + } +} diff --git a/src/ui/radioControls.test.mjs b/src/ui/radioControls.test.mjs new file mode 100644 index 000000000..89d128d40 --- /dev/null +++ b/src/ui/radioControls.test.mjs @@ -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(); } +}); diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js new file mode 100644 index 000000000..46da1705b --- /dev/null +++ b/src/ui/radioPresentation.js @@ -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(); + } + diff --git a/src/ui/radioTunerModel.js b/src/ui/radioTunerModel.js new file mode 100644 index 000000000..5d328371f --- /dev/null +++ b/src/ui/radioTunerModel.js @@ -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) }; +} + From bd6cc5abe8513441aa97fb0ff087026cf131c330 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:25:09 -0700 Subject: [PATCH 02/10] style: format Radio component boundary --- scripts/package-boundaries.json | 216 ++---- src/ui/radio.js | 7 +- src/ui/radioBindings.js | 1138 ++++++++++++++++++++----------- src/ui/radioControls.js | 131 +++- src/ui/radioControls.test.mjs | 112 ++- src/ui/radioPresentation.js | 590 +++++++++------- src/ui/radioTunerModel.js | 82 ++- 7 files changed, 1393 insertions(+), 883 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6e9b94dce..b1435a095 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,47 +10,27 @@ "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", @@ -65,24 +45,16 @@ "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", @@ -97,19 +69,13 @@ "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", @@ -120,19 +86,13 @@ "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" @@ -140,50 +100,30 @@ "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", @@ -192,19 +132,13 @@ "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", @@ -221,9 +155,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -237,9 +169,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -258,9 +188,7 @@ }, "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", @@ -277,9 +205,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -298,9 +224,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -320,9 +244,7 @@ }, "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", @@ -333,9 +255,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -345,27 +265,17 @@ "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", @@ -377,9 +287,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -388,19 +296,12 @@ "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", @@ -413,14 +314,10 @@ "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", @@ -430,10 +327,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -444,9 +338,7 @@ "external": [] }, "location-controls": { - "exports": [ - "./ui/location" - ], + "exports": ["./ui/location"], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -456,9 +348,7 @@ "external": [] }, "radio-controls": { - "exports": [ - "./ui/radio" - ], + "exports": ["./ui/radio"], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", diff --git a/src/ui/radio.js b/src/ui/radio.js index 496c23444..c8110b682 100644 --- a/src/ui/radio.js +++ b/src/ui/radio.js @@ -1,2 +1,7 @@ export { RadioControls } from './radioControls.js'; -export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js'; +export { + radioTunerSlot, + radioTunerCommitSlot, + radioTunerPointerPosition, + buildRadioTunerTicks, +} from './radioTunerModel.js'; diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js index ef1af2d41..530e4b43e 100644 --- a/src/ui/radioBindings.js +++ b/src/ui/radioBindings.js @@ -1,488 +1,808 @@ -import { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from './radioTunerModel.js'; +import { + radioTunerSlot, + radioTunerCommitSlot, + radioTunerPointerPosition, + buildRadioTunerTicks, +} from './radioTunerModel.js'; /** Bind Radio input using a RadioControls receiver and supplied actions. */ 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, + 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`, ); - 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 + 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 + : 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 + 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; + : '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, }; - const setTunerDirectory = (pool) => { - this._radioTunerPool = [...pool]; - this._radioTunerStations = [...pool]; - this._radioTunerBandSignature = this._radioTunerStations.map((station) => station.id).join('|'); + 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) { - 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, + const startSlot = radioTunerSlot( + dragSnapshot.slot, this._radioTunerStations.length, ); - this._radioTunerSlider.value = String(snapped.slot); - this._radioTunerCoordinate = snapped.stationIndex; - station = tunerPreview({ coordinate: this._radioTunerCoordinate }); + this._radioTunerSlider.max = String(startSlot.max); + this._radioTunerSlider.value = String(startSlot.slot); } - 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._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; - 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; + 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 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, { + } + }; + 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"]'), + `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, }); - 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 }); + 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._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; + 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; + }, + 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; + 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; + }, + 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); + 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) => { + }, + { 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'; + 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)'); + 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 }); + 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) => { + }, + { 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', () => { + }, + tunerListenerOptions, + ); + this.listen( + window, + 'gev:cockpit-signal-expanded', + () => { setCockpitDisclosure('display', false); - }, tunerListenerOptions); - this.listen(window, 'gev:cockpit-context-expanded', () => { + }, + tunerListenerOptions, + ); + this.listen( + window, + 'gev:cockpit-context-expanded', + () => { this.actions.setPanelCollapsed('data-panel', true); - }, 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) => { + }, + 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 */ } + 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; + }, + 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; + }, + 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; + 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) => { + }, + 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 */ } + 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 */ } + }, + 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) => { + }, + 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 ( + ![ + '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 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 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; + 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; + }, + 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); - } - + }, + 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); +} diff --git a/src/ui/radioControls.js b/src/ui/radioControls.js index a0379509f..df2cc3b2b 100644 --- a/src/ui/radioControls.js +++ b/src/ui/radioControls.js @@ -31,17 +31,24 @@ export class RadioControls { } listen(target, type, handler, options = {}) { - target?.addEventListener(type, handler, { ...options, signal: this.listeners.signal }); + 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)); + this._radioUnsubscribe = this.radio.subscribe?.((state) => + this._renderRadioState(state), + ); } - _renderRadioState(state) { renderRadioState.call(this, state); } + _renderRadioState(state) { + renderRadioState.call(this, state); + } /** * Reveal the newly enabled directory and transport inside Context without @@ -55,28 +62,46 @@ export class RadioControls { 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; + 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; + 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 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' }); + const reducedMotion = window.matchMedia?.( + '(prefers-reduced-motion: reduce)', + )?.matches; + scroller.scrollTo({ + top: next, + behavior: reducedMotion ? 'auto' : 'smooth', + }); return true; } @@ -89,11 +114,24 @@ export class RadioControls { 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; + 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; + 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(); @@ -104,8 +142,13 @@ export class RadioControls { ); 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' }); + const reducedMotion = window.matchMedia?.( + '(prefers-reduced-motion: reduce)', + )?.matches; + scroller.scrollTo({ + top: next, + behavior: reducedMotion ? 'auto' : 'smooth', + }); } focusTarget?.focus?.({ preventScroll: true }); return moved; @@ -115,25 +158,44 @@ export class RadioControls { _syncContextRadioLauncherState() { if (!this._contextRadioToggleBtn) return; const contextPanel = document.getElementById('global-context-panel'); - const contextExpanded = Boolean(contextPanel && !contextPanel.classList.contains('collapsed')); + const contextExpanded = Boolean( + contextPanel && !contextPanel.classList.contains('collapsed'), + ); if (contextExpanded) { - const radioExpanded = Boolean(this._radioPanel && !this._radioPanel.classList.contains('collapsed')); + const radioExpanded = Boolean( + this._radioPanel && !this._radioPanel.classList.contains('collapsed'), + ); this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel'); - this._contextRadioToggleBtn.setAttribute('aria-expanded', String(radioExpanded)); - const label = radioExpanded ? 'Go to expanded Radio section' : 'Expand Radio section in Context'; + 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 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.setAttribute( + 'aria-label', + `${action} compact Radio controls`, + ); this._contextRadioToggleBtn.title = `${action} compact Radio controls`; } - destroy() { if (this.destroyed) return; this.destroyed = true; @@ -148,9 +210,16 @@ export class RadioControls { this._radioTunerPool = []; this._radioTunerStations = []; this._radioTunerBandPinnedForNavigation = false; - try { if (pointer !== null) this._radioTunerSlider?.releasePointerCapture(pointer); } catch { /* capture already released */ } + try { + if (pointer !== null) + this._radioTunerSlider?.releasePointerCapture(pointer); + } catch { + /* capture already released */ + } this._radioTuner?.classList.remove('is-dragging', 'is-static'); - document.getElementById('title-bar')?.classList.remove('radio-broadcasting'); + document + .getElementById('title-bar') + ?.classList.remove('radio-broadcasting'); this.radio.endTuning(); } } diff --git a/src/ui/radioControls.test.mjs b/src/ui/radioControls.test.mjs index 89d128d40..5bbb88fef 100644 --- a/src/ui/radioControls.test.mjs +++ b/src/ui/radioControls.test.mjs @@ -13,13 +13,22 @@ class Element extends EventTarget { 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), + 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++; } + setAttribute(name, value) { + this.attrs.set(name, String(value)); + } + getAttribute(name) { + return this.attrs.get(name); + } + querySelector() { + return null; + } + focus() { + this.focusCount++; + } } function fixture() { @@ -34,25 +43,54 @@ function fixture() { 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; }, + 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, + isRegistered: () => true, + isEnabled: () => false, runUserAction: (operation) => operation(), setEnabled: async () => true, setPanelCollapsed: (...args) => calls.push(['panel', ...args]), - layoutCockpit() {}, isCockpitActive: () => false, - scheduleLayout() {}, getLifecycle: () => null, + layoutCockpit() {}, + isCockpitActive: () => false, + scheduleLayout() {}, + getLifecycle: () => null, preservePanelStateDuringClear: () => false, }; const controls = new RadioControls({ - elements: { _radioPanel: panel, _radioEnableBtn: enable, _radioNextBtn: next }, - radio, actions, canvas: new Element(), + 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); } }; + 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', () => { @@ -61,7 +99,8 @@ test('Radio listeners and subscriptions are revoked once before tuning teardown' f.controls.connect(); f.next.dispatchEvent(new Event('click')); assert.deepEqual(f.calls, ['subscribe', ['cycle', 1]]); - f.controls.destroy(); f.controls.destroy(); + 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')); @@ -69,21 +108,28 @@ test('Radio listeners and subscriptions are revoked once before tuning teardown' f.controls.connect(); assert.deepEqual(f.calls, snapshot); assert.deepEqual(f.calls.slice(-2), ['unsubscribe', 'endTuning']); - } finally { f.cleanup(); } + } finally { + f.cleanup(); + } }); test('Radio reconnect removes its previous state subscription', () => { const f = fixture(); try { - f.controls.connect(); f.controls.connect(); + f.controls.connect(); + f.controls.connect(); assert.deepEqual(f.calls, ['subscribe', 'unsubscribe', 'subscribe']); - } finally { f.cleanup(); } + } finally { + f.cleanup(); + } }); test('a delayed Radio enable cannot reveal or refocus controls after destruction', async () => { const f = fixture(); let resolve; - const pending = new Promise((done) => { resolve = done; }); + const pending = new Promise((done) => { + resolve = done; + }); try { f.actions.setEnabled = () => pending; f.enable.dispatchEvent(new Event('click')); @@ -91,26 +137,36 @@ test('a delayed Radio enable cannot reveal or refocus controls after destruction f.controls.destroy(); const attrs = new Map(f.enable.attrs); resolve(true); - await pending; await new Promise((done) => setImmediate(done)); + await pending; + await new Promise((done) => setImmediate(done)); assert.deepEqual(f.enable.attrs, attrs); assert.equal(f.enable.focusCount, 0); - assert.equal(f.calls.some((call) => Array.isArray(call) && call[0] === 'panel'), false); - } finally { f.cleanup(); } + 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._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(); } + } finally { + f.cleanup(); + } }); diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index 46da1705b..eab9c3bf7 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -1,245 +1,367 @@ - /** Render Radio state without making playback or Context decisions. */ +/** 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.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) => { + 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})`); + 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; + }), + ); + this._radioCategorySignature = categorySignature; } - - 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(); + 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(); +} diff --git a/src/ui/radioTunerModel.js b/src/ui/radioTunerModel.js index 5d328371f..8c84422f9 100644 --- a/src/ui/radioTunerModel.js +++ b/src/ui/radioTunerModel.js @@ -1,7 +1,15 @@ /** 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 }; + 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 { @@ -20,29 +28,49 @@ export function radioTunerCommitSlot(value, stationCount) { } /** Map one pointer coordinate to continuous absolute directory progress. */ -export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) { +export function radioTunerPointerPosition( + clientX, + left, + width, + stationCount, + insetPx = 7, +) { const count = Math.max(0, Math.floor(Number(stationCount) || 0)); 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 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))), + 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, -} = {}) { +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); @@ -51,7 +79,15 @@ export function buildRadioTunerTicks(coordinate, stationCount, width, { 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' }], + 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, @@ -64,16 +100,29 @@ export function buildRadioTunerTicks(coordinate, stationCount, width, { ); 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 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; + const labelled = + current || + stationIndex === 0 || + stationIndex === count - 1 || + channel % majorEvery === 0; ticks.push({ stationIndex, channel, @@ -84,4 +133,3 @@ export function buildRadioTunerTicks(coordinate, stationCount, width, { } return { ticks, needleX, pitchPx, ratio: value / (count - 1) }; } - From 02e90ca698b7e64e2410adc371cddbe0d4ee5a71 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:25:09 -0700 Subject: [PATCH 03/10] test: keep Radio invariants independent of formatting --- src/cockpitMarkup.test.mjs | 10 +++++----- src/contextSessionOrdering.test.mjs | 2 +- src/firstRunExperience.test.mjs | 2 +- src/panelEscape.test.mjs | 2 +- src/radioMarkup.test.mjs | 10 +++++----- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 355dfc040..dc3201310 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -355,12 +355,12 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => { ); assert.match( radioBindings, - /!displayOpen && !radioOpen[\s\S]*?this\.actions\.isCockpitActive\(\)[\s\S]*?!this\.actions\.signalUserCollapsed\(\)[\s\S]*?setSignalCollapsed\(false\)/, + /!displayOpen\s*&&\s*!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( radioBindings, - /event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\) return;[\s\S]*?setCockpitDisclosure\('display', false\);/, + /event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\)\s*return;[\s\S]*?setCockpitDisclosure\('display', false\);/, 'left-side interactions must not collapse the independent Cockpit utilities', ); }); @@ -503,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 = radioBindings.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(radioBindings, /listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/); assert.match(radioBindings, /listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/); assert.match( radioBindings, - /listen\(this\._cockpitRadioPrevBtn, 'click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/, + /listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/, ); assert.match( radioBindings, - /listen\(this\._cockpitRadioNextBtn, 'click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/, + /listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/, ); }); diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index e9f8ef8d7..4bc5f6704 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -192,7 +192,7 @@ test('only the expanded Radio Enable gesture requests the contained post-enable 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.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/); assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1); assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/); }); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index f127e88b5..b19f082ae 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -270,7 +270,7 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh // disclosure's earlier capture handler closed the disclosure and the launcher // dismissed itself off the same key. The earlier listener is the only one that // can stop the later one — and only the immediate form does it. - const radioEsc = ui.slice(ui.indexOf("if (event.key !== 'Escape' || !this._contextRadioDock")); + const radioEsc = ui.slice(ui.indexOf("event.key !== 'Escape' ||")); const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false')); assert.match(claim, /event\.preventDefault\(\);/); assert.match(claim, /event\.stopImmediatePropagation\(\);/); diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index 6b44052fc..c44be1b97 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -160,7 +160,7 @@ 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( radioBindings, - /const nestedPanel = event\.target\?\.closest\?\.\('\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)'\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/, + /const nestedPanel = event\.target\?\.closest\?\.\(\s*'\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)',?\s*\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/, ); assert.match( radioBindings, diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 91a86b465..303a54246 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -259,9 +259,9 @@ test('Radio is nested inside Context with separate disclosure and power controls const cycleMethod = radioBindings.slice(cycleStart, radioBindings.indexOf('const toggleRadio', cycleStart)); assert.doesNotMatch(cycleMethod, /refreshTunerBand/); assert.match(radioBindings, /_radioTunerBandPinnedForNavigation = true/); - assert.match(radioBindings, /listen\(this\.canvas, 'pointerdown', releaseNavigationBand/); + assert.match(radioBindings, /listen\(\s*this\.canvas,\s*'pointerdown',\s*releaseNavigationBand/); assert.match(radioBindings, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/); - assert.match(radioBindings, /tunerPreview\(\{ coordinate: this\._radioTunerCoordinate, rotate: commit \}\)/); + assert.match(radioBindings, /tunerPreview\(\{\s*coordinate: this\._radioTunerCoordinate,\s*rotate: commit,?\s*\}\)/); assert.match(radioBindings, /this\.radio\.cancelTuning\(\)/); assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/); assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/); @@ -312,7 +312,7 @@ test('expanded Context routes its Radio icon to the embedded section and keeps c 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, /scroller\.scrollTo\(\{\s*top: next,\s*behavior: reducedMotion \? 'auto' : 'smooth',?\s*\}\)/); assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/); assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/); @@ -320,8 +320,8 @@ test('expanded Context routes its Radio icon to the embedded section and keeps c 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\)/); + assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded',\s*String\(radioExpanded\)/); + assert.match(syncMethod, /aria-controls',\s*'context-radio-mini'[\s\S]*?aria-expanded',\s*String\(compactOpen\)/); const renderMethod = radioPresentation; assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/); assert.doesNotMatch(renderMethod, /compact Radio controls/); From 641a2c67e2705faab875735b1bdf4ef5ad2280ee Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:30:23 -0700 Subject: [PATCH 04/10] test: align Radio browser rendering with platform QA defaults --- scripts/qa-radio.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index bcc5524d1..48920dd8a 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -87,7 +87,10 @@ async function main() { headless: HEADFUL ? false : 'new', ...(chrome ? { executablePath: chrome } : {}), args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', + '--no-sandbox', '--disable-setuid-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', ], From 53c510fe9f0f933a9998bf76f174e24c89784c08 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:40:51 -0700 Subject: [PATCH 05/10] fix: keep disposed Radio disclosures inert --- src/ui/radioBindings.js | 2 ++ src/ui/radioControls.js | 2 +- src/ui/radioControls.test.mjs | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js index 530e4b43e..68ee78fa8 100644 --- a/src/ui/radioBindings.js +++ b/src/ui/radioBindings.js @@ -10,6 +10,7 @@ export function bindRadioControls() { if (!this._radioPanel) return; const tunerListenerOptions = {}; const setRadioDisclosure = (expanded, { returnFocus = false } = {}) => { + if (this.destroyed) return; const open = Boolean(expanded); this._contextRadioDock?.classList.toggle('disclosure-open', open); if (this._contextRadioMini) this._contextRadioMini.hidden = !open; @@ -23,6 +24,7 @@ export function bindRadioControls() { expanded, { returnFocus = false } = {}, ) => { + if (this.destroyed) return; const displayOpen = kind === 'display' && Boolean(expanded); const radioOpen = kind === 'radio' && Boolean(expanded); if (displayOpen || radioOpen) this.actions.setSignalCollapsed(true); diff --git a/src/ui/radioControls.js b/src/ui/radioControls.js index df2cc3b2b..8a60898e2 100644 --- a/src/ui/radioControls.js +++ b/src/ui/radioControls.js @@ -156,7 +156,7 @@ export class RadioControls { /** Keep the Context header Radio shortcut truthful for its current route. */ _syncContextRadioLauncherState() { - if (!this._contextRadioToggleBtn) return; + if (this.destroyed || !this._contextRadioToggleBtn) return; const contextPanel = document.getElementById('global-context-panel'); const contextExpanded = Boolean( contextPanel && !contextPanel.classList.contains('collapsed'), diff --git a/src/ui/radioControls.test.mjs b/src/ui/radioControls.test.mjs index 5bbb88fef..b99c96a18 100644 --- a/src/ui/radioControls.test.mjs +++ b/src/ui/radioControls.test.mjs @@ -102,6 +102,8 @@ test('Radio listeners and subscriptions are revoked once before tuning teardown' f.controls.destroy(); f.controls.destroy(); const snapshot = structuredClone(f.calls); + f.controls._setRadioDisclosure(true); + f.controls._setCockpitDisclosure('display', true); f.next.dispatchEvent(new Event('click')); f.document.dispatchEvent(new Event('gev:radio-selected')); f.subscribers[0]({ enabled: true }); From 11aa90c7bbe03ab7b1d8e60311978ea43588e917 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:40:51 -0700 Subject: [PATCH 06/10] test: isolate Radio gestures and wait for contained reveal --- scripts/qa-radio.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 48920dd8a..310367cbc 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -25,6 +25,10 @@ const option = (name, fallback) => { }; const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173'); const APP_ORIGIN = new URL(APP_URL).origin; +// Radio owns this harness's keyboard and pointer actions; the welcome dialog +// has its own acceptance harness and must not intercept those gestures. +const RADIO_URL = new URL(APP_URL); +RADIO_URL.searchParams.set('welcome', '0'); const HEADFUL = args.includes('--headful'); const chromeCandidates = [ @@ -216,7 +220,7 @@ async function main() { } }); - await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.goto(RADIO_URL.href, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.styleManager?._dataManager?.layers?.has('radio'), { timeout: 60_000 }); await page.waitForFunction( @@ -574,7 +578,14 @@ async function main() { await page.evaluate(() => window.__qaReleaseRadioEnable?.()); await page.waitForFunction(() => window.__godsEyeView.dataManager.isEnabled('radio')); await page.evaluate(() => window.__qaRestoreRadioEnable?.()); - await sleep(650); + await page.waitForFunction(() => { + const scroller = document.querySelector('#global-context-panel .global-context-panel-inner'); + const viewport = scroller.getBoundingClientRect(); + const directory = document.querySelector('#radio-panel .radio-directory-row').getBoundingClientRect(); + const play = document.getElementById('radio-play-btn').getBoundingClientRect(); + return directory.top >= viewport.top && directory.bottom <= viewport.bottom + && play.top >= viewport.top && play.bottom <= viewport.bottom; + }, { timeout: 10_000 }); const explicitRevealAfter = await page.evaluate(() => { const gev = window.__godsEyeView; const scroller = document.querySelector('#global-context-panel .global-context-panel-inner'); From b119aef0ad9ac22dcb985f36b034e18261985283 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:49:26 -0700 Subject: [PATCH 07/10] test: settle Radio render frames before native picking --- scripts/qa-radio.mjs | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 310367cbc..e94e22898 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -80,6 +80,22 @@ function check(name, ok, detail = '') { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +// Native pick buffers and clustered overlay entries settle on rendered frames, +// not on wall-clock sleeps, particularly with software rendering. +async function settleRadioFrames(page) { + await page.evaluate(() => new Promise((resolve, reject) => { + const scene = window.__godsEyeView.viewer.scene; + let remaining = 4; + const timeout = setTimeout(() => { remove(); reject(new Error('Radio rendered-frame deadline exceeded')); }, 10_000); + const remove = scene.postRender.addEventListener(() => { + if (--remaining === 0) { remove(); clearTimeout(timeout); resolve(); } + else requestAnimationFrame(() => scene.requestRender()); + }); + scene.requestRender(); + })); +} + + async function main() { const response = await fetch(APP_URL).catch(() => null); if (!response?.ok) { @@ -652,6 +668,7 @@ async function main() { viewer.scene.requestRender(); }, { lon: spec.lon, height: view.height }); await sleep(700); + await settleRadioFrames(page); const sample = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; const radio = window.__godsEyeView.dataManager.layers.get('radio').module; @@ -1334,12 +1351,9 @@ async function main() { && clusterBadge?.rect?.h > 0, JSON.stringify(clusterBadge), ); - const highGlobalClusterLabels = await page.evaluate(async () => { + await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; - const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const ellipsoid = viewer.scene.globe.ellipsoid; - const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); - const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); viewer.camera.cancelFlight(); viewer.camera.setView({ destination: ellipsoid.cartographicToCartesian({ @@ -1351,6 +1365,14 @@ async function main() { }); viewer.scene.requestRender(); await new Promise((resolve) => setTimeout(resolve, 500)); + }); + await settleRadioFrames(page); + const highGlobalClusterLabels = await page.evaluate(async () => { + const viewer = window.__godsEyeView.viewer; + const radio = window.__godsEyeView.dataManager.layers.get('radio').module; + const ellipsoid = viewer.scene.globe.ellipsoid; + const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); + const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); const source = radio.getOverlayDiagnostics(); const cameraPosition = viewer.camera.positionWC; const cameraRadius = Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z); @@ -1361,11 +1383,23 @@ async function main() { const points = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations')?.clustering?._clusterPointCollection; let clusterPoint = null; + // Pool order is not visibility order after a camera change. Choose an + // on-screen point independently of the native pick result being tested. + const visiblePoints = []; for (let index = 0; index < (points?.length || 0); index += 1) { const point = points.get(index); if (!point?.show || !Array.isArray(point.id) || !point.id.length) continue; const anchor = viewer.scene.cartesianToCanvasCoordinates(point.position); if (!anchor) continue; + if (anchor.x < 0 || anchor.y < 0 || anchor.x > viewer.canvas.clientWidth || anchor.y > viewer.canvas.clientHeight) continue; + visiblePoints.push({ point, anchor }); + } + visiblePoints.sort((a, b) => ( + Math.hypot(a.anchor.x - viewer.canvas.clientWidth / 2, a.anchor.y - viewer.canvas.clientHeight / 2) + - Math.hypot(b.anchor.x - viewer.canvas.clientWidth / 2, b.anchor.y - viewer.canvas.clientHeight / 2) + )); + if (visiblePoints.length) { + const { point, anchor } = visiblePoints[0]; const exactPick = (viewer.scene.drillPick(anchor, 16) || []).find((picked) => ( picked?.primitive === point && picked?.id === point.id )); @@ -1373,7 +1407,6 @@ async function main() { maxDistance: point.distanceDisplayCondition?.far ?? null, pickable: Boolean(exactPick), }; - break; } return { source, From a5af1cbfa332912f0fdf35e18997a0dd116ed178 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:53:10 -0700 Subject: [PATCH 08/10] fix: restore independent UI observer and subscription teardown --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 6 ++ scripts/format-scope.json | 3 +- scripts/qa-ui-disposal.mjs | 123 +++++++++++++++++++++++++++++++++++++ src/ui.js | 10 +++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-ui-disposal.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bddf56bb..a83bbbe08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction. + - 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dc911cb8d..cb2616ef0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,11 @@ # God's Eye View Current State +## UI disposal + +UI disposal releases the CCTV subscription, command-dock observer, legacy drag +resize observer and window resize listener. These remain independent of the +Location component and are included in whole-UI browser teardown acceptance. + ## Radio controls Radio panel, compact controls and tuner presentation have a dedicated owner. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5e0a8b660..91918223e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -172,5 +172,6 @@ "src/ui/radioBindings.js", "src/ui/radioPresentation.js", "src/ui/radioTunerModel.js", - "src/ui/radioControls.test.mjs" + "src/ui/radioControls.test.mjs", + "scripts/qa-ui-disposal.mjs" ] diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs new file mode 100644 index 000000000..155d9116a --- /dev/null +++ b/scripts/qa-ui-disposal.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** Exercise the real UI disposal path, including unrelated resource owners. */ +import puppeteer from 'puppeteer'; +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +function check(name, ok) { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); + if (!ok) failures++; +} +try { + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.styleManager?._dataManager && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + const result = await page.evaluate(async () => { + const ui = window.__godsEyeView.styleManager; + const counts = {}; + const watchObserver = (name) => { + const observer = ui[name]; + if (!observer) return false; + const disconnect = observer.disconnect.bind(observer); + counts[name] = 0; + observer.disconnect = () => { + counts[name]++; + return disconnect(); + }; + return true; + }; + const observed = [ + '_commandDockTrayObserver', + '_draggableResizeObserver', + '_leftStackResizeObserver', + '_rightStackResizeObserver', + '_leftStackMutationObserver', + '_rightStackMutationObserver', + ].filter(watchObserver); + const cctvUnsubscribe = ui._cctvUnsubscribe; + counts.cctv = 0; + if (cctvUnsubscribe) + ui._cctvUnsubscribe = () => { + counts.cctv++; + return cctvUnsubscribe(); + }; + const resizeHandler = ui._windowResizeHandler; + const removeEventListener = window.removeEventListener; + counts.resize = 0; + window.removeEventListener = function (type, callback, options) { + if (type === 'resize' && callback === resizeHandler) counts.resize++; + return removeEventListener.call(this, type, callback, options); + }; + try { + await ui.dispose(); + const once = JSON.stringify(counts); + await ui.dispose(); + window.dispatchEvent(new Event('resize')); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + return { + connected: Boolean( + cctvUnsubscribe && + resizeHandler && + observed.includes('_commandDockTrayObserver'), + ), + observersReleased: observed.every( + (name) => counts[name] === 1 && ui[name] === null, + ), + subscriptionReleased: counts.cctv === 1 && ui._cctvUnsubscribe === null, + resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null, + controlsReleased: + ui._radioControls.destroyed && ui._locationControls.destroyed, + idempotent: once === JSON.stringify(counts), + }; + } finally { + window.removeEventListener = removeEventListener; + } + }); + check( + 'real UI has the expected live resources before disposal', + result.connected, + ); + check( + 'UI disposal disconnects each active panel observer once', + result.observersReleased, + ); + check( + 'UI disposal releases the CCTV state subscription', + result.subscriptionReleased, + ); + check( + 'UI disposal removes the registered window resize handler', + result.resizeReleased, + ); + check( + 'Radio and Location controls are disposed with the UI', + result.controlsReleased, + ); + check('repeated UI disposal is inert', result.idempotent); + check( + 'disposal and subsequent resize produce no uncaught browser errors', + errors.length === 0, + ); +} finally { + await browser.close(); +} +if (failures) process.exitCode = 1; diff --git a/src/ui.js b/src/ui.js index 634c0e5b3..d8bf096bc 100644 --- a/src/ui.js +++ b/src/ui.js @@ -8594,6 +8594,16 @@ export class StyleManager { this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe = null; + this._cctvUnsubscribe?.(); + this._cctvUnsubscribe = null; + this._commandDockTrayObserver?.disconnect?.(); + this._commandDockTrayObserver = null; + this._draggableResizeObserver?.disconnect(); + this._draggableResizeObserver = null; + if (this._windowResizeHandler) { + window.removeEventListener('resize', this._windowResizeHandler); + this._windowResizeHandler = null; + } if (this._loadingVisibilityHandler) { document.removeEventListener('visibilitychange', this._loadingVisibilityHandler); this._loadingVisibilityHandler = null; From 457fe5451c23a38a17ab2fe86b013e02bef0292a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 00:05:16 -0700 Subject: [PATCH 09/10] test: follow hold-Space and cancellable tuner keyboard semantics --- scripts/qa-radio.mjs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index e94e22898..10365afeb 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -2699,9 +2699,15 @@ async function main() { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; await radio.play(); const beforeSpace = radio.getUIState().audioState; - document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, code: 'Space', key: ' ' })); + // Simulate an already-started hold-Space session; click-started open mic + // deliberately ignores Space takeover, and short taps never claim voice. + const priorPushToTalkMode = voice.pushToTalkMode; + voice.pushToTalkMode = true; + document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); + await new Promise((resolve) => setTimeout(resolve, 700)); const afterSpace = radio.getUIState().audioState; - document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, code: 'Space', key: ' ' })); + document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); + voice.pushToTalkMode = priorPushToTalkMode; await radio.play(); voice.setVoiceSpeaker('ai'); return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState }; @@ -3369,6 +3375,7 @@ async function main() { const slider = document.getElementById('radio-tuner-slider'); const key = (type, value) => slider.dispatchEvent(new KeyboardEvent(type, { bubbles: true, + cancelable: true, key: value, })); const markerId = () => gev.viewer.entities.values @@ -3423,7 +3430,8 @@ async function main() { previewAndCancel('PageUp', Math.min(count - 1, base + pageStep)), previewAndCancel('PageDown', Math.max(0, base - pageStep)), ]; - return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation }; + return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation, + panelStayedOpen: !document.getElementById('radio-panel').classList.contains('collapsed') }; }); check( 'keyboard preview is silent, key release commits once, and Escape restores the committed station', @@ -3435,7 +3443,8 @@ async function main() { && !tunerKeyboard.committed.tuningActive && tunerKeyboard.committed.playDelta === 1 && tunerKeyboard.cancelled.selectedId === tunerKeyboard.expectedId && tunerKeyboard.cancelled.slot === tunerKeyboard.expectedIndex - && !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0, + && !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0 + && tunerKeyboard.panelStayedOpen, JSON.stringify(tunerKeyboard), ); check( @@ -4437,7 +4446,10 @@ async function main() { }); await page.click('#context-radio-toggle-btn'); await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed')); - await sleep(450); + await page.waitForFunction((priorScroll) => ( + document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel' + && document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll + ), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop); const expandedContextRadioAfter = await page.evaluate(() => { const gev = window.__godsEyeView; gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState()); From a9e7cb67015498369e2d2402debc8e865811de30 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 00:21:51 -0700 Subject: [PATCH 10/10] test: measure Cockpit layout after rendered frames --- scripts/qa-radio.mjs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 10365afeb..a232bfdeb 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -1922,7 +1922,10 @@ async function main() { hudVariant: manager.hud.getVariant(), focusId: document.activeElement?.id || null, }; - const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320)); + const waitForLayout = async () => { + await new Promise((resolve) => setTimeout(resolve, 320)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; const cases = []; manager.setPanelCollapsed('global-context-panel', true); @@ -1999,7 +2002,10 @@ async function main() { // owns the real tracked-aircraft camera session. Hold the frame update so // the intentionally synthetic Cockpit shell is not auto-exited mid-check. const realCockpitUpdate = manager.cockpitView.update; - const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320)); + const waitForLayout = async () => { + await new Promise((resolve) => setTimeout(resolve, 320)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; const prior = { cockpit: document.body.classList.contains('cockpit-mode'), cockpitActive: manager.cockpitView.active, @@ -2104,7 +2110,10 @@ async function main() { }; // The Intel HUD fades over 400ms and keeps its readout rects for the // whole transition, so both lanes are measured only once it has settled. - const waitForHudSettle = () => new Promise((resolve) => setTimeout(resolve, 560)); + const waitForHudSettle = async () => { + await new Promise((resolve) => setTimeout(resolve, 560)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; for (let index = 0; index < 5; index += 1) { recordLayoutStep(); if (index < 4) { @@ -2255,14 +2264,17 @@ async function main() { const stableTop = utility.getBoundingClientRect().top; const transitionTops = []; let sampling = true; + let sampleFrame = null; const sampleTop = () => { + if (!sampling) return; transitionTops.push(utility.getBoundingClientRect().top); - if (sampling) requestAnimationFrame(sampleTop); + sampleFrame = requestAnimationFrame(sampleTop); }; - requestAnimationFrame(sampleTop); + sampleFrame = requestAnimationFrame(sampleTop); await manager._setMapStack('osm', { syncShare: false }); await waitForLayout(); sampling = false; + cancelAnimationFrame(sampleFrame); mapProviderUtilityStable = transitionTops.length > 1 && transitionTops.every((top) => Math.abs(top - stableTop) < 1); }