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