mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
A dial click shorter than the device start-up let suspend() run on a still-suspended context, so the later resume() left it running with no nodes. Cover paused, connecting and voice-fallback releases in radioNextState tests.
300 lines
10 KiB
JavaScript
300 lines
10 KiB
JavaScript
import { normalizeRadioFilter } from '../../data/layerState.js';
|
|
import { RADIO_TUNER_DIRECTORY_LIMIT } from './policy.js';
|
|
|
|
export function createSelection({
|
|
state: layerState,
|
|
services,
|
|
parts,
|
|
source,
|
|
}) {
|
|
const { warmGroundFloor } = services.ground;
|
|
|
|
/** Durable Radio preferences; this surface never creates or plays audio. */
|
|
|
|
function setRadioParams(params = {}) {
|
|
const nextFilter = Object.hasOwn(params, 'filter')
|
|
? normalizeRadioFilter(params.filter)
|
|
: null;
|
|
if (Object.hasOwn(params, 'filter') && !nextFilter) return false;
|
|
const numericVolume = Object.hasOwn(params, 'volume')
|
|
? Number(params.volume)
|
|
: null;
|
|
if (Object.hasOwn(params, 'volume') && !Number.isFinite(numericVolume))
|
|
return false;
|
|
const nextVolume =
|
|
numericVolume === null
|
|
? null
|
|
: parts.volume.clampRadioVolume(numericVolume);
|
|
|
|
let changed = false;
|
|
let filterChanged = false;
|
|
let clearedCancelledPresentation = false;
|
|
if (nextFilter !== null) {
|
|
filterChanged = nextFilter !== layerState._filter;
|
|
if (
|
|
filterChanged &&
|
|
(layerState._tuningActive || layerState._tuningAwaitingStationId)
|
|
)
|
|
parts.tuning.endRadioTuning();
|
|
clearedCancelledPresentation = Boolean(
|
|
layerState._cancelledTuningPresentationStation,
|
|
);
|
|
layerState._cancelledTuningPresentationStation = null;
|
|
layerState._filter = nextFilter;
|
|
if (clearedCancelledPresentation) parts.rendering.updateSelectionEntity();
|
|
if (filterChanged) parts.clustering.resetRadioClusterOverlayIdentities();
|
|
changed ||= filterChanged || clearedCancelledPresentation;
|
|
}
|
|
if (nextVolume !== null && nextVolume !== layerState._userVolume) {
|
|
layerState._userVolume = nextVolume;
|
|
if (layerState._audio && !layerState._voiceDucked) {
|
|
parts.volume.cancelRadioVolumeTransition();
|
|
layerState._voiceRestoring = false;
|
|
layerState._audio.volume = nextVolume;
|
|
}
|
|
parts.tuningNoise.syncTuningNoiseGain();
|
|
changed = true;
|
|
}
|
|
if (changed && parts.interaction.radioPresentationAllowed()) {
|
|
parts.rendering.updateRenderVisibility();
|
|
if (filterChanged && layerState._dataSource?.clustering?.enabled) {
|
|
const clusterPoints = layerState._dataSource.clustering.clusterPoints;
|
|
layerState._dataSource.clustering.clusterPoints = !clusterPoints;
|
|
layerState._dataSource.clustering.clusterPoints = clusterPoints;
|
|
layerState._viewer?.scene?.requestRender();
|
|
}
|
|
parts.rendering.scheduleRadioOverlayPublish();
|
|
}
|
|
parts.presentation.emitState();
|
|
return true;
|
|
}
|
|
|
|
function getRadioParams() {
|
|
return { filter: layerState._filter, volume: layerState._userVolume };
|
|
}
|
|
|
|
// A paused or failed stream of another station would outlive its selection.
|
|
function releaseStaleRadioStream(stationId) {
|
|
if (
|
|
layerState._audioStationId &&
|
|
layerState._audioStationId !== stationId &&
|
|
['paused', 'error'].includes(layerState._audioState)
|
|
)
|
|
parts.playback.stopRadioPlayback();
|
|
}
|
|
|
|
/** Select a station. Playback occurs only when autoplay is explicitly true. */
|
|
|
|
function selectRadioStation(
|
|
id,
|
|
{
|
|
autoplay = false,
|
|
focus = false,
|
|
origin = 'programmatic',
|
|
attemptId = null,
|
|
cameraNavigation = null,
|
|
playFallback = null,
|
|
} = {},
|
|
) {
|
|
if (!parts.interaction.radioPresentationAllowed()) return false;
|
|
const station = layerState._stationById.get(String(id));
|
|
if (!station) return false;
|
|
if (!autoplay) releaseStaleRadioStream(station.id);
|
|
// Set after the release, which clears fallbacks.
|
|
if (playFallback) {
|
|
layerState._playFallbackId = playFallback.id;
|
|
layerState._playFallbackFocus = playFallback.focus;
|
|
}
|
|
layerState._tuningUnavailableStationId = null;
|
|
if (
|
|
!parts.navigation.radioCameraNavigationOwnsSelection(
|
|
cameraNavigation,
|
|
station,
|
|
)
|
|
) {
|
|
parts.navigation.invalidateRadioCameraNavigation();
|
|
}
|
|
if (
|
|
layerState._tuningActive ||
|
|
(layerState._tuningAwaitingStationId &&
|
|
layerState._tuningAwaitingStationId !== station.id)
|
|
)
|
|
parts.tuning.endRadioTuning();
|
|
layerState._cancelledTuningPresentationStation = null;
|
|
const changed = layerState._selectedId !== station.id;
|
|
layerState._selectedId = station.id;
|
|
const generation = ++layerState._selectionGeneration;
|
|
const sessionGeneration = layerState._sessionGeneration;
|
|
parts.rendering.updateSelectionEntity();
|
|
warmGroundFloor([{ lat: station.lat, lon: station.lon }]);
|
|
if (layerState._selectionTimer) clearTimeout(layerState._selectionTimer);
|
|
layerState._selectionTimer = setTimeout(() => {
|
|
layerState._selectionTimer = null;
|
|
if (
|
|
sessionGeneration === layerState._sessionGeneration &&
|
|
generation === layerState._selectionGeneration &&
|
|
layerState._selectedId === station.id
|
|
)
|
|
parts.rendering.updateSelectionEntity();
|
|
}, 1300);
|
|
if (focus) parts.navigation.focusStation(station);
|
|
parts.presentation.emitState();
|
|
if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId });
|
|
// A user step to another station cancels a playback voice prepared.
|
|
else if (origin === 'user' && changed)
|
|
parts.presentation.emitPlaybackControl('select', origin);
|
|
return true;
|
|
}
|
|
|
|
/** Select the previous or next station and optionally retain a UI-owned band order. */
|
|
|
|
function cycleRadioStation(
|
|
direction = 1,
|
|
{
|
|
rotate = false,
|
|
stationIds = null,
|
|
autoplay = true,
|
|
fallback = autoplay,
|
|
origin = 'programmatic',
|
|
} = {},
|
|
) {
|
|
if (!parts.interaction.radioPresentationAllowed()) return false;
|
|
const ranked =
|
|
Array.isArray(stationIds) && stationIds.length
|
|
? stationIds
|
|
.slice(0, RADIO_TUNER_DIRECTORY_LIMIT)
|
|
.map((id) => layerState._stationById.get(String(id)))
|
|
.filter(
|
|
(station) =>
|
|
station &&
|
|
parts.categories.stationMatchesRadioCategory(
|
|
station,
|
|
layerState._filter,
|
|
),
|
|
)
|
|
: parts.queries.rankedVisibleStations();
|
|
if (!ranked.length) return false;
|
|
const current = ranked.findIndex(
|
|
(station) => station.id === layerState._selectedId,
|
|
);
|
|
const nextIndex =
|
|
current < 0
|
|
? 0
|
|
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
|
|
// Voice defers playback, so its silent step keeps a fallback; silent UI steps do not.
|
|
const fallbackId =
|
|
fallback && ranked.length > 1
|
|
? ranked[(nextIndex + 1) % ranked.length].id
|
|
: null;
|
|
const rotationCameraState = rotate
|
|
? parts.navigation.radioCameraState()
|
|
: null;
|
|
const rotationNavigation = rotate
|
|
? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
|
|
: null;
|
|
const fallbackFocus =
|
|
fallback && rotate
|
|
? (fallbackStation) => {
|
|
parts.navigation.rotateRadioStationIntoView(
|
|
fallbackStation,
|
|
0.65,
|
|
rotationCameraState,
|
|
rotationNavigation,
|
|
);
|
|
return rotationNavigation;
|
|
}
|
|
: false;
|
|
const station = ranked[nextIndex];
|
|
if (rotate)
|
|
parts.navigation.rotateRadioStationIntoView(
|
|
station,
|
|
0.65,
|
|
rotationCameraState,
|
|
rotationNavigation,
|
|
);
|
|
return selectRadioStation(station.id, {
|
|
autoplay,
|
|
focus: false,
|
|
origin,
|
|
cameraNavigation: rotationNavigation,
|
|
playFallback: { id: fallbackId, focus: fallbackFocus },
|
|
});
|
|
}
|
|
|
|
/** Select and optionally play the best station for a location/category request. */
|
|
|
|
function selectRequestedRadioStation(
|
|
criteria = {},
|
|
{ autoplay = true, origin = 'programmatic' } = {},
|
|
) {
|
|
if (!parts.interaction.radioPresentationAllowed()) return null;
|
|
const requestedCategory = String(criteria.categoryId || 'all');
|
|
const categoryId = layerState._categories.some(
|
|
(category) => category.id === requestedCategory,
|
|
)
|
|
? requestedCategory
|
|
: 'all';
|
|
setRadioFilter(categoryId);
|
|
const ranked = parts.queries.rankRadioStationsForRequest(
|
|
layerState._stations,
|
|
{
|
|
...criteria,
|
|
categoryId,
|
|
anchor: criteria.anchor || parts.queries.viewportRadioAnchor(),
|
|
},
|
|
);
|
|
if (!ranked.length) return null;
|
|
selectRadioStation(ranked[0].id, {
|
|
autoplay,
|
|
focus: false,
|
|
origin,
|
|
playFallback: { id: ranked[1]?.id || null, focus: false },
|
|
});
|
|
return ranked[0];
|
|
}
|
|
|
|
/** Change marker/list category without interrupting an active station. */
|
|
|
|
function setRadioFilter(categoryId) {
|
|
if (!parts.interaction.radioPresentationAllowed()) return false;
|
|
const valid = layerState._categories.some(
|
|
(category) => category.id === categoryId,
|
|
);
|
|
const nextFilter = valid ? categoryId : 'all';
|
|
const changed = nextFilter !== layerState._filter;
|
|
if (
|
|
changed &&
|
|
(layerState._tuningActive || layerState._tuningAwaitingStationId)
|
|
)
|
|
parts.tuning.endRadioTuning();
|
|
const clearsCancelledPresentation = Boolean(
|
|
layerState._cancelledTuningPresentationStation,
|
|
);
|
|
layerState._cancelledTuningPresentationStation = null;
|
|
layerState._filter = nextFilter;
|
|
if (clearsCancelledPresentation) parts.rendering.updateSelectionEntity();
|
|
if (changed) parts.clustering.resetRadioClusterOverlayIdentities();
|
|
parts.rendering.updateRenderVisibility();
|
|
if (changed && layerState._dataSource?.clustering?.enabled) {
|
|
// Entity visibility changes do not invalidate Cesium's existing cluster
|
|
// primitives. Toggle a public clustering input twice to mark the current
|
|
// cluster set dirty without changing its effective configuration.
|
|
const clusterPoints = layerState._dataSource.clustering.clusterPoints;
|
|
layerState._dataSource.clustering.clusterPoints = !clusterPoints;
|
|
layerState._dataSource.clustering.clusterPoints = clusterPoints;
|
|
layerState._viewer?.scene?.requestRender();
|
|
}
|
|
parts.rendering.scheduleRadioOverlayPublish();
|
|
parts.presentation.emitState();
|
|
return true;
|
|
}
|
|
return {
|
|
setRadioParams,
|
|
getRadioParams,
|
|
selectRadioStation,
|
|
cycleRadioStation,
|
|
selectRequestedRadioStation,
|
|
setRadioFilter,
|
|
};
|
|
}
|