From 7b9aca859739732b205ea1223911c12f850aabdb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:23:20 -0700 Subject: [PATCH] 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) }; +} +