mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor(radio): separate directory and playback components
This commit is contained in:
1 parent
ba7fda74d7
commit
41b3cc1705
30 files changed
+4488
-2795
No files matched your search
@@ -59,6 +59,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Traffic now retries a failed destination after city navigation without a layer
|
||||
|
||||
@@ -3292,3 +3292,14 @@ Cesium post-render events over one-second windows and does not request extra
|
||||
frames. Typing fields, modified keys and key repeats do not toggle it. The
|
||||
readout starts hidden each session and releases its timer and frame listener
|
||||
when hidden or when the application is disposed.
|
||||
|
||||
|
||||
## Radio components
|
||||
|
||||
The radio entry composes one layer from directory ingestion, station queries,
|
||||
selection, globe rendering and playback modules. Its source supplies directory
|
||||
metadata and click reporting; scene, ground and overlay services are provided
|
||||
explicitly. Each constructed layer owns its catalog, audio and lifecycle state.
|
||||
Existing catalog validation, category filters, tuning and voice playback behavior
|
||||
remain unchanged. Audio connects directly to the broadcaster after an explicit
|
||||
play action; the source does not relay or record streams.
|
||||
+2
-1
@@ -155,6 +155,7 @@
|
||||
"./layers/awareness": "./src/layers/awareness/index.js",
|
||||
"./layers/traffic": "./src/layers/traffic/index.js",
|
||||
"./layers/bikeshare": "./src/layers/bikeshare/index.js",
|
||||
"./layers/cctv": "./src/layers/cctv/index.js"
|
||||
"./layers/cctv": "./src/layers/cctv/index.js",
|
||||
"./layers/radio": "./src/layers/radio/index.js"
|
||||
}
|
||||
}
|
||||
@@ -455,5 +455,29 @@
|
||||
"src/layers/cctv/ground.js",
|
||||
"src/layers/cctv/health.js",
|
||||
"src/data/cctv.js",
|
||||
"src/layers/cctv/source.test.mjs"
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
"src/layers/radio/catalogModel.js",
|
||||
"src/layers/radio/categories.js",
|
||||
"src/layers/radio/clustering.js",
|
||||
"src/layers/radio/controls.js",
|
||||
"src/layers/radio/index.js",
|
||||
"src/layers/radio/ingestion.js",
|
||||
"src/layers/radio/interaction.js",
|
||||
"src/layers/radio/labels.js",
|
||||
"src/layers/radio/lifecycle.js",
|
||||
"src/layers/radio/model.js",
|
||||
"src/layers/radio/navigation.js",
|
||||
"src/layers/radio/playback.js",
|
||||
"src/layers/radio/policy.js",
|
||||
"src/layers/radio/presentation.js",
|
||||
"src/layers/radio/queries.js",
|
||||
"src/layers/radio/rendering.js",
|
||||
"src/layers/radio/selection.js",
|
||||
"src/layers/radio/source.js",
|
||||
"src/layers/radio/state.js",
|
||||
"src/layers/radio/tuning.js",
|
||||
"src/layers/radio/tuningNoise.js",
|
||||
"src/layers/radio/volume.js",
|
||||
"src/data/radio.js",
|
||||
"src/layers/radio/source.test.mjs"
|
||||
]
|
||||
@@ -893,5 +893,37 @@
|
||||
"src/overlays/worldOverlayTokens.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"radio-layer": {
|
||||
"exports": ["./layers/radio"],
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/radioCountry.js",
|
||||
"src/layers/radio/catalogModel.js",
|
||||
"src/layers/radio/categories.js",
|
||||
"src/layers/radio/clustering.js",
|
||||
"src/layers/radio/controls.js",
|
||||
"src/layers/radio/index.js",
|
||||
"src/layers/radio/ingestion.js",
|
||||
"src/layers/radio/interaction.js",
|
||||
"src/layers/radio/labels.js",
|
||||
"src/layers/radio/lifecycle.js",
|
||||
"src/layers/radio/model.js",
|
||||
"src/layers/radio/navigation.js",
|
||||
"src/layers/radio/playback.js",
|
||||
"src/layers/radio/policy.js",
|
||||
"src/layers/radio/presentation.js",
|
||||
"src/layers/radio/queries.js",
|
||||
"src/layers/radio/rendering.js",
|
||||
"src/layers/radio/selection.js",
|
||||
"src/layers/radio/source.js",
|
||||
"src/layers/radio/state.js",
|
||||
"src/layers/radio/tuning.js",
|
||||
"src/layers/radio/tuningNoise.js",
|
||||
"src/layers/radio/volume.js",
|
||||
"src/ui/radioTunerModel.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
}
|
||||
}
|
||||
+97
-2788
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,147 @@
|
||||
import { normalizeRadioCountryInput } from '../../data/radioCountry.js';
|
||||
import { RADIO_UUID_RE } from './policy.js';
|
||||
|
||||
export function createCatalogModel({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
function isNonGlobalRadioIpv4(hostname) {
|
||||
const pieces = hostname.split('.');
|
||||
if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece)))
|
||||
return false;
|
||||
const values = pieces.map(Number);
|
||||
if (values.some((value) => value > 255)) return true;
|
||||
const [a, b, c] = values;
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
a >= 224 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 0) ||
|
||||
(a === 192 && b === 88 && c === 99) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 198 && (b === 18 || b === 19)) ||
|
||||
(a === 198 && b === 51 && c === 100) ||
|
||||
(a === 203 && b === 0 && c === 113)
|
||||
);
|
||||
}
|
||||
|
||||
function isSafeRadioHttpsUrl(value) {
|
||||
if (typeof value !== 'string' || !value) return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const hostname = url.hostname
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.replace(/\.$/, '');
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
!hostname
|
||||
)
|
||||
return false;
|
||||
return !(
|
||||
hostname === 'localhost' ||
|
||||
hostname.endsWith('.localhost') ||
|
||||
hostname.endsWith('.local') ||
|
||||
isNonGlobalRadioIpv4(hostname) ||
|
||||
hostname.includes(':')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidRadioDirectoryStation(station) {
|
||||
const cleanText = (value, maxLength, { allowEmpty = true } = {}) =>
|
||||
typeof value === 'string' &&
|
||||
value.length <= maxLength &&
|
||||
(allowEmpty || value.trim().length > 0) &&
|
||||
!/[\u0000-\u001f\u007f]/.test(value) &&
|
||||
value === value.trim() &&
|
||||
!/\s{2,}/.test(value);
|
||||
const textArray = (value, limit, itemMaxLength) =>
|
||||
Array.isArray(value) &&
|
||||
value.length <= limit &&
|
||||
value.every((item) =>
|
||||
cleanText(item, itemMaxLength, { allowEmpty: false }),
|
||||
);
|
||||
return Boolean(
|
||||
station &&
|
||||
RADIO_UUID_RE.test(station.id) &&
|
||||
cleanText(station.name, 140, { allowEmpty: false }) &&
|
||||
Number.isFinite(station.lat) &&
|
||||
station.lat >= -90 &&
|
||||
station.lat <= 90 &&
|
||||
Number.isFinite(station.lon) &&
|
||||
station.lon >= -180 &&
|
||||
station.lon <= 180 &&
|
||||
isSafeRadioHttpsUrl(station.streamUrl) &&
|
||||
(station.homepage === null || isSafeRadioHttpsUrl(station.homepage)) &&
|
||||
textArray(station.tags, 24, 80) &&
|
||||
textArray(station.languages, 8, 40) &&
|
||||
cleanText(station.state, 80) &&
|
||||
cleanText(station.country, 80) &&
|
||||
cleanText(station.countryCode, 2) &&
|
||||
(station.countryCode === '' ||
|
||||
normalizeRadioCountryInput(station.countryCode).valid) &&
|
||||
station.metadataTrust === 'untrusted-community' &&
|
||||
cleanText(station.codec, 16, { allowEmpty: false }) &&
|
||||
/^(?:MP3|AAC(?:\+|-LC|-HE)?|HE-AAC)$/i.test(station.codec) &&
|
||||
(station.bitrate === null ||
|
||||
(Number.isInteger(station.bitrate) &&
|
||||
station.bitrate >= 8 &&
|
||||
station.bitrate <= 1024)),
|
||||
);
|
||||
}
|
||||
|
||||
function freezeRadioStation(station) {
|
||||
return Object.freeze({
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
lat: station.lat,
|
||||
lon: station.lon,
|
||||
streamUrl: station.streamUrl,
|
||||
homepage: station.homepage,
|
||||
tags: Object.freeze([...station.tags]),
|
||||
languages: Object.freeze([...station.languages]),
|
||||
state: station.state,
|
||||
country: station.country,
|
||||
countryCode: station.countryCode,
|
||||
metadataTrust: station.metadataTrust,
|
||||
codec: station.codec,
|
||||
bitrate: station.bitrate,
|
||||
});
|
||||
}
|
||||
|
||||
function createAcceptedCatalogSnapshot(
|
||||
instance,
|
||||
generation,
|
||||
updatedAt,
|
||||
stations,
|
||||
) {
|
||||
if (!Number.isSafeInteger(generation) || generation < 1) return null;
|
||||
if (typeof instance !== 'string' || !instance) return null;
|
||||
const frozenStations = Object.freeze(stations.map(freezeRadioStation));
|
||||
return Object.freeze({
|
||||
instance,
|
||||
generation,
|
||||
updatedAt,
|
||||
stations: frozenStations,
|
||||
stationIds: Object.freeze(frozenStations.map((station) => station.id)),
|
||||
});
|
||||
}
|
||||
return {
|
||||
isNonGlobalRadioIpv4,
|
||||
isSafeRadioHttpsUrl,
|
||||
isValidRadioDirectoryStation,
|
||||
freezeRadioStation,
|
||||
createAcceptedCatalogSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { MUSIC_GENRES, CATEGORY_MATCHERS } from './policy.js';
|
||||
|
||||
export function createCategories({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/** Normalize one directory tag to a stable, lower-case display token. */
|
||||
|
||||
function normalizeRadioTag(value) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function stationTags(station) {
|
||||
if (Array.isArray(station?.tags))
|
||||
return station.tags.map(normalizeRadioTag).filter(Boolean);
|
||||
return String(station?.tags ?? '')
|
||||
.split(',')
|
||||
.map(normalizeRadioTag)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasTag(station, needles) {
|
||||
const tags = stationTags(station);
|
||||
return needles.some((needle) =>
|
||||
tags.some((tag) => tag === needle || tag.includes(needle)),
|
||||
);
|
||||
}
|
||||
|
||||
function detectedGenres(station) {
|
||||
return MUSIC_GENRES.filter(([genre]) => hasTag(station, [genre])).map(
|
||||
([genre]) => genre,
|
||||
);
|
||||
}
|
||||
|
||||
/** Return whether a station belongs in a station-tag category. */
|
||||
|
||||
function stationMatchesRadioCategory(station, categoryId) {
|
||||
if (categoryId === 'all') return true;
|
||||
if (categoryId.startsWith('genre:')) {
|
||||
return detectedGenres(station).includes(
|
||||
categoryId.slice('genre:'.length),
|
||||
);
|
||||
}
|
||||
if (categoryId === 'music') {
|
||||
return (
|
||||
detectedGenres(station).length > 0 ||
|
||||
hasTag(station, ['music', 'hits', 'songs'])
|
||||
);
|
||||
}
|
||||
if (categoryId === 'other') {
|
||||
return (
|
||||
!Object.entries(CATEGORY_MATCHERS).some(([id]) =>
|
||||
stationMatchesRadioCategory(station, id),
|
||||
) && !stationMatchesRadioCategory(station, 'music')
|
||||
);
|
||||
}
|
||||
return hasTag(station, CATEGORY_MATCHERS[categoryId] || []);
|
||||
}
|
||||
|
||||
/** Build canonical and detected-genre categories from station-level tags. */
|
||||
|
||||
function buildRadioCategories(stations) {
|
||||
const rows = Array.isArray(stations) ? stations : [];
|
||||
const categories = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'news', label: 'News' },
|
||||
{ id: 'talk', label: 'Talk' },
|
||||
{ id: 'weather', label: 'Weather / Emergency' },
|
||||
{ id: 'public-safety', label: 'Public Safety' },
|
||||
{ id: 'aviation-marine', label: 'Aviation / Marine' },
|
||||
{ id: 'traffic-transit', label: 'Traffic / Transit' },
|
||||
{ id: 'music', label: 'Music' },
|
||||
];
|
||||
|
||||
for (const [genre, label] of MUSIC_GENRES) {
|
||||
const id = `genre:${genre}`;
|
||||
if (rows.some((station) => stationMatchesRadioCategory(station, id))) {
|
||||
categories.push({ id, label });
|
||||
}
|
||||
}
|
||||
categories.push({ id: 'other', label: 'Other' });
|
||||
return categories.map((category) => ({
|
||||
...category,
|
||||
color: parts.model.radioCategoryColor(category.id),
|
||||
count: rows.filter((station) =>
|
||||
stationMatchesRadioCategory(station, category.id),
|
||||
).length,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Filter stations without changing the active stream or selection. */
|
||||
|
||||
function filterRadioStations(stations, categoryId = 'all') {
|
||||
return (Array.isArray(stations) ? stations : []).filter((station) =>
|
||||
stationMatchesRadioCategory(station, categoryId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Return whether Radio Browser metadata identifies a station as English-language. */
|
||||
|
||||
function isEnglishRadioStation(station) {
|
||||
const languages = Array.isArray(station?.languages)
|
||||
? station.languages
|
||||
: [];
|
||||
return languages.some((language) => {
|
||||
const normalized = normalizeRadioTag(language);
|
||||
return (
|
||||
normalized === 'en' ||
|
||||
normalized === 'eng' ||
|
||||
normalized.startsWith('english')
|
||||
);
|
||||
});
|
||||
}
|
||||
return {
|
||||
normalizeRadioTag,
|
||||
stationTags,
|
||||
hasTag,
|
||||
detectedGenres,
|
||||
stationMatchesRadioCategory,
|
||||
buildRadioCategories,
|
||||
filterRadioStations,
|
||||
isEnglishRadioStation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
GLOBAL_RADIO_ALTITUDE_M,
|
||||
RADIO_SINGLETON_GLOBAL_LIMIT,
|
||||
RADIO_SINGLETON_MID_LIMIT,
|
||||
RADIO_SINGLETON_NEAR_LIMIT,
|
||||
RADIO_OVERLAY_COHORT_LIMIT,
|
||||
} from './policy.js';
|
||||
|
||||
export function createClustering({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/** Return the bounded singleton-label allowance for the current camera scale. */
|
||||
|
||||
function radioSingletonLabelLimit(cameraHeightM) {
|
||||
const height = Math.max(0, Number(cameraHeightM) || 0);
|
||||
if (height >= GLOBAL_RADIO_ALTITUDE_M) return RADIO_SINGLETON_GLOBAL_LIMIT;
|
||||
if (height >= 250_000) return RADIO_SINGLETON_MID_LIMIT;
|
||||
return RADIO_SINGLETON_NEAR_LIMIT;
|
||||
}
|
||||
|
||||
/** Rank visible singleton stations by camera distance and stable station id. */
|
||||
|
||||
function selectRadioSingletonCandidates(
|
||||
candidates,
|
||||
limit = RADIO_SINGLETON_NEAR_LIMIT,
|
||||
) {
|
||||
const distance = (candidate) => {
|
||||
const value = Number(candidate?.distanceM);
|
||||
return Number.isFinite(value) ? value : Number.POSITIVE_INFINITY;
|
||||
};
|
||||
return [...(candidates || [])]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
distance(a) - distance(b) ||
|
||||
String(a?.station?.id || a?.id || '').localeCompare(
|
||||
String(b?.station?.id || b?.id || ''),
|
||||
),
|
||||
)
|
||||
.slice(0, Math.max(0, Math.floor(Number(limit) || 0)));
|
||||
}
|
||||
|
||||
/** Rank and cap ambient Radio clusters before shared-host entry allocation. */
|
||||
|
||||
function selectRadioClusterCandidates(
|
||||
candidates,
|
||||
limit = RADIO_OVERLAY_COHORT_LIMIT,
|
||||
) {
|
||||
return [...(candidates || [])]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(Number(b?.stationCount) || 0) - (Number(a?.stationCount) || 0) ||
|
||||
String(a?.id || '').localeCompare(String(b?.id || '')),
|
||||
)
|
||||
.slice(0, Math.max(0, Math.floor(Number(limit) || 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve one-to-one overlay identity for overlapping clusters.
|
||||
* Cesium clustering is screen-space, so a small camera move may alter exact
|
||||
* membership even when the same geographic cluster remains visible. Every
|
||||
* positive overlap participates in greatest-contributor discovery, while
|
||||
* inheritance remains mutual-best: a current cluster accepts only one of its
|
||||
* greatest contributors and a prior identity transfers only to one of its
|
||||
* strongest split children.
|
||||
*/
|
||||
|
||||
function reconcileRadioClusterCandidates(
|
||||
candidates,
|
||||
previous = [],
|
||||
createId = null,
|
||||
) {
|
||||
const current = (Array.isArray(candidates) ? candidates : []).map(
|
||||
(candidate, index) => {
|
||||
const stationIds = [
|
||||
...new Set((candidate?.stationIds || []).map((id) => String(id))),
|
||||
].sort();
|
||||
const membershipId = String(
|
||||
candidate?.id || `membership:${stationIds.join('|')}`,
|
||||
);
|
||||
return {
|
||||
...candidate,
|
||||
_reconcileIndex: index,
|
||||
_reconcileMembershipId: membershipId,
|
||||
_reconcileCanonicalKey: `${membershipId}\u0000${stationIds.join('\u0000')}`,
|
||||
stationIds,
|
||||
};
|
||||
},
|
||||
);
|
||||
const priorMembershipsByIdentity = new Map();
|
||||
for (const candidate of Array.isArray(previous) ? previous : []) {
|
||||
const identityId = String(candidate?.identityId || candidate?.id || '');
|
||||
if (!identityId) continue;
|
||||
if (!priorMembershipsByIdentity.has(identityId)) {
|
||||
priorMembershipsByIdentity.set(identityId, new Set());
|
||||
}
|
||||
const membership = priorMembershipsByIdentity.get(identityId);
|
||||
for (const stationId of candidate?.stationIds || [])
|
||||
membership.add(String(stationId));
|
||||
}
|
||||
const prior = [...priorMembershipsByIdentity]
|
||||
.map(([identityId, stationIds]) => ({
|
||||
identityId,
|
||||
stationIds: [...stationIds].sort(),
|
||||
}))
|
||||
.filter((candidate) => candidate.stationIds.length)
|
||||
.sort((a, b) => a.identityId.localeCompare(b.identityId));
|
||||
const priorByStation = new Map();
|
||||
for (let priorIndex = 0; priorIndex < prior.length; priorIndex += 1) {
|
||||
for (const stationId of prior[priorIndex].stationIds) {
|
||||
if (!priorByStation.has(stationId)) priorByStation.set(stationId, []);
|
||||
priorByStation.get(stationId).push(priorIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const edges = [];
|
||||
const greatestOverlapByCurrent = new Map();
|
||||
const greatestOverlapByPrior = new Map();
|
||||
for (
|
||||
let currentIndex = 0;
|
||||
currentIndex < current.length;
|
||||
currentIndex += 1
|
||||
) {
|
||||
const overlapByPrior = new Map();
|
||||
for (const stationId of current[currentIndex].stationIds) {
|
||||
for (const priorIndex of priorByStation.get(stationId) || []) {
|
||||
overlapByPrior.set(
|
||||
priorIndex,
|
||||
(overlapByPrior.get(priorIndex) || 0) + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [priorIndex, overlap] of overlapByPrior) {
|
||||
const union =
|
||||
current[currentIndex].stationIds.length +
|
||||
prior[priorIndex].stationIds.length -
|
||||
overlap;
|
||||
const smaller = Math.min(
|
||||
current[currentIndex].stationIds.length,
|
||||
prior[priorIndex].stationIds.length,
|
||||
);
|
||||
const score = smaller > 0 ? overlap / smaller : 0;
|
||||
const similarity = union > 0 ? overlap / union : 0;
|
||||
edges.push({ currentIndex, priorIndex, overlap, score, similarity });
|
||||
greatestOverlapByCurrent.set(
|
||||
currentIndex,
|
||||
Math.max(greatestOverlapByCurrent.get(currentIndex) || 0, overlap),
|
||||
);
|
||||
greatestOverlapByPrior.set(
|
||||
priorIndex,
|
||||
Math.max(greatestOverlapByPrior.get(priorIndex) || 0, overlap),
|
||||
);
|
||||
}
|
||||
}
|
||||
const eligibleEdges = edges.filter(
|
||||
(edge) =>
|
||||
edge.overlap === greatestOverlapByCurrent.get(edge.currentIndex) &&
|
||||
edge.overlap === greatestOverlapByPrior.get(edge.priorIndex),
|
||||
);
|
||||
eligibleEdges.sort(
|
||||
(a, b) =>
|
||||
// Identity follows the contributor representing the largest share of the
|
||||
// new cluster. Overlap-coefficient-first incorrectly lets a fully retained
|
||||
// two-station minority beat a larger partial contributor during a merge.
|
||||
b.overlap - a.overlap ||
|
||||
b.score - a.score ||
|
||||
b.similarity - a.similarity ||
|
||||
current[a.currentIndex]._reconcileCanonicalKey.localeCompare(
|
||||
current[b.currentIndex]._reconcileCanonicalKey,
|
||||
) ||
|
||||
prior[a.priorIndex].identityId.localeCompare(
|
||||
prior[b.priorIndex].identityId,
|
||||
),
|
||||
);
|
||||
|
||||
const inheritedByCurrent = new Map();
|
||||
const usedPriorIdentities = new Set();
|
||||
for (const edge of eligibleEdges) {
|
||||
const identityId = prior[edge.priorIndex].identityId;
|
||||
if (
|
||||
inheritedByCurrent.has(edge.currentIndex) ||
|
||||
usedPriorIdentities.has(identityId)
|
||||
)
|
||||
continue;
|
||||
inheritedByCurrent.set(
|
||||
edge.currentIndex,
|
||||
prior[edge.priorIndex].identityId,
|
||||
);
|
||||
usedPriorIdentities.add(identityId);
|
||||
}
|
||||
|
||||
const generatedByCurrent = new Map();
|
||||
if (typeof createId === 'function') {
|
||||
const freshIndices = current
|
||||
.map((candidate, index) => ({ candidate, index }))
|
||||
.filter(({ index }) => !inheritedByCurrent.has(index))
|
||||
.sort((a, b) =>
|
||||
a.candidate._reconcileCanonicalKey.localeCompare(
|
||||
b.candidate._reconcileCanonicalKey,
|
||||
),
|
||||
);
|
||||
for (const { candidate, index } of freshIndices) {
|
||||
generatedByCurrent.set(index, String(createId(candidate, index) || ''));
|
||||
}
|
||||
}
|
||||
|
||||
return current.map((candidate, index) => {
|
||||
const membershipId = candidate._reconcileMembershipId;
|
||||
const inherited = inheritedByCurrent.get(index);
|
||||
const generated = generatedByCurrent.get(index) || '';
|
||||
const identityId = inherited || generated || membershipId;
|
||||
const {
|
||||
_reconcileIndex,
|
||||
_reconcileMembershipId,
|
||||
_reconcileCanonicalKey,
|
||||
...rest
|
||||
} = candidate;
|
||||
return { ...rest, membershipId, identityId, id: identityId };
|
||||
});
|
||||
}
|
||||
|
||||
function resetRadioClusterOverlayIdentities() {
|
||||
layerState._clusterOverlayIdentities = [];
|
||||
}
|
||||
|
||||
function emptyRadioOverlayDiagnostics() {
|
||||
return {
|
||||
entryCount: 0,
|
||||
selectedCount: 0,
|
||||
singletonTexts: [],
|
||||
singletonIds: [],
|
||||
clusterTexts: [],
|
||||
clusterIds: [],
|
||||
clusterMemberships: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Retain refresh identities only while every represented station still exists. */
|
||||
|
||||
function retainRadioClusterIdentitiesForStations(previous, stations) {
|
||||
const stationIds = new Set(
|
||||
(stations || [])
|
||||
.map((station) => String(station?.id || ''))
|
||||
.filter(Boolean),
|
||||
);
|
||||
return (previous || []).filter(
|
||||
(candidate) =>
|
||||
Array.isArray(candidate?.stationIds) &&
|
||||
candidate.stationIds.length > 0 &&
|
||||
candidate.stationIds.every((stationId) =>
|
||||
stationIds.has(String(stationId)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return {
|
||||
radioSingletonLabelLimit,
|
||||
selectRadioSingletonCandidates,
|
||||
selectRadioClusterCandidates,
|
||||
reconcileRadioClusterCandidates,
|
||||
resetRadioClusterOverlayIdentities,
|
||||
emptyRadioOverlayDiagnostics,
|
||||
retainRadioClusterIdentitiesForStations,
|
||||
};
|
||||
}
|
||||
Vendored
+119
@@ -0,0 +1,119 @@
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const methods = {
|
||||
id: 'radio',
|
||||
|
||||
name: 'Radio',
|
||||
|
||||
icon: '◉',
|
||||
|
||||
source: 'Radio Browser',
|
||||
|
||||
updateInterval: 45 * 60 * 1000,
|
||||
|
||||
/** Apply the manager-owned lifecycle gate to visible and pickable Radio state. */
|
||||
setLifecyclePresentation({
|
||||
lifecycleState = null,
|
||||
enabled = false,
|
||||
uncertain = false,
|
||||
} = {}) {
|
||||
const settledState = enabled ? 'enabled' : 'disabled';
|
||||
const normalizedState = [
|
||||
'enabling',
|
||||
'enabled',
|
||||
'disabling',
|
||||
'disabled',
|
||||
].includes(lifecycleState)
|
||||
? lifecycleState
|
||||
: settledState;
|
||||
layerState._managerLifecyclePresentation = {
|
||||
lifecycleState: normalizedState,
|
||||
enabled: Boolean(enabled),
|
||||
uncertain: Boolean(uncertain),
|
||||
};
|
||||
parts.interaction.syncRadioLifecyclePresentation();
|
||||
parts.presentation.emitState();
|
||||
},
|
||||
|
||||
/** Layer statistics for HUD/debug surfaces. */
|
||||
getStats() {
|
||||
return {
|
||||
count: layerState._stations.length,
|
||||
filtered: parts.queries.visibleStations().length,
|
||||
selected: layerState._selectedId,
|
||||
playing: layerState._audioStationId,
|
||||
stale: layerState._stale,
|
||||
degraded: layerState._degraded,
|
||||
loading: layerState._loading,
|
||||
error: layerState._error,
|
||||
lastUpdate: layerState._updatedAt
|
||||
? Date.parse(layerState._updatedAt)
|
||||
: null,
|
||||
horizonScans: layerState._horizonScanCount,
|
||||
overlayEntries: layerState._overlayDiagnostics.entryCount,
|
||||
};
|
||||
},
|
||||
|
||||
subscribe: parts.presentation.subscribeToRadio,
|
||||
|
||||
subscribePlaybackControls:
|
||||
parts.presentation.subscribeToRadioPlaybackControls,
|
||||
|
||||
getAcceptedCatalogSnapshot:
|
||||
parts.presentation.getRadioAcceptedCatalogSnapshot,
|
||||
|
||||
getUIState: parts.presentation.getRadioUIState,
|
||||
|
||||
getParams: parts.selection.getRadioParams,
|
||||
|
||||
setParams: parts.selection.setRadioParams,
|
||||
|
||||
getOverlayDiagnostics: () => ({
|
||||
...layerState._overlayDiagnostics,
|
||||
singletonTexts: [...layerState._overlayDiagnostics.singletonTexts],
|
||||
singletonIds: [...layerState._overlayDiagnostics.singletonIds],
|
||||
clusterTexts: [...layerState._overlayDiagnostics.clusterTexts],
|
||||
clusterIds: [...layerState._overlayDiagnostics.clusterIds],
|
||||
clusterMemberships: layerState._overlayDiagnostics.clusterMemberships.map(
|
||||
(entry) => ({ ...entry }),
|
||||
),
|
||||
}),
|
||||
|
||||
getTunerStations: parts.tuning.getRadioTunerStations,
|
||||
|
||||
beginTuning: parts.tuning.beginRadioTuning,
|
||||
|
||||
setTuningStatic: parts.tuning.setRadioTuningStatic,
|
||||
|
||||
previewTuningStation: parts.tuning.previewRadioTuningStation,
|
||||
|
||||
commitTuningStation: parts.tuning.commitRadioTuningStation,
|
||||
|
||||
cancelTuning: parts.tuning.cancelRadioTuning,
|
||||
|
||||
endTuning: parts.tuning.endRadioTuning,
|
||||
|
||||
setFilter: parts.selection.setRadioFilter,
|
||||
|
||||
selectStation: parts.selection.selectRadioStation,
|
||||
|
||||
selectRequestedStation: parts.selection.selectRequestedRadioStation,
|
||||
|
||||
cycleStation: parts.selection.cycleRadioStation,
|
||||
|
||||
togglePlayback: parts.playback.toggleRadioPlayback,
|
||||
|
||||
play: parts.playback.playSelectedRadio,
|
||||
|
||||
playForVoice: parts.playback.playPreparedRadioForVoice,
|
||||
|
||||
pause: parts.playback.pauseRadioPlayback,
|
||||
|
||||
stopPlayback: parts.playback.stopRadioPlayback,
|
||||
|
||||
setVolume: parts.volume.setRadioVolume,
|
||||
|
||||
setVoiceDucked: parts.volume.setRadioVoiceDucking,
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createCatalogModel } from './catalogModel.js';
|
||||
import { createLabels } from './labels.js';
|
||||
import { createClustering } from './clustering.js';
|
||||
import { createVolume } from './volume.js';
|
||||
import { createNavigation } from './navigation.js';
|
||||
import { createCategories } from './categories.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createInteraction } from './interaction.js';
|
||||
import { createTuning } from './tuning.js';
|
||||
import { createTuningNoise } from './tuningNoise.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createPresentation } from './presentation.js';
|
||||
import { createPlayback } from './playback.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
/** Construct one layer with its own scene state and supplied application services. */
|
||||
export function createRadioLayer({ services, source }) {
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
parts.catalogModel = createCatalogModel(context);
|
||||
parts.labels = createLabels(context);
|
||||
parts.clustering = createClustering(context);
|
||||
parts.volume = createVolume(context);
|
||||
parts.navigation = createNavigation(context);
|
||||
parts.categories = createCategories(context);
|
||||
parts.model = createModel(context);
|
||||
parts.queries = createQueries(context);
|
||||
parts.interaction = createInteraction(context);
|
||||
parts.tuning = createTuning(context);
|
||||
parts.tuningNoise = createTuningNoise(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.presentation = createPresentation(context);
|
||||
parts.playback = createPlayback(context);
|
||||
parts.selection = createSelection(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
return Object.assign(
|
||||
{},
|
||||
parts.controls.methods,
|
||||
parts.lifecycle.methods,
|
||||
parts.ingestion?.methods,
|
||||
{
|
||||
radioGlobeLabel: parts.labels.radioGlobeLabel,
|
||||
createRadioSelectedOverlayEntry:
|
||||
parts.labels.createRadioSelectedOverlayEntry,
|
||||
createRadioClusterOverlayEntry:
|
||||
parts.labels.createRadioClusterOverlayEntry,
|
||||
createRadioSingletonOverlayEntry:
|
||||
parts.labels.createRadioSingletonOverlayEntry,
|
||||
radioSingletonLabelLimit: parts.clustering.radioSingletonLabelLimit,
|
||||
selectRadioSingletonCandidates:
|
||||
parts.clustering.selectRadioSingletonCandidates,
|
||||
selectRadioClusterCandidates:
|
||||
parts.clustering.selectRadioClusterCandidates,
|
||||
reconcileRadioClusterCandidates:
|
||||
parts.clustering.reconcileRadioClusterCandidates,
|
||||
radioCameraPositionChanged: parts.navigation.radioCameraPositionChanged,
|
||||
normalizeRadioTag: parts.categories.normalizeRadioTag,
|
||||
stationMatchesRadioCategory: parts.categories.stationMatchesRadioCategory,
|
||||
radioCategoryColor: parts.model.radioCategoryColor,
|
||||
radioClusterBadgeText: parts.model.radioClusterBadgeText,
|
||||
radioClusterCategoryId: parts.model.radioClusterCategoryId,
|
||||
radioSelectionBracketSvg: parts.labels.radioSelectionBracketSvg,
|
||||
radioStationCategoryId: parts.model.radioStationCategoryId,
|
||||
buildRadioCategories: parts.categories.buildRadioCategories,
|
||||
filterRadioStations: parts.categories.filterRadioStations,
|
||||
isEnglishRadioStation: parts.categories.isEnglishRadioStation,
|
||||
rankRadioStationsForViewport: parts.queries.rankRadioStationsForViewport,
|
||||
rankRadioStationsForRequest: parts.queries.rankRadioStationsForRequest,
|
||||
radioViewIsGlobal: parts.navigation.radioViewIsGlobal,
|
||||
radioRequestIsCurrent: parts.model.radioRequestIsCurrent,
|
||||
radioStationIdFromPick: parts.interaction.radioStationIdFromPick,
|
||||
radioStationCameraPlan: parts.navigation.radioStationCameraPlan,
|
||||
radioGlobeNeedsRecentering: parts.navigation.radioGlobeNeedsRecentering,
|
||||
radioGlobeRecenterHeight: parts.navigation.radioGlobeRecenterHeight,
|
||||
buildRadioTunerBand: parts.tuning.buildRadioTunerBand,
|
||||
radioTuningStaticShouldPlay:
|
||||
parts.tuningNoise.radioTuningStaticShouldPlay,
|
||||
getRadioAcceptedCatalogSnapshot:
|
||||
parts.presentation.getRadioAcceptedCatalogSnapshot,
|
||||
radioStationResolutionMatches: parts.model.radioStationResolutionMatches,
|
||||
getRadioUIState: parts.presentation.getRadioUIState,
|
||||
subscribeToRadio: parts.presentation.subscribeToRadio,
|
||||
subscribeToRadioPlaybackControls:
|
||||
parts.presentation.subscribeToRadioPlaybackControls,
|
||||
getRadioTunerStations: parts.tuning.getRadioTunerStations,
|
||||
beginRadioTuning: parts.tuning.beginRadioTuning,
|
||||
setRadioTuningStatic: parts.tuning.setRadioTuningStatic,
|
||||
radioCameraNavigationAllowed:
|
||||
parts.navigation.radioCameraNavigationAllowed,
|
||||
previewRadioTuningStation: parts.tuning.previewRadioTuningStation,
|
||||
endRadioTuning: parts.tuning.endRadioTuning,
|
||||
cancelRadioTuning: parts.tuning.cancelRadioTuning,
|
||||
commitRadioTuningStation: parts.tuning.commitRadioTuningStation,
|
||||
playSelectedRadio: parts.playback.playSelectedRadio,
|
||||
confirmRadioPlayback: parts.playback.confirmRadioPlayback,
|
||||
playPreparedRadioForVoice: parts.playback.playPreparedRadioForVoice,
|
||||
stopRadioPlayback: parts.playback.stopRadioPlayback,
|
||||
toggleRadioPlayback: parts.playback.toggleRadioPlayback,
|
||||
pauseRadioPlayback: parts.playback.pauseRadioPlayback,
|
||||
setRadioVolume: parts.volume.setRadioVolume,
|
||||
setRadioParams: parts.selection.setRadioParams,
|
||||
getRadioParams: parts.selection.getRadioParams,
|
||||
setRadioVoiceDucking: parts.volume.setRadioVoiceDucking,
|
||||
selectRadioStation: parts.selection.selectRadioStation,
|
||||
cycleRadioStation: parts.selection.cycleRadioStation,
|
||||
selectRequestedRadioStation: parts.selection.selectRequestedRadioStation,
|
||||
setRadioFilter: parts.selection.setRadioFilter,
|
||||
retainRadioClusterIdentitiesForStations:
|
||||
parts.clustering.retainRadioClusterIdentitiesForStations,
|
||||
},
|
||||
);
|
||||
}
|
||||
export {
|
||||
RADIO_OVERLAY_SOURCE_ID,
|
||||
RADIO_OVERLAY_COHORT_LIMIT,
|
||||
RADIO_SINGLETON_GLOBAL_LIMIT,
|
||||
RADIO_SINGLETON_MID_LIMIT,
|
||||
RADIO_SINGLETON_NEAR_LIMIT,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
|
||||
RADIO_OVERLAY_SOURCE_OPTIONS,
|
||||
DEFAULT_RADIO_FILTER,
|
||||
GLOBAL_RADIO_ALTITUDE_M,
|
||||
} from './policy.js';
|
||||
|
||||
export { createRadioSource } from './source.js';
|
||||
export {
|
||||
radioTunerSlot,
|
||||
radioTunerCommitSlot,
|
||||
radioTunerPointerPosition,
|
||||
buildRadioTunerTicks,
|
||||
} from '../../ui/radioTunerModel.js';
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
RADIO_DIRECTORY_STALE_MS,
|
||||
RADIO_DIRECTORY_FUTURE_SKEW_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createIngestion({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const methods = {
|
||||
/** Refresh directory metadata through the hardened same-origin broker. */
|
||||
async update() {
|
||||
if (!layerState._enabled) return;
|
||||
const generation = ++layerState._requestGeneration;
|
||||
const sessionGeneration = layerState._sessionGeneration;
|
||||
layerState._abortController?.abort();
|
||||
layerState._abortController = new AbortController();
|
||||
layerState._loading = true;
|
||||
layerState._error = null;
|
||||
parts.presentation.emitState();
|
||||
try {
|
||||
const body = await source.getDirectory({
|
||||
signal: layerState._abortController.signal,
|
||||
});
|
||||
if (
|
||||
!parts.model.radioRequestIsCurrent(
|
||||
generation,
|
||||
layerState._requestGeneration,
|
||||
layerState._enabled,
|
||||
sessionGeneration,
|
||||
layerState._sessionGeneration,
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (!Array.isArray(body?.stations))
|
||||
throw new Error('Radio directory response was malformed');
|
||||
const updatedAt =
|
||||
typeof body.updatedAt === 'string' &&
|
||||
Number.isFinite(Date.parse(body.updatedAt))
|
||||
? body.updatedAt
|
||||
: null;
|
||||
const updatedAtMs = updatedAt ? Date.parse(updatedAt) : NaN;
|
||||
if (
|
||||
!updatedAt ||
|
||||
updatedAtMs < Date.now() - RADIO_DIRECTORY_STALE_MS ||
|
||||
updatedAtMs > Date.now() + RADIO_DIRECTORY_FUTURE_SKEW_MS ||
|
||||
typeof body.stale !== 'boolean' ||
|
||||
typeof body.degraded !== 'boolean'
|
||||
)
|
||||
throw new Error('Radio directory freshness metadata was malformed');
|
||||
const rows = body.stations;
|
||||
const acceptedRows = rows.filter(
|
||||
parts.catalogModel.isValidRadioDirectoryStation,
|
||||
);
|
||||
if (!acceptedRows.length)
|
||||
throw new Error('Radio directory returned no usable stations');
|
||||
if (acceptedRows.length !== rows.length) {
|
||||
throw new Error(
|
||||
'Radio directory response contained malformed stations',
|
||||
);
|
||||
}
|
||||
const acceptedGeneration = body.acceptedGeneration;
|
||||
if (
|
||||
acceptedGeneration !== null &&
|
||||
(!Number.isSafeInteger(acceptedGeneration) || acceptedGeneration < 1)
|
||||
)
|
||||
throw new Error('Radio directory generation metadata was malformed');
|
||||
if (!body.stale && !body.degraded && acceptedGeneration === null) {
|
||||
throw new Error('Radio directory omitted its accepted generation');
|
||||
}
|
||||
const catalogInstance = body.catalogInstance;
|
||||
if (
|
||||
!body.stale &&
|
||||
!body.degraded &&
|
||||
(typeof catalogInstance !== 'string' || !catalogInstance)
|
||||
) {
|
||||
throw new Error('Radio directory omitted its catalog instance');
|
||||
}
|
||||
const preservingWarmCatalog =
|
||||
layerState._stations.length > 0 && (body.stale || body.degraded);
|
||||
// Generations are only comparable within one producer instance. A new
|
||||
// instance token (server restart, different proxy process) starts a fresh
|
||||
// sequence: never a repeat and never a regression.
|
||||
const sameCatalogInstance =
|
||||
layerState._acceptedCatalogSnapshot?.instance === catalogInstance;
|
||||
const currentAcceptedGeneration = sameCatalogInstance
|
||||
? layerState._acceptedCatalogSnapshot?.generation
|
||||
: null;
|
||||
if (
|
||||
!body.stale &&
|
||||
!body.degraded &&
|
||||
Number.isSafeInteger(currentAcceptedGeneration) &&
|
||||
acceptedGeneration < currentAcceptedGeneration
|
||||
)
|
||||
throw new Error('Radio directory generation regressed');
|
||||
const repeatingAcceptedGeneration =
|
||||
!body.stale &&
|
||||
!body.degraded &&
|
||||
Number.isSafeInteger(currentAcceptedGeneration) &&
|
||||
acceptedGeneration === currentAcceptedGeneration;
|
||||
if (!preservingWarmCatalog) {
|
||||
const immutableRows = acceptedRows.map(
|
||||
parts.catalogModel.freezeRadioStation,
|
||||
);
|
||||
if (repeatingAcceptedGeneration) {
|
||||
layerState._updatedAt =
|
||||
layerState._acceptedCatalogSnapshot.updatedAt;
|
||||
if (
|
||||
!layerState._tuningActive &&
|
||||
layerState._cancelledTuningPresentationStation
|
||||
) {
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
parts.rendering.updateSelectionEntity();
|
||||
}
|
||||
} else if (!body.stale && !body.degraded) {
|
||||
const acceptedSnapshot =
|
||||
parts.catalogModel.createAcceptedCatalogSnapshot(
|
||||
catalogInstance,
|
||||
acceptedGeneration,
|
||||
updatedAt,
|
||||
immutableRows,
|
||||
);
|
||||
if (!acceptedSnapshot)
|
||||
throw new Error(
|
||||
'Radio directory generation metadata was malformed',
|
||||
);
|
||||
layerState._acceptedCatalogSnapshot = acceptedSnapshot;
|
||||
parts.rendering.reconcileStations(acceptedSnapshot.stations);
|
||||
} else {
|
||||
parts.rendering.reconcileStations(immutableRows);
|
||||
}
|
||||
if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt;
|
||||
}
|
||||
layerState._degraded = body.degraded;
|
||||
layerState._stale = body.stale;
|
||||
layerState._error = preservingWarmCatalog
|
||||
? 'Directory refresh degraded; showing the previous station catalog.'
|
||||
: layerState._degraded
|
||||
? 'Radio directory coverage is degraded.'
|
||||
: null;
|
||||
} catch (error) {
|
||||
if (
|
||||
error?.name === 'AbortError' ||
|
||||
!parts.model.radioRequestIsCurrent(
|
||||
generation,
|
||||
layerState._requestGeneration,
|
||||
layerState._enabled,
|
||||
sessionGeneration,
|
||||
layerState._sessionGeneration,
|
||||
)
|
||||
)
|
||||
return;
|
||||
layerState._error = layerState._stations.length
|
||||
? 'Directory refresh failed; showing the previous station catalog.'
|
||||
: 'Radio directory is temporarily unavailable.';
|
||||
layerState._stale = layerState._stations.length > 0;
|
||||
layerState._degraded = layerState._stations.length > 0;
|
||||
} finally {
|
||||
if (
|
||||
generation === layerState._requestGeneration &&
|
||||
sessionGeneration === layerState._sessionGeneration
|
||||
) {
|
||||
layerState._loading = false;
|
||||
layerState._abortController = null;
|
||||
parts.presentation.emitState();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
RADIO_PREFIX,
|
||||
RADIO_PICK_OFFSETS,
|
||||
RADIO_OVERLAY_SOURCE_ID,
|
||||
HORIZON_TICK_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createInteraction({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const {
|
||||
resolvePickId,
|
||||
isOwnedByOtherLayer,
|
||||
registerPickOwner,
|
||||
unregisterPickOwner,
|
||||
} = services.picking;
|
||||
const { setOverlaySourceVisible } = services.overlays;
|
||||
|
||||
/** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */
|
||||
|
||||
function radioStationIdFromPick(picked) {
|
||||
const pending = [picked?.id, picked?.primitive?.id];
|
||||
const seen = new Set();
|
||||
while (pending.length) {
|
||||
const value = pending.shift();
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
const id = String(value);
|
||||
if (id.startsWith(`${RADIO_PREFIX}selected:`))
|
||||
return id.slice(`${RADIO_PREFIX}selected:`.length);
|
||||
if (id.startsWith(RADIO_PREFIX)) return id.slice(RADIO_PREFIX.length);
|
||||
continue;
|
||||
}
|
||||
if (!value || typeof value !== 'object' || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) pending.push(...value);
|
||||
else {
|
||||
pending.push(value.id);
|
||||
if (value.primitive) pending.push(value.primitive.id);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickedRadioStationAt(position) {
|
||||
const scene = layerState._viewer?.scene;
|
||||
if (!scene || !position) return null;
|
||||
|
||||
const stationFromPick = (picked) => {
|
||||
const stationId = radioStationIdFromPick(picked);
|
||||
return stationId && layerState._stationById.has(stationId)
|
||||
? stationId
|
||||
: null;
|
||||
};
|
||||
const primaryPick = scene.pick(position);
|
||||
const primaryStationId = stationFromPick(primaryPick);
|
||||
if (primaryStationId) return primaryStationId;
|
||||
const primaryId = resolvePickId(primaryPick);
|
||||
if (primaryId && isOwnedByOtherLayer('radio', primaryId)) return null;
|
||||
|
||||
if (typeof scene.drillPick === 'function') {
|
||||
const drilled = scene.drillPick(position, 16) || [];
|
||||
for (const picked of drilled) {
|
||||
const stationId = stationFromPick(picked);
|
||||
if (stationId) return stationId;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [offsetX, offsetY] of RADIO_PICK_OFFSETS) {
|
||||
const offsetPosition = new Cesium.Cartesian2(
|
||||
position.x + offsetX,
|
||||
position.y + offsetY,
|
||||
);
|
||||
const picked = scene.pick(offsetPosition);
|
||||
const pickedId = resolvePickId(picked);
|
||||
if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue;
|
||||
const stationId = stationFromPick(picked);
|
||||
if (stationId) return stationId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function radioPresentationAllowed() {
|
||||
if (!layerState._managerLifecyclePresentation) return layerState._enabled;
|
||||
return (
|
||||
layerState._enabled &&
|
||||
layerState._managerLifecyclePresentation.lifecycleState === 'enabled' &&
|
||||
layerState._managerLifecyclePresentation.enabled &&
|
||||
!layerState._managerLifecyclePresentation.uncertain
|
||||
);
|
||||
}
|
||||
|
||||
function syncRadioLifecyclePresentation() {
|
||||
const visible = radioPresentationAllowed();
|
||||
if (layerState._dataSource) layerState._dataSource.show = visible;
|
||||
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible);
|
||||
if (visible) {
|
||||
installInteraction();
|
||||
parts.rendering.updateSelectionEntity();
|
||||
parts.rendering.updateRenderVisibility();
|
||||
parts.rendering.scheduleRadioOverlayPublish();
|
||||
} else {
|
||||
removeInteraction();
|
||||
if (layerState._selectedEntity && layerState._viewer)
|
||||
layerState._viewer.entities.remove(layerState._selectedEntity);
|
||||
layerState._selectedEntity = null;
|
||||
}
|
||||
}
|
||||
|
||||
function installInteraction() {
|
||||
if (!layerState._viewer || layerState._clickHandler) return;
|
||||
registerPickOwner('radio', (id) => id.startsWith(RADIO_PREFIX));
|
||||
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
layerState._viewer.scene.canvas,
|
||||
);
|
||||
layerState._clickHandler.setInputAction((click) => {
|
||||
if (!radioPresentationAllowed()) return;
|
||||
const stationId = pickedRadioStationAt(click.position);
|
||||
if (!stationId) return;
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
parts.selection.selectRadioStation(stationId, {
|
||||
autoplay: true,
|
||||
origin: 'user',
|
||||
});
|
||||
if (typeof document !== 'undefined') {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('gev:radio-selected', { detail: { stationId } }),
|
||||
);
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
// Polling remains bounded during flights, but a stationary camera no longer
|
||||
// rewrites every station's visibility four times per second while voice and
|
||||
// audio processing share the main thread.
|
||||
layerState._horizonTimer = setInterval(
|
||||
() => parts.rendering.updateRenderVisibility({ force: false }),
|
||||
HORIZON_TICK_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function removeInteraction() {
|
||||
unregisterPickOwner('radio');
|
||||
layerState._clickHandler?.destroy();
|
||||
layerState._clickHandler = null;
|
||||
if (layerState._horizonTimer) clearInterval(layerState._horizonTimer);
|
||||
layerState._horizonTimer = null;
|
||||
}
|
||||
return {
|
||||
radioStationIdFromPick,
|
||||
pickedRadioStationAt,
|
||||
radioPresentationAllowed,
|
||||
syncRadioLifecyclePresentation,
|
||||
installInteraction,
|
||||
removeInteraction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
RADIO_LABEL_SEGMENTER,
|
||||
RADIO_GLOBE_LABEL_MAX_CHARS,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
RADIO_CATEGORY_COLORS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createLabels({ state: layerState, services, parts, source }) {
|
||||
function compactRadioLabelText(value, maxChars) {
|
||||
const text = String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const graphemes = RADIO_LABEL_SEGMENTER
|
||||
? [...RADIO_LABEL_SEGMENTER.segment(text)].map(
|
||||
(segment) => segment.segment,
|
||||
)
|
||||
: Array.from(text);
|
||||
if (graphemes.length <= maxChars) return text;
|
||||
return `${graphemes
|
||||
.slice(0, Math.max(1, maxChars - 1))
|
||||
.join('')
|
||||
.trimEnd()}…`;
|
||||
}
|
||||
|
||||
function cleanRadioLabelName(value) {
|
||||
return String(value || '')
|
||||
.replace(/^[\s|:·\-–—]+|[\s|:·\-–—]+$/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\s+\([^()]{1,40}\)$/, '')
|
||||
.replace(/\s+FM$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Return compact frequency-first text for a Radio globe label. */
|
||||
|
||||
function radioGlobeLabel(station) {
|
||||
const fullName = String(station?.name || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!fullName) return '';
|
||||
|
||||
let frequency = null;
|
||||
let labelName = '';
|
||||
const explicitMatches = [
|
||||
...fullName.matchAll(/(\d{2,3}(?:\.\d{1,2})?)\s*FM\b/gi),
|
||||
];
|
||||
const explicit = explicitMatches.find((match) => {
|
||||
const value = Number(match[1]);
|
||||
return value >= 64 && value <= 108;
|
||||
});
|
||||
if (explicit) {
|
||||
frequency = explicit[1];
|
||||
const before = cleanRadioLabelName(fullName.slice(0, explicit.index));
|
||||
const after = cleanRadioLabelName(
|
||||
fullName.slice(explicit.index + explicit[0].length),
|
||||
);
|
||||
labelName = before || after;
|
||||
} else {
|
||||
// Infer only a leading decimal in the conventional FM band. Integers such
|
||||
// as "80's" or "100 GREATEST" and domains such as "1.fm" stay names.
|
||||
const leading = fullName.match(
|
||||
/^(\d{2,3}\.\d{1,2})(?:\s+|\s*[-–—]\s*)(.+)$/,
|
||||
);
|
||||
const value = Number(leading?.[1]);
|
||||
if (leading && value >= 87.5 && value <= 108) {
|
||||
frequency = leading[1];
|
||||
labelName = cleanRadioLabelName(leading[2].split(/\s+[-–—]\s+/)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!frequency)
|
||||
return compactRadioLabelText(fullName, RADIO_GLOBE_LABEL_MAX_CHARS);
|
||||
const prefix = `${frequency} FM`;
|
||||
if (!labelName) return prefix;
|
||||
const nameBudget = RADIO_GLOBE_LABEL_MAX_CHARS - prefix.length - 3;
|
||||
return `${prefix} — ${compactRadioLabelText(labelName, nameBudget)}`;
|
||||
}
|
||||
|
||||
/** Build the protected selected-station text published through WorldOverlay. */
|
||||
|
||||
function createRadioSelectedOverlayEntry(station, position) {
|
||||
if (!station?.id || !station?.name || !position) return null;
|
||||
return {
|
||||
id: `selected:${station.id}`,
|
||||
position,
|
||||
variant: 'selected',
|
||||
selected: true,
|
||||
protected: true,
|
||||
paintLane: 'selected',
|
||||
collisionGroup: 'ambient-label',
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
title: radioGlobeLabel(station),
|
||||
accent: parts.model.radioCategoryColor(
|
||||
parts.model.radioStationCategoryId(station),
|
||||
),
|
||||
interactive: false,
|
||||
anchorRadiusPx: 20,
|
||||
minAnchorGapPx: 8,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
gapPx: 8,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build one bounded ambient cluster badge for the shared overlay host. */
|
||||
|
||||
function createRadioClusterOverlayEntry({
|
||||
id,
|
||||
position,
|
||||
text,
|
||||
accent,
|
||||
stationCount,
|
||||
}) {
|
||||
if (!id || !position || !text) return null;
|
||||
return {
|
||||
id: `cluster:${id}`,
|
||||
position,
|
||||
variant: 'label',
|
||||
paintLane: 'ambient-label',
|
||||
collisionGroup: 'ambient-label',
|
||||
priority: Math.max(1, Number(stationCount) || 1),
|
||||
title: text,
|
||||
accent: accent || RADIO_CATEGORY_COLORS.other,
|
||||
interactive: false,
|
||||
anchorRadiusPx: Math.min(
|
||||
13,
|
||||
6 + Math.log2(Math.max(1, Number(stationCount) || 1)) * 0.8,
|
||||
),
|
||||
minAnchorGapPx: 4,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
gapPx: 6,
|
||||
// Cluster membership/counts update continuously while the camera moves.
|
||||
// Paint them as hard-opacity incumbents so a replacement count never
|
||||
// cross-fades with its predecessor or dims merely for leaving the keyhole.
|
||||
// Collision, viewport rejection, and horizon culling remain host-owned.
|
||||
stateless: true,
|
||||
edgeFade: 'none',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
// The camera can sit above 24,000 km in the supported full-globe view, and
|
||||
// horizon clusters are farther from it than the camera's surface altitude.
|
||||
maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
distanceScale: {
|
||||
near: 100_000,
|
||||
nearValue: 1.08,
|
||||
far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
farValue: 0.92,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build one ambient station label while Cesium retains its point and picking. */
|
||||
|
||||
function createRadioSingletonOverlayEntry({
|
||||
station,
|
||||
position,
|
||||
priority = 1,
|
||||
}) {
|
||||
if (!station?.id || !station?.name || !position) return null;
|
||||
return {
|
||||
id: `station:${station.id}`,
|
||||
position,
|
||||
variant: 'label',
|
||||
paintLane: 'ambient-label',
|
||||
collisionGroup: 'ambient-label',
|
||||
priority: Math.max(1, Number(priority) || 1),
|
||||
title: radioGlobeLabel(station),
|
||||
accent: parts.model.radioCategoryColor(
|
||||
parts.model.radioStationCategoryId(station),
|
||||
),
|
||||
interactive: false,
|
||||
anchorRadiusPx: 9,
|
||||
minAnchorGapPx: 4,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
gapPx: 5,
|
||||
// Camera/filter changes replace this bounded cohort. Prevent the prior
|
||||
// cohort from fading over the current visible points.
|
||||
stateless: true,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
distanceScale: {
|
||||
near: 100_000,
|
||||
nearValue: 1,
|
||||
far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
farValue: 0.86,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the code-native four-corner bracket used by the selected station. */
|
||||
|
||||
function radioSelectionBracketSvg(color = RADIO_CATEGORY_COLORS.other) {
|
||||
const stroke = /^#[0-9a-f]{6}$/i.test(String(color))
|
||||
? String(color)
|
||||
: RADIO_CATEGORY_COLORS.other;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><path d="M2 13V2H13 M27 2H38V13 M38 27V38H27 M13 38H2V27" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="square"/></svg>`;
|
||||
}
|
||||
return {
|
||||
compactRadioLabelText,
|
||||
cleanRadioLabelName,
|
||||
radioGlobeLabel,
|
||||
createRadioSelectedOverlayEntry,
|
||||
createRadioClusterOverlayEntry,
|
||||
createRadioSingletonOverlayEntry,
|
||||
radioSelectionBracketSvg,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
RADIO_OVERLAY_SOURCE_ID,
|
||||
DEFAULT_RADIO_VOLUME,
|
||||
EMPTY_ACCEPTED_CATALOG_SNAPSHOT,
|
||||
DEFAULT_RADIO_FILTER,
|
||||
} from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { clearOverlaySource, setOverlaySourceVisible } = services.overlays;
|
||||
|
||||
const methods = {
|
||||
/** Initialize the Cesium data source and the single audio element. */
|
||||
init(viewer) {
|
||||
layerState._sessionGeneration += 1;
|
||||
layerState._viewer = viewer;
|
||||
parts.clustering.resetRadioClusterOverlayIdentities();
|
||||
parts.playback.installAudio();
|
||||
if (!layerState._dataSource) {
|
||||
layerState._dataSource = new Cesium.CustomDataSource('Radio stations');
|
||||
viewer.dataSources.add(layerState._dataSource);
|
||||
parts.rendering.installClusterStyling();
|
||||
}
|
||||
layerState._dataSource.show = false;
|
||||
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
|
||||
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
|
||||
},
|
||||
|
||||
/** Show stations. Enabling or preset restoration never starts audio. */
|
||||
enable() {
|
||||
layerState._enabled = true;
|
||||
parts.interaction.syncRadioLifecyclePresentation();
|
||||
parts.presentation.emitState();
|
||||
},
|
||||
|
||||
/** Hide the layer and stop playback without forgetting the selected station. */
|
||||
disable() {
|
||||
layerState._sessionGeneration += 1;
|
||||
layerState._enabled = false;
|
||||
parts.navigation.invalidateRadioCameraNavigation();
|
||||
layerState._requestGeneration += 1;
|
||||
layerState._abortController?.abort();
|
||||
layerState._abortController = null;
|
||||
layerState._loading = false;
|
||||
layerState._selectionGeneration += 1;
|
||||
if (layerState._selectionTimer) clearTimeout(layerState._selectionTimer);
|
||||
layerState._selectionTimer = null;
|
||||
parts.interaction.removeInteraction();
|
||||
parts.tuning.endRadioTuning();
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
parts.playback.stopRadioPlayback({ origin: 'layer-disable' });
|
||||
if (layerState._dataSource) layerState._dataSource.show = false;
|
||||
if (layerState._selectedEntity && layerState._viewer)
|
||||
layerState._viewer.entities.remove(layerState._selectedEntity);
|
||||
layerState._selectedEntity = null;
|
||||
if (layerState._overlayPublishTimer)
|
||||
clearTimeout(layerState._overlayPublishTimer);
|
||||
layerState._overlayPublishTimer = null;
|
||||
parts.clustering.resetRadioClusterOverlayIdentities();
|
||||
layerState._overlayDiagnostics =
|
||||
parts.clustering.emptyRadioOverlayDiagnostics();
|
||||
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
|
||||
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
|
||||
parts.presentation.emitState();
|
||||
},
|
||||
|
||||
/** Release rendering, event, request, and playback resources. */
|
||||
destroy() {
|
||||
this.disable();
|
||||
parts.volume.cancelRadioVolumeTransition();
|
||||
layerState._voiceDucked = false;
|
||||
layerState._voiceRestoring = false;
|
||||
parts.tuning.endRadioTuning();
|
||||
const closedTuningNoise = layerState._tuningNoiseContext?.close?.();
|
||||
if (closedTuningNoise?.catch) void closedTuningNoise.catch(() => {});
|
||||
layerState._tuningNoiseContext = null;
|
||||
if (layerState._audio) layerState._audio.volume = DEFAULT_RADIO_VOLUME;
|
||||
layerState._audio = null;
|
||||
layerState._userVolume = DEFAULT_RADIO_VOLUME;
|
||||
layerState._removeClusterListener?.();
|
||||
layerState._removeClusterListener = null;
|
||||
if (layerState._dataSource && layerState._viewer)
|
||||
layerState._viewer.dataSources.remove(layerState._dataSource, true);
|
||||
layerState._dataSource = null;
|
||||
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
|
||||
layerState._stations = [];
|
||||
layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
layerState._stationById.clear();
|
||||
layerState._categories = Object.freeze([]);
|
||||
layerState._filter = DEFAULT_RADIO_FILTER;
|
||||
layerState._renderById.clear();
|
||||
parts.clustering.resetRadioClusterOverlayIdentities();
|
||||
layerState._lastHorizonCameraPosition = null;
|
||||
layerState._horizonScanCount = 0;
|
||||
layerState._selectedId = null;
|
||||
layerState._tuningPreviewId = null;
|
||||
layerState._tuningStartStationId = null;
|
||||
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
layerState._tuningStationById = new Map();
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._loading = false;
|
||||
layerState._stale = false;
|
||||
layerState._degraded = false;
|
||||
layerState._error = null;
|
||||
layerState._updatedAt = null;
|
||||
layerState._managerLifecyclePresentation = null;
|
||||
layerState._viewer = null;
|
||||
parts.presentation.emitState();
|
||||
layerState._listeners.clear();
|
||||
layerState._playbackControlListeners.clear();
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
RADIO_CATEGORY_COLORS,
|
||||
RADIO_CLUSTER_LABELS,
|
||||
RADIO_MARKER_CATEGORY_ORDER,
|
||||
} from './policy.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
/** Return the shared CSS color for a canonical or detected-genre category. */
|
||||
|
||||
function radioCategoryColor(categoryId = 'other') {
|
||||
const normalized = String(categoryId || 'other');
|
||||
const canonical = normalized.startsWith('genre:') ? 'music' : normalized;
|
||||
return RADIO_CATEGORY_COLORS[canonical] || RADIO_CATEGORY_COLORS.other;
|
||||
}
|
||||
|
||||
/** Format the concise count/category badge shown above a Radio cluster. */
|
||||
|
||||
function radioClusterBadgeText(categoryId = 'other', count = 0) {
|
||||
const normalized = String(categoryId || 'other');
|
||||
const canonical = normalized.startsWith('genre:') ? 'music' : normalized;
|
||||
const label = RADIO_CLUSTER_LABELS[canonical] || RADIO_CLUSTER_LABELS.other;
|
||||
const stationCount = Math.max(0, Math.floor(Number(count) || 0));
|
||||
return `${stationCount} ${label}`;
|
||||
}
|
||||
|
||||
/** Choose the category advertised by a cluster in the active station-tag view. */
|
||||
|
||||
function radioClusterCategoryId(stations, activeFilter = 'all') {
|
||||
const filter = String(activeFilter || 'all');
|
||||
if (filter !== 'all') {
|
||||
if (
|
||||
filter.startsWith('genre:') ||
|
||||
RADIO_MARKER_CATEGORY_ORDER.includes(filter) ||
|
||||
filter === 'other'
|
||||
) {
|
||||
return filter;
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
const categoryCounts = new Map();
|
||||
for (const station of Array.isArray(stations) ? stations : []) {
|
||||
const categoryId = radioStationCategoryId(station);
|
||||
categoryCounts.set(categoryId, (categoryCounts.get(categoryId) || 0) + 1);
|
||||
}
|
||||
let clusterCategory = 'other';
|
||||
let clusterCategoryCount = 0;
|
||||
for (const categoryId of RADIO_MARKER_CATEGORY_ORDER) {
|
||||
const count = categoryCounts.get(categoryId) || 0;
|
||||
if (count > clusterCategoryCount) {
|
||||
clusterCategory = categoryId;
|
||||
clusterCategoryCount = count;
|
||||
}
|
||||
}
|
||||
if ((categoryCounts.get('other') || 0) > clusterCategoryCount)
|
||||
return 'other';
|
||||
return clusterCategory;
|
||||
}
|
||||
|
||||
/** Choose one stable display category for a station that may match several filters. */
|
||||
|
||||
function radioStationCategoryId(station) {
|
||||
return (
|
||||
RADIO_MARKER_CATEGORY_ORDER.find((categoryId) =>
|
||||
parts.categories.stationMatchesRadioCategory(station, categoryId),
|
||||
) || 'other'
|
||||
);
|
||||
}
|
||||
|
||||
/** Pure stale-response guard shared by the async directory update path. */
|
||||
|
||||
function radioRequestIsCurrent(
|
||||
generation,
|
||||
currentGeneration,
|
||||
enabled,
|
||||
sessionGeneration = null,
|
||||
currentSessionGeneration = sessionGeneration,
|
||||
) {
|
||||
return (
|
||||
generation === currentGeneration &&
|
||||
Boolean(enabled) &&
|
||||
sessionGeneration === currentSessionGeneration
|
||||
);
|
||||
}
|
||||
|
||||
/** Compare every station field that determines tuner presentation and playback. */
|
||||
|
||||
function radioStationResolutionMatches(frozenStation, currentStation) {
|
||||
if (!frozenStation || !currentStation) return false;
|
||||
for (const key of [
|
||||
'id',
|
||||
'name',
|
||||
'lat',
|
||||
'lon',
|
||||
'streamUrl',
|
||||
'homepage',
|
||||
'state',
|
||||
'country',
|
||||
'countryCode',
|
||||
'metadataTrust',
|
||||
'codec',
|
||||
'bitrate',
|
||||
]) {
|
||||
if (frozenStation[key] !== currentStation[key]) return false;
|
||||
}
|
||||
return ['tags', 'languages'].every((key) => {
|
||||
const frozenValues = Array.isArray(frozenStation[key])
|
||||
? frozenStation[key]
|
||||
: [];
|
||||
const currentValues = Array.isArray(currentStation[key])
|
||||
? currentStation[key]
|
||||
: [];
|
||||
return (
|
||||
frozenValues.length === currentValues.length &&
|
||||
frozenValues.every((value, index) => value === currentValues[index])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function tuningResolutionStation(id) {
|
||||
return id ? layerState._tuningStationById.get(String(id)) || null : null;
|
||||
}
|
||||
return {
|
||||
radioCategoryColor,
|
||||
radioClusterBadgeText,
|
||||
radioClusterCategoryId,
|
||||
radioStationCategoryId,
|
||||
radioRequestIsCurrent,
|
||||
radioStationResolutionMatches,
|
||||
tuningResolutionStation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
HORIZON_CAMERA_MOVE_EPSILON_M,
|
||||
GLOBAL_RADIO_ALTITUDE_M,
|
||||
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createNavigation({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const {
|
||||
isFullGlobeInsideKeyhole,
|
||||
GLOBE_ENTER_CLEARANCE_PX,
|
||||
projectEarthDiscToViewport,
|
||||
earthDiscScreenRadius,
|
||||
getKeyholeGeometry,
|
||||
} = services.globe;
|
||||
|
||||
/** Return whether camera translation materially changes horizon visibility. */
|
||||
|
||||
function radioCameraPositionChanged(
|
||||
previous,
|
||||
current,
|
||||
epsilonM = HORIZON_CAMERA_MOVE_EPSILON_M,
|
||||
) {
|
||||
if (!previous || !current) return true;
|
||||
const dx = Number(current.x) - Number(previous.x);
|
||||
const dy = Number(current.y) - Number(previous.y);
|
||||
const dz = Number(current.z) - Number(previous.z);
|
||||
if (![dx, dy, dz].every(Number.isFinite)) return true;
|
||||
const threshold = Math.max(0, Number(epsilonM) || 0);
|
||||
return dx * dx + dy * dy + dz * dz > threshold * threshold;
|
||||
}
|
||||
|
||||
/** Classify a globe-scale Radio view without flapping on Cesium height round-off. */
|
||||
|
||||
function radioViewIsGlobal(altitudeM) {
|
||||
return (
|
||||
Number.isFinite(altitudeM) &&
|
||||
Math.round(altitudeM) >= GLOBAL_RADIO_ALTITUDE_M
|
||||
);
|
||||
}
|
||||
|
||||
/** Plan a station-centered camera move that preserves altitude and view angle. */
|
||||
|
||||
function radioStationCameraPlan(station, cameraState = {}) {
|
||||
const targetLat = Number(station?.lat);
|
||||
const targetLon = Number(station?.lon);
|
||||
const height = Math.max(1, Number(cameraState.height) || 1);
|
||||
const heading = Number.isFinite(cameraState.heading)
|
||||
? cameraState.heading
|
||||
: 0;
|
||||
const pitch = Number.isFinite(cameraState.pitch)
|
||||
? cameraState.pitch
|
||||
: -Math.PI / 2;
|
||||
const roll = Number.isFinite(cameraState.roll) ? cameraState.roll : 0;
|
||||
if (!Number.isFinite(targetLat) || !Number.isFinite(targetLon)) return null;
|
||||
|
||||
const downAngle = Math.min(
|
||||
Math.PI / 2,
|
||||
Math.max(0.08, Math.abs(Math.min(-0.001, pitch))),
|
||||
);
|
||||
const groundOffsetM =
|
||||
downAngle > Math.PI / 2 - 1e-6
|
||||
? 0
|
||||
: Math.min(2_000_000, height / Math.max(0.08, Math.tan(downAngle)));
|
||||
const angularDistance = groundOffsetM / 6_378_137;
|
||||
const targetLatRad = (targetLat * Math.PI) / 180;
|
||||
const cameraBearing = heading + Math.PI;
|
||||
const cameraLatRad = Math.asin(
|
||||
Math.sin(targetLatRad) * Math.cos(angularDistance) +
|
||||
Math.cos(targetLatRad) *
|
||||
Math.sin(angularDistance) *
|
||||
Math.cos(cameraBearing),
|
||||
);
|
||||
const cameraLonRad =
|
||||
(targetLon * Math.PI) / 180 +
|
||||
Math.atan2(
|
||||
Math.sin(cameraBearing) *
|
||||
Math.sin(angularDistance) *
|
||||
Math.cos(targetLatRad),
|
||||
Math.cos(angularDistance) -
|
||||
Math.sin(targetLatRad) * Math.sin(cameraLatRad),
|
||||
);
|
||||
const cameraLon = (((cameraLonRad * 180) / Math.PI + 540) % 360) - 180;
|
||||
return {
|
||||
lat: (cameraLatRad * 180) / Math.PI,
|
||||
lon: cameraLon,
|
||||
height,
|
||||
heading,
|
||||
pitch,
|
||||
roll,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether Radio navigation should first restore a centered Earth view.
|
||||
* Fit-capable clipped discs and closer views whose optical center has reached
|
||||
* the Earth limb use the staged path; ordinary centered local views stay direct.
|
||||
*/
|
||||
|
||||
function radioGlobeNeedsRecentering(geometry) {
|
||||
if (!geometry || isFullGlobeInsideKeyhole(geometry, false)) return false;
|
||||
const centeredGeometry = {
|
||||
...geometry,
|
||||
earthCenterX: geometry.keyholeCenterX,
|
||||
earthCenterY: geometry.keyholeCenterY,
|
||||
};
|
||||
if (isFullGlobeInsideKeyhole(centeredGeometry, false)) return true;
|
||||
const values = [
|
||||
geometry.earthCenterX,
|
||||
geometry.earthCenterY,
|
||||
geometry.earthRadius,
|
||||
geometry.keyholeCenterX,
|
||||
geometry.keyholeCenterY,
|
||||
];
|
||||
if (!values.every(Number.isFinite) || geometry.earthRadius <= 0)
|
||||
return false;
|
||||
const centerOffset = Math.hypot(
|
||||
geometry.earthCenterX - geometry.keyholeCenterX,
|
||||
geometry.earthCenterY - geometry.keyholeCenterY,
|
||||
);
|
||||
return geometry.earthRadius - centerOffset < GLOBE_ENTER_CLEARANCE_PX;
|
||||
}
|
||||
|
||||
/** Preserve closer zoom while capping an extreme full-globe recovery. */
|
||||
|
||||
function radioGlobeRecenterHeight(currentHeight, fullGlobeCapable) {
|
||||
if (!Number.isFinite(currentHeight) || currentHeight < 0) return null;
|
||||
return fullGlobeCapable
|
||||
? Math.min(currentHeight, RADIO_GLOBE_RECENTER_MAX_HEIGHT_M)
|
||||
: currentHeight;
|
||||
}
|
||||
|
||||
function radioCameraState(camera = layerState._viewer?.camera) {
|
||||
if (!camera) return null;
|
||||
return {
|
||||
height: camera.positionCartographic?.height,
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
};
|
||||
}
|
||||
|
||||
/** Radio may move the globe only while no tracked entity owns the follow camera. */
|
||||
|
||||
function radioCameraNavigationAllowed(viewer = layerState._viewer) {
|
||||
return Boolean(viewer?.camera) && !viewer.trackedEntity;
|
||||
}
|
||||
|
||||
function radioGlobeRecenterPlan(viewer = layerState._viewer) {
|
||||
const camera = viewer?.camera;
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const width = canvas?.clientWidth || canvas?.width;
|
||||
const height = canvas?.clientHeight || canvas?.height;
|
||||
const cartographic = camera?.positionCartographic;
|
||||
if (!camera || !cartographic || !(width > 0) || !(height > 0)) return null;
|
||||
const geometry = projectEarthDiscToViewport(
|
||||
viewer,
|
||||
width,
|
||||
height,
|
||||
layerState._radioEarthScreenCenter,
|
||||
layerState._radioEarthToCenter,
|
||||
);
|
||||
let fullGlobeCapable = false;
|
||||
if (geometry) {
|
||||
if (!radioGlobeNeedsRecentering(geometry)) return null;
|
||||
fullGlobeCapable = isFullGlobeInsideKeyhole(
|
||||
{
|
||||
...geometry,
|
||||
earthCenterX: geometry.keyholeCenterX,
|
||||
earthCenterY: geometry.keyholeCenterY,
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
const earthRadius = earthDiscScreenRadius(
|
||||
Cesium.Cartesian3.magnitude(camera.positionWC),
|
||||
height,
|
||||
camera.frustum?.fovy,
|
||||
);
|
||||
const facingEarthCenter =
|
||||
Cesium.Cartesian3.dot(
|
||||
camera.directionWC,
|
||||
layerState._radioEarthToCenter,
|
||||
) > 0;
|
||||
if (!earthRadius || facingEarthCenter) return null;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
fullGlobeCapable =
|
||||
earthRadius + GLOBE_ENTER_CLEARANCE_PX <= keyhole.radius;
|
||||
}
|
||||
const recenterHeight = radioGlobeRecenterHeight(
|
||||
cartographic.height,
|
||||
fullGlobeCapable,
|
||||
);
|
||||
if (recenterHeight == null) return null;
|
||||
return {
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
cartographic.latitude,
|
||||
recenterHeight,
|
||||
),
|
||||
cameraState: {
|
||||
...radioCameraState(camera),
|
||||
height: recenterHeight,
|
||||
heading: 0,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function radioCameraNavigationIsCurrent(navigation) {
|
||||
return Boolean(
|
||||
navigation &&
|
||||
navigation.generation === layerState._radioCameraNavigationGeneration &&
|
||||
layerState._enabled &&
|
||||
radioCameraNavigationAllowed(layerState._viewer),
|
||||
);
|
||||
}
|
||||
|
||||
function cancelActiveRadioCameraFlight() {
|
||||
if (
|
||||
!layerState._activeRadioCameraFlight ||
|
||||
!radioCameraNavigationAllowed(layerState._viewer)
|
||||
)
|
||||
return;
|
||||
layerState._activeRadioCameraFlight = null;
|
||||
layerState._viewer.camera.cancelFlight();
|
||||
}
|
||||
|
||||
function invalidateRadioCameraNavigation() {
|
||||
layerState._radioCameraNavigationGeneration += 1;
|
||||
cancelActiveRadioCameraFlight();
|
||||
layerState._activeRadioCameraFlight = null;
|
||||
}
|
||||
|
||||
function radioCameraNavigationOwnsSelection(navigation, station) {
|
||||
return Boolean(
|
||||
navigation &&
|
||||
station &&
|
||||
navigation.generation === layerState._radioCameraNavigationGeneration &&
|
||||
navigation.target?.id === station.id,
|
||||
);
|
||||
}
|
||||
|
||||
function startRadioCameraFlight(navigation, options, onComplete) {
|
||||
if (!radioCameraNavigationIsCurrent(navigation)) return false;
|
||||
const token = ++layerState._radioCameraFlightSequence;
|
||||
layerState._activeRadioCameraFlight = {
|
||||
generation: navigation.generation,
|
||||
token,
|
||||
};
|
||||
const finish = (completed) => {
|
||||
if (layerState._activeRadioCameraFlight?.token === token)
|
||||
layerState._activeRadioCameraFlight = null;
|
||||
if (completed && radioCameraNavigationIsCurrent(navigation))
|
||||
onComplete?.();
|
||||
};
|
||||
layerState._viewer.camera.flyTo({
|
||||
...options,
|
||||
complete: () => finish(true),
|
||||
cancel: () => finish(false),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function focusRadioNavigationTarget(navigation) {
|
||||
if (!radioCameraNavigationIsCurrent(navigation) || !navigation.target)
|
||||
return false;
|
||||
const plan = radioStationCameraPlan(
|
||||
navigation.target,
|
||||
navigation.cameraState,
|
||||
);
|
||||
if (!plan) return false;
|
||||
navigation.phase = 'focusing';
|
||||
return startRadioCameraFlight(
|
||||
navigation,
|
||||
{
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
plan.lon,
|
||||
plan.lat,
|
||||
plan.height,
|
||||
),
|
||||
orientation: {
|
||||
heading: plan.heading,
|
||||
pitch: plan.pitch,
|
||||
roll: plan.roll,
|
||||
},
|
||||
duration: navigation.duration,
|
||||
},
|
||||
() => {
|
||||
navigation.phase = 'settled';
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function beginRadioCameraNavigation(cameraState = null) {
|
||||
const generation = ++layerState._radioCameraNavigationGeneration;
|
||||
if (
|
||||
!radioCameraNavigationAllowed(layerState._viewer) ||
|
||||
!layerState._enabled
|
||||
)
|
||||
return null;
|
||||
cancelActiveRadioCameraFlight();
|
||||
const recenterPlan = radioGlobeRecenterPlan(layerState._viewer);
|
||||
return {
|
||||
generation,
|
||||
phase: 'idle',
|
||||
recentered: false,
|
||||
recenterPlan,
|
||||
cameraState:
|
||||
recenterPlan?.cameraState || cameraState || radioCameraState(),
|
||||
target: null,
|
||||
duration: 0.35,
|
||||
};
|
||||
}
|
||||
|
||||
function rotateRadioStationIntoView(
|
||||
station,
|
||||
duration = 0.35,
|
||||
cameraState = null,
|
||||
navigation = null,
|
||||
) {
|
||||
const activeNavigation =
|
||||
navigation || beginRadioCameraNavigation(cameraState);
|
||||
if (!station || !radioCameraNavigationIsCurrent(activeNavigation))
|
||||
return false;
|
||||
activeNavigation.target = station;
|
||||
activeNavigation.duration = duration;
|
||||
if (activeNavigation.phase === 'recentering') return true;
|
||||
if (activeNavigation.phase === 'focusing') cancelActiveRadioCameraFlight();
|
||||
if (activeNavigation.recenterPlan && !activeNavigation.recentered) {
|
||||
activeNavigation.phase = 'recentering';
|
||||
const recenterDuration = Math.min(0.9, Math.max(0.65, duration));
|
||||
return startRadioCameraFlight(
|
||||
activeNavigation,
|
||||
{
|
||||
destination: activeNavigation.recenterPlan.destination,
|
||||
orientation: {
|
||||
heading: activeNavigation.cameraState.heading,
|
||||
pitch: activeNavigation.cameraState.pitch,
|
||||
roll: activeNavigation.cameraState.roll,
|
||||
},
|
||||
duration: recenterDuration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
},
|
||||
() => {
|
||||
activeNavigation.recentered = true;
|
||||
activeNavigation.phase = 'idle';
|
||||
focusRadioNavigationTarget(activeNavigation);
|
||||
},
|
||||
);
|
||||
}
|
||||
return focusRadioNavigationTarget(activeNavigation);
|
||||
}
|
||||
|
||||
function focusStation(station) {
|
||||
layerState._radioCameraNavigationGeneration += 1;
|
||||
if (!station || !radioCameraNavigationAllowed(layerState._viewer))
|
||||
return false;
|
||||
cancelActiveRadioCameraFlight();
|
||||
layerState._viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
station.lon,
|
||||
station.lat,
|
||||
85_000,
|
||||
),
|
||||
duration: 1.1,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
radioCameraPositionChanged,
|
||||
radioViewIsGlobal,
|
||||
radioStationCameraPlan,
|
||||
radioGlobeNeedsRecentering,
|
||||
radioGlobeRecenterHeight,
|
||||
radioCameraState,
|
||||
radioCameraNavigationAllowed,
|
||||
radioGlobeRecenterPlan,
|
||||
radioCameraNavigationIsCurrent,
|
||||
cancelActiveRadioCameraFlight,
|
||||
invalidateRadioCameraNavigation,
|
||||
radioCameraNavigationOwnsSelection,
|
||||
startRadioCameraFlight,
|
||||
focusRadioNavigationTarget,
|
||||
beginRadioCameraNavigation,
|
||||
rotateRadioStationIntoView,
|
||||
focusStation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { RADIO_VOICE_PLAYBACK_TIMEOUT_MS } from './policy.js';
|
||||
|
||||
export function createPlayback({ state: layerState, services, parts, source }) {
|
||||
function audioEventBelongsToActiveAttempt(audio) {
|
||||
const attempt = layerState._activePlaybackAttempt;
|
||||
return (
|
||||
layerState._audio === audio &&
|
||||
Boolean(attempt) &&
|
||||
attempt.generation === layerState._playGeneration &&
|
||||
attempt.stationId === layerState._audioStationId
|
||||
);
|
||||
}
|
||||
|
||||
function installAudio({ replace = false } = {}) {
|
||||
if (layerState._audio && !replace) return;
|
||||
if (typeof Audio === 'undefined') return;
|
||||
const previousAudio = layerState._audio;
|
||||
layerState._audio = null;
|
||||
if (previousAudio) {
|
||||
try {
|
||||
previousAudio.pause();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
try {
|
||||
previousAudio.removeAttribute('src');
|
||||
} catch {
|
||||
/* no source */
|
||||
}
|
||||
try {
|
||||
previousAudio.load();
|
||||
} catch {
|
||||
/* detached media */
|
||||
}
|
||||
}
|
||||
const audio = new Audio();
|
||||
layerState._audio = audio;
|
||||
audio.preload = 'none';
|
||||
audio.volume = layerState._voiceDucked ? 0 : layerState._userVolume;
|
||||
audio.addEventListener('playing', () => {
|
||||
if (layerState._audio !== audio) return;
|
||||
if (!audioEventBelongsToActiveAttempt(audio)) return;
|
||||
if (!['loading', 'buffering', 'playing'].includes(layerState._audioState))
|
||||
return;
|
||||
layerState._audioState = 'playing';
|
||||
layerState._audioError = null;
|
||||
if (layerState._tuningAwaitingStationId === layerState._audioStationId)
|
||||
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||
parts.presentation.emitState();
|
||||
});
|
||||
audio.addEventListener('pause', () => {
|
||||
if (layerState._audio !== audio) return;
|
||||
if (
|
||||
layerState._audioState === 'stopped' ||
|
||||
layerState._audioState === 'loading'
|
||||
)
|
||||
return;
|
||||
layerState._audioState = 'paused';
|
||||
parts.presentation.emitState();
|
||||
});
|
||||
audio.addEventListener('waiting', () => {
|
||||
if (layerState._audio !== audio) return;
|
||||
if (!audioEventBelongsToActiveAttempt(audio)) return;
|
||||
if (!['loading', 'buffering', 'playing'].includes(layerState._audioState))
|
||||
return;
|
||||
layerState._audioState = 'buffering';
|
||||
parts.presentation.emitState();
|
||||
});
|
||||
audio.addEventListener('error', () => {
|
||||
if (layerState._audio !== audio) return;
|
||||
if (!audioEventBelongsToActiveAttempt(audio)) return;
|
||||
if (!['loading', 'buffering', 'playing'].includes(layerState._audioState))
|
||||
return;
|
||||
const failedId = layerState._audioStationId;
|
||||
const attempt = layerState._activePlaybackAttempt;
|
||||
if (tryRadioFallback(failedId, attempt?.origin, attempt?.id)) return;
|
||||
layerState._audioState = 'error';
|
||||
layerState._audioError =
|
||||
'Broadcaster stream is unavailable or blocked by the browser.';
|
||||
parts.presentation.emitState();
|
||||
});
|
||||
}
|
||||
|
||||
function tryRadioFallback(
|
||||
failedId,
|
||||
origin = layerState._playFallbackOrigin || 'programmatic',
|
||||
attemptId = layerState._playFallbackAttemptId,
|
||||
) {
|
||||
const fallbackId = layerState._playFallbackId;
|
||||
const fallbackStation = fallbackId
|
||||
? layerState._stationById.get(fallbackId)
|
||||
: null;
|
||||
const fallbackFocusPolicy = layerState._playFallbackFocus;
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
if (
|
||||
!fallbackStation ||
|
||||
fallbackId === failedId ||
|
||||
layerState._selectedId !== failedId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const fallbackFocusResult =
|
||||
typeof fallbackFocusPolicy === 'function'
|
||||
? fallbackFocusPolicy(fallbackStation)
|
||||
: fallbackFocusPolicy;
|
||||
const fallbackCameraNavigation =
|
||||
fallbackFocusResult && typeof fallbackFocusResult === 'object'
|
||||
? fallbackFocusResult
|
||||
: null;
|
||||
const fallbackFocus = fallbackCameraNavigation
|
||||
? false
|
||||
: Boolean(fallbackFocusResult);
|
||||
parts.selection.selectRadioStation(fallbackId, {
|
||||
autoplay: true,
|
||||
focus: fallbackFocus,
|
||||
origin: origin || 'programmatic',
|
||||
attemptId,
|
||||
cameraNavigation: fallbackCameraNavigation,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function recordDirectoryClick(id) {
|
||||
Promise.resolve()
|
||||
.then(() => source.recordClick(id))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Play the selected broadcaster stream after an explicit user action. */
|
||||
|
||||
async function playSelectedRadio({
|
||||
origin = 'programmatic',
|
||||
attemptId = null,
|
||||
} = {}) {
|
||||
const station = parts.queries.selectedStation();
|
||||
if (!parts.interaction.radioPresentationAllowed() || !station?.streamUrl)
|
||||
return false;
|
||||
if (layerState._tuningActive) parts.tuning.endRadioTuning();
|
||||
// A media event carries no reliable attempt identity. Give every explicit
|
||||
// play/resume/replacement its own element so queued events from the retired
|
||||
// attempt remain bound to the discarded element and cannot mutate this one.
|
||||
installAudio({ replace: true });
|
||||
if (!layerState._audio) return false;
|
||||
|
||||
const generation = ++layerState._playGeneration;
|
||||
const ownedAttemptId =
|
||||
attemptId || `radio-play-${++layerState._playAttemptSequence}`;
|
||||
layerState._activePlaybackAttempt = {
|
||||
id: ownedAttemptId,
|
||||
origin,
|
||||
stationId: station.id,
|
||||
streamUrl: station.streamUrl,
|
||||
generation,
|
||||
};
|
||||
if (layerState._playFallbackId) {
|
||||
layerState._playFallbackOrigin = origin;
|
||||
layerState._playFallbackAttemptId = ownedAttemptId;
|
||||
}
|
||||
layerState._audioError = null;
|
||||
layerState._audioState = 'loading';
|
||||
if (
|
||||
layerState._audioStationId !== station.id ||
|
||||
layerState._audio.src !== station.streamUrl
|
||||
) {
|
||||
layerState._audio.pause();
|
||||
layerState._audio.src = station.streamUrl;
|
||||
layerState._audioStationId = station.id;
|
||||
}
|
||||
parts.presentation.emitState();
|
||||
|
||||
try {
|
||||
const playback = layerState._audio.play();
|
||||
if (playback?.then) await playback;
|
||||
if (
|
||||
generation !== layerState._playGeneration ||
|
||||
layerState._audioStationId !== station.id ||
|
||||
layerState._activePlaybackAttempt?.id !== ownedAttemptId
|
||||
)
|
||||
return false;
|
||||
layerState._audioState = 'playing';
|
||||
if (layerState._tuningAwaitingStationId === station.id)
|
||||
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
recordDirectoryClick(station.id);
|
||||
parts.presentation.emitState();
|
||||
if (origin === 'user')
|
||||
parts.presentation.emitPlaybackControl('play', origin, ownedAttemptId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
generation !== layerState._playGeneration ||
|
||||
layerState._activePlaybackAttempt?.id !== ownedAttemptId
|
||||
)
|
||||
return false;
|
||||
if (tryRadioFallback(station.id, origin, ownedAttemptId)) return false;
|
||||
layerState._audioState = 'error';
|
||||
layerState._audioError =
|
||||
error?.name === 'NotAllowedError'
|
||||
? 'Playback requires a direct click or tap.'
|
||||
: 'Broadcaster stream could not be started.';
|
||||
parts.presentation.emitState();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for confirmed Radio playback while voice owns a hard mute.
|
||||
* A fallback station may replace the first stream during this wait, so the
|
||||
* state subscription—not the first play() promise—is authoritative.
|
||||
*/
|
||||
|
||||
function confirmRadioPlayback({
|
||||
startPlayback,
|
||||
subscribe,
|
||||
getState,
|
||||
timeoutMs = RADIO_VOICE_PLAYBACK_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
if (
|
||||
typeof startPlayback !== 'function' ||
|
||||
typeof subscribe !== 'function' ||
|
||||
typeof getState !== 'function'
|
||||
) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let started = false;
|
||||
let unsubscribe = () => {};
|
||||
let timer = null;
|
||||
const finish = (ok) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
unsubscribe();
|
||||
resolve(Boolean(ok));
|
||||
};
|
||||
const inspect = (state) => {
|
||||
if (!started || !state) return;
|
||||
if (
|
||||
!state.voiceDucked &&
|
||||
['loading', 'buffering', 'playing'].includes(state.audioState)
|
||||
) {
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
state.audioState === 'playing' &&
|
||||
state.playingStationId &&
|
||||
state.voiceDucked
|
||||
) {
|
||||
finish(true);
|
||||
} else if (state.audioState === 'error') {
|
||||
finish(false);
|
||||
}
|
||||
};
|
||||
unsubscribe = subscribe(inspect);
|
||||
started = true;
|
||||
timer = setTimeout(
|
||||
() => finish(false),
|
||||
Math.max(1, Number(timeoutMs) || RADIO_VOICE_PLAYBACK_TIMEOUT_MS),
|
||||
);
|
||||
Promise.resolve()
|
||||
.then(startPlayback)
|
||||
.then((playStarted) => {
|
||||
const state = getState();
|
||||
inspect(state);
|
||||
if (
|
||||
!playStarted &&
|
||||
!['loading', 'buffering', 'playing'].includes(state?.audioState)
|
||||
)
|
||||
finish(false);
|
||||
})
|
||||
.catch(() => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
/** Start and verify a prepared station without ever making it audible under voice. */
|
||||
|
||||
function playPreparedRadioForVoice(options = {}) {
|
||||
if (!layerState._voiceDucked) return Promise.resolve(false);
|
||||
return confirmRadioPlayback({
|
||||
startPlayback: () =>
|
||||
playSelectedRadio({ origin: 'voice', attemptId: options.attemptId }),
|
||||
subscribe: parts.presentation.subscribeToRadio,
|
||||
getState: parts.presentation.getRadioUIState,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop the shared stream and release its network resource. */
|
||||
|
||||
function stopRadioPlayback({
|
||||
origin = 'programmatic',
|
||||
attemptId = null,
|
||||
} = {}) {
|
||||
if (attemptId && layerState._activePlaybackAttempt?.id !== attemptId)
|
||||
return false;
|
||||
const stoppedAttemptId = layerState._activePlaybackAttempt?.id || null;
|
||||
parts.tuning.endRadioTuning();
|
||||
layerState._playGeneration += 1;
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
layerState._activePlaybackAttempt = null;
|
||||
if (layerState._audio) {
|
||||
layerState._audio.pause();
|
||||
layerState._audio.removeAttribute('src');
|
||||
layerState._audio.load();
|
||||
}
|
||||
layerState._audioStationId = null;
|
||||
layerState._audioState = 'stopped';
|
||||
layerState._audioError = null;
|
||||
parts.presentation.emitState();
|
||||
if (origin === 'user' || origin === 'voice') {
|
||||
parts.presentation.emitPlaybackControl('stop', origin, stoppedAttemptId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pause or resume the selected stream. Resuming is still click initiated. */
|
||||
|
||||
function toggleRadioPlayback({ origin = 'programmatic' } = {}) {
|
||||
if (['loading', 'playing', 'buffering'].includes(layerState._audioState)) {
|
||||
return Promise.resolve(pauseRadioPlayback({ origin }));
|
||||
}
|
||||
if (!parts.interaction.radioPresentationAllowed())
|
||||
return Promise.resolve(false);
|
||||
if (!layerState._selectedId) {
|
||||
const ranked = parts.queries.rankedVisibleStations();
|
||||
if (!ranked.length) return Promise.resolve(false);
|
||||
layerState._playFallbackId = ranked[1]?.id || null;
|
||||
layerState._playFallbackFocus = false;
|
||||
parts.selection.selectRadioStation(ranked[0].id, {
|
||||
autoplay: false,
|
||||
focus: false,
|
||||
});
|
||||
}
|
||||
return playSelectedRadio({ origin });
|
||||
}
|
||||
|
||||
/** Pause Radio without toggling a stopped or already-paused stream back on. */
|
||||
|
||||
function pauseRadioPlayback({ origin = 'programmatic' } = {}) {
|
||||
if (!['loading', 'playing', 'buffering'].includes(layerState._audioState))
|
||||
return false;
|
||||
const pausedAttemptId = layerState._activePlaybackAttempt?.id || null;
|
||||
if (layerState._tuningAwaitingStationId && !layerState._tuningActive)
|
||||
parts.tuning.endRadioTuning();
|
||||
layerState._playGeneration += 1;
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
layerState._activePlaybackAttempt = null;
|
||||
layerState._audio?.pause();
|
||||
layerState._audioState = 'paused';
|
||||
parts.presentation.emitState();
|
||||
if (origin === 'user' || origin === 'voice') {
|
||||
parts.presentation.emitPlaybackControl('pause', origin, pausedAttemptId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
audioEventBelongsToActiveAttempt,
|
||||
installAudio,
|
||||
tryRadioFallback,
|
||||
recordDirectoryClick,
|
||||
playSelectedRadio,
|
||||
confirmRadioPlayback,
|
||||
playPreparedRadioForVoice,
|
||||
stopRadioPlayback,
|
||||
toggleRadioPlayback,
|
||||
pauseRadioPlayback,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
export const RADIO_PREFIX = 'radio:';
|
||||
|
||||
export const DIRECTORY_ENDPOINT = '/api/radio/stations';
|
||||
|
||||
export const HORIZON_TICK_MS = 250;
|
||||
|
||||
export const HORIZON_CAMERA_MOVE_EPSILON_M = 1;
|
||||
|
||||
export const MARKER_LIFT_M = 2.5;
|
||||
|
||||
export const SELECTED_LIFT_M = 5;
|
||||
|
||||
export const RADIO_PICK_TOLERANCE_PX = 8;
|
||||
|
||||
export const RADIO_TUNER_DIRECTORY_LIMIT = 750;
|
||||
|
||||
export const RADIO_TUNER_STATION_LIMIT = RADIO_TUNER_DIRECTORY_LIMIT;
|
||||
|
||||
export const RADIO_TUNER_STATIC_MAX_GAIN = 0.018;
|
||||
|
||||
export const RADIO_VOICE_PLAYBACK_TIMEOUT_MS = 12_000;
|
||||
|
||||
export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
export const RADIO_UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const RADIO_OVERLAY_SOURCE_ID = 'radio';
|
||||
|
||||
export const RADIO_OVERLAY_COHORT_LIMIT = 64;
|
||||
|
||||
export const RADIO_SINGLETON_GLOBAL_LIMIT = 16;
|
||||
|
||||
export const RADIO_SINGLETON_MID_LIMIT = 32;
|
||||
|
||||
export const RADIO_SINGLETON_NEAR_LIMIT = 48;
|
||||
|
||||
export const RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M = 50_000_000;
|
||||
|
||||
export const RADIO_GLOBE_RECENTER_MAX_HEIGHT_M = 13_000_000;
|
||||
|
||||
export const RADIO_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: RADIO_OVERLAY_COHORT_LIMIT,
|
||||
collisionCapacity: 96,
|
||||
moving: false,
|
||||
});
|
||||
|
||||
export const RADIO_PICK_OFFSETS = Object.freeze([
|
||||
[0, -RADIO_PICK_TOLERANCE_PX],
|
||||
[RADIO_PICK_TOLERANCE_PX, 0],
|
||||
[0, RADIO_PICK_TOLERANCE_PX],
|
||||
[-RADIO_PICK_TOLERANCE_PX, 0],
|
||||
[6, -6],
|
||||
[6, 6],
|
||||
[-6, 6],
|
||||
[-6, -6],
|
||||
]);
|
||||
|
||||
export const DEFAULT_RADIO_FILTER = 'all';
|
||||
|
||||
export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000;
|
||||
|
||||
export const MUSIC_GENRES = Object.freeze([
|
||||
['alternative', 'Alternative'],
|
||||
['ambient', 'Ambient'],
|
||||
['blues', 'Blues'],
|
||||
['classical', 'Classical'],
|
||||
['country', 'Country'],
|
||||
['dance', 'Dance'],
|
||||
['electronic', 'Electronic'],
|
||||
['folk', 'Folk'],
|
||||
['funk', 'Funk'],
|
||||
['hip hop', 'Hip-Hop'],
|
||||
['house', 'House'],
|
||||
['indie', 'Indie'],
|
||||
['jazz', 'Jazz'],
|
||||
['latin', 'Latin'],
|
||||
['metal', 'Metal'],
|
||||
['oldies', 'Oldies'],
|
||||
['pop', 'Pop'],
|
||||
['punk', 'Punk'],
|
||||
['r&b', 'R&B'],
|
||||
['reggae', 'Reggae'],
|
||||
['rock', 'Rock'],
|
||||
['soul', 'Soul'],
|
||||
['techno', 'Techno'],
|
||||
['trance', 'Trance'],
|
||||
['world', 'World'],
|
||||
]);
|
||||
|
||||
export const CATEGORY_MATCHERS = Object.freeze({
|
||||
news: ['news', 'current affairs', 'journalism'],
|
||||
talk: ['talk', 'spoken word', 'interview', 'podcast'],
|
||||
weather: ['weather', 'emergency', 'noaa'],
|
||||
'public-safety': [
|
||||
'public safety',
|
||||
'scanner',
|
||||
'police',
|
||||
'fire',
|
||||
'ems',
|
||||
'dispatch',
|
||||
'emergency',
|
||||
],
|
||||
'aviation-marine': [
|
||||
'aviation',
|
||||
'air traffic',
|
||||
'atc',
|
||||
'airport',
|
||||
'marine',
|
||||
'maritime',
|
||||
'coast guard',
|
||||
],
|
||||
'traffic-transit': ['traffic', 'transit', 'transport', 'rail', 'metro'],
|
||||
});
|
||||
|
||||
export const RADIO_CATEGORY_COLORS = Object.freeze({
|
||||
all: '#b9fbff',
|
||||
news: '#44adff',
|
||||
talk: '#f2b84b',
|
||||
weather: '#ff5c78',
|
||||
'public-safety': '#ff8b4a',
|
||||
'aviation-marine': '#a87cff',
|
||||
'traffic-transit': '#ffd166',
|
||||
music: '#54d17a',
|
||||
other: '#9aa7b3',
|
||||
});
|
||||
|
||||
export const RADIO_CLUSTER_LABELS = Object.freeze({
|
||||
news: 'NEWS',
|
||||
talk: 'TALK',
|
||||
weather: 'WEATHER',
|
||||
'public-safety': 'SAFETY',
|
||||
'aviation-marine': 'AIR / SEA',
|
||||
'traffic-transit': 'TRANSIT',
|
||||
music: 'MUSIC',
|
||||
other: 'OTHER',
|
||||
});
|
||||
|
||||
export const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([
|
||||
'news',
|
||||
'public-safety',
|
||||
'weather',
|
||||
'aviation-marine',
|
||||
'traffic-transit',
|
||||
'talk',
|
||||
'music',
|
||||
]);
|
||||
|
||||
export const DEFAULT_RADIO_VOLUME = 0.8;
|
||||
|
||||
export const EMPTY_ACCEPTED_CATALOG_SNAPSHOT = Object.freeze({
|
||||
instance: null,
|
||||
generation: null,
|
||||
updatedAt: null,
|
||||
stations: Object.freeze([]),
|
||||
stationIds: Object.freeze([]),
|
||||
});
|
||||
|
||||
export const VOICE_RESTORE_DELAY_MS = 650;
|
||||
|
||||
export const VOICE_RESTORE_DURATION_MS = 1800;
|
||||
|
||||
export const RADIO_GLOBE_LABEL_MAX_CHARS = 30;
|
||||
|
||||
export const RADIO_LABEL_SEGMENTER =
|
||||
typeof Intl?.Segmenter === 'function'
|
||||
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
: null;
|
||||
@@ -0,0 +1,105 @@
|
||||
export function createPresentation({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/** Return the deeply immutable healthy catalog generation shared with tuner consumers. */
|
||||
|
||||
function getRadioAcceptedCatalogSnapshot() {
|
||||
return layerState._acceptedCatalogSnapshot;
|
||||
}
|
||||
|
||||
/** Return an immutable snapshot consumed by the right-rail UI. */
|
||||
|
||||
function getRadioUIState() {
|
||||
const visible = parts.queries.visibleStations();
|
||||
const selected = parts.queries.selectedStation();
|
||||
return Object.freeze({
|
||||
enabled: layerState._enabled,
|
||||
loading: layerState._loading,
|
||||
stale: layerState._stale,
|
||||
degraded: layerState._degraded,
|
||||
error: layerState._error,
|
||||
updatedAt: layerState._updatedAt,
|
||||
filter: layerState._filter,
|
||||
categories: layerState._categories,
|
||||
acceptedCatalogGeneration: layerState._acceptedCatalogSnapshot.generation,
|
||||
presentationActive: parts.interaction.radioPresentationAllowed(),
|
||||
stationCount: layerState._stations.length,
|
||||
filteredCount: visible.length,
|
||||
selected,
|
||||
selectedIndex: selected
|
||||
? visible.findIndex((station) => station.id === selected.id)
|
||||
: -1,
|
||||
audioState: layerState._audioState,
|
||||
audioError: layerState._audioError,
|
||||
playingStationId: layerState._audioStationId,
|
||||
volume: layerState._userVolume,
|
||||
effectiveVolume:
|
||||
layerState._audio?.volume ??
|
||||
(layerState._voiceDucked ? 0 : layerState._userVolume),
|
||||
voiceDucked: layerState._voiceDucked,
|
||||
voiceRestoring: layerState._voiceRestoring,
|
||||
tuningActive: layerState._tuningActive,
|
||||
tuningStatic: layerState._tuningStatic,
|
||||
tuningAwaitingStationId: layerState._tuningAwaitingStationId,
|
||||
tuningPreviewStationId: layerState._tuningPreviewId,
|
||||
tuningRestoredStationId:
|
||||
layerState._cancelledTuningPresentationStation?.id || null,
|
||||
tuningCatalogGeneration: layerState._tuningResolutionSnapshot.generation,
|
||||
tuningUnavailableStationId: layerState._tuningUnavailableStationId,
|
||||
});
|
||||
}
|
||||
|
||||
function emitState() {
|
||||
const snapshot = getRadioUIState();
|
||||
for (const listener of layerState._listeners) {
|
||||
try {
|
||||
listener(snapshot);
|
||||
} catch {
|
||||
// A broken consumer must not break playback or rendering.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to radio state; the current state is delivered immediately. */
|
||||
|
||||
function subscribeToRadio(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
layerState._listeners.add(listener);
|
||||
listener(getRadioUIState());
|
||||
return () => layerState._listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Subscribe to explicit playback controls so voice handoffs cannot undo them. */
|
||||
|
||||
function subscribeToRadioPlaybackControls(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
layerState._playbackControlListeners.add(listener);
|
||||
return () => layerState._playbackControlListeners.delete(listener);
|
||||
}
|
||||
|
||||
function emitPlaybackControl(
|
||||
action,
|
||||
origin,
|
||||
attemptId = layerState._activePlaybackAttempt?.id || null,
|
||||
) {
|
||||
const event = { action, origin, attemptId };
|
||||
for (const listener of layerState._playbackControlListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// Playback controls must remain usable if an observer fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
getRadioAcceptedCatalogSnapshot,
|
||||
getRadioUIState,
|
||||
emitState,
|
||||
subscribeToRadio,
|
||||
subscribeToRadioPlaybackControls,
|
||||
emitPlaybackControl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { normalizeRadioCountryInput } from '../../data/radioCountry.js';
|
||||
|
||||
export function createQueries({ state: layerState, services, parts, source }) {
|
||||
function radioAngularDistance(station, anchor) {
|
||||
const lat1 = Cesium.Math.toRadians(Number(anchor?.lat));
|
||||
const lon1 = Cesium.Math.toRadians(Number(anchor?.lon));
|
||||
const lat2 = Cesium.Math.toRadians(Number(station?.lat));
|
||||
const lon2 = Cesium.Math.toRadians(Number(station?.lon));
|
||||
if (![lat1, lon1, lat2, lon2].every(Number.isFinite))
|
||||
return Number.POSITIVE_INFINITY;
|
||||
const deltaLat = lat2 - lat1;
|
||||
const deltaLon = lon2 - lon1;
|
||||
const haversine =
|
||||
Math.sin(deltaLat / 2) ** 2 +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
|
||||
return 2 * Math.asin(Math.min(1, Math.sqrt(Math.max(0, haversine))));
|
||||
}
|
||||
|
||||
/** Rank a copied station list by viewport distance, with an optional English-first tier. */
|
||||
|
||||
function rankRadioStationsForViewport(
|
||||
stations,
|
||||
anchor,
|
||||
{ preferEnglish = false } = {},
|
||||
) {
|
||||
return (Array.isArray(stations) ? stations : [])
|
||||
.map((station, index) => ({
|
||||
station,
|
||||
index,
|
||||
distance: radioAngularDistance(station, anchor),
|
||||
languageTier:
|
||||
preferEnglish && !parts.categories.isEnglishRadioStation(station)
|
||||
? 1
|
||||
: 0,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.languageTier - b.languageTier ||
|
||||
a.distance - b.distance ||
|
||||
a.index - b.index,
|
||||
)
|
||||
.map(({ station }) => station);
|
||||
}
|
||||
|
||||
/** Rank stations for an explicit voice/player request without moving the camera. */
|
||||
|
||||
function rankRadioStationsForRequest(
|
||||
stations,
|
||||
{ categoryId = 'all', anchor = null, country = '', stationQuery = '' } = {},
|
||||
) {
|
||||
const countryFilter = normalizeRadioCountryInput(country);
|
||||
if (!countryFilter.valid) return [];
|
||||
const query = parts.categories.normalizeRadioTag(stationQuery);
|
||||
let matches = parts.categories.filterRadioStations(stations, categoryId);
|
||||
if (countryFilter.code || countryFilter.name) {
|
||||
matches = matches.filter((station) => {
|
||||
const stationCode = String(station?.countryCode || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const stationCountry = normalizeRadioCountryInput(station?.country);
|
||||
return (
|
||||
(countryFilter.code && stationCode === countryFilter.code) ||
|
||||
(countryFilter.code &&
|
||||
stationCountry.valid &&
|
||||
stationCountry.code === countryFilter.code)
|
||||
);
|
||||
});
|
||||
}
|
||||
if (query) {
|
||||
matches = matches.filter((station) =>
|
||||
[
|
||||
station?.id,
|
||||
station?.name,
|
||||
station?.state,
|
||||
station?.country,
|
||||
station?.countryCode,
|
||||
...(Array.isArray(station?.tags) ? station.tags : []),
|
||||
].some((value) =>
|
||||
parts.categories.normalizeRadioTag(value).includes(query),
|
||||
),
|
||||
);
|
||||
}
|
||||
return anchor
|
||||
? rankRadioStationsForViewport(matches, anchor)
|
||||
: matches.slice();
|
||||
}
|
||||
|
||||
function selectedStation() {
|
||||
return layerState._selectedId
|
||||
? layerState._stationById.get(layerState._selectedId) || null
|
||||
: null;
|
||||
}
|
||||
|
||||
function selectedPresentationStation() {
|
||||
if (layerState._tuningActive)
|
||||
return parts.model.tuningResolutionStation(layerState._tuningPreviewId);
|
||||
return layerState._cancelledTuningPresentationStation || selectedStation();
|
||||
}
|
||||
|
||||
function visibleStations() {
|
||||
return parts.categories.filterRadioStations(
|
||||
layerState._stations,
|
||||
layerState._filter,
|
||||
);
|
||||
}
|
||||
|
||||
function viewportRadioAnchor() {
|
||||
const camera = layerState._viewer?.camera;
|
||||
const scene = layerState._viewer?.scene;
|
||||
if (!camera) return null;
|
||||
const altitudeM = Number(camera.positionCartographic?.height);
|
||||
let cartographic = null;
|
||||
const canvas = scene?.canvas;
|
||||
if (canvas && typeof camera.pickEllipsoid === 'function') {
|
||||
const center = new Cesium.Cartesian2(
|
||||
canvas.clientWidth / 2,
|
||||
canvas.clientHeight / 2,
|
||||
);
|
||||
const position = camera.pickEllipsoid(
|
||||
center,
|
||||
scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
|
||||
);
|
||||
if (position) cartographic = Cesium.Cartographic.fromCartesian(position);
|
||||
}
|
||||
cartographic ||= camera.positionCartographic || null;
|
||||
if (!cartographic) return null;
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(cartographic.latitude),
|
||||
lon: Cesium.Math.toDegrees(cartographic.longitude),
|
||||
altitudeM,
|
||||
globalView: parts.navigation.radioViewIsGlobal(altitudeM),
|
||||
};
|
||||
}
|
||||
|
||||
function rankedVisibleStations() {
|
||||
const anchor = viewportRadioAnchor();
|
||||
return rankRadioStationsForViewport(visibleStations(), anchor, {
|
||||
preferEnglish: Boolean(anchor?.globalView),
|
||||
});
|
||||
}
|
||||
return {
|
||||
radioAngularDistance,
|
||||
rankRadioStationsForViewport,
|
||||
rankRadioStationsForRequest,
|
||||
selectedStation,
|
||||
selectedPresentationStation,
|
||||
visibleStations,
|
||||
viewportRadioAnchor,
|
||||
rankedVisibleStations,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import {
|
||||
MARKER_LIFT_M,
|
||||
RADIO_PREFIX,
|
||||
SELECTED_LIFT_M,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
RADIO_OVERLAY_SOURCE_ID,
|
||||
RADIO_OVERLAY_COHORT_LIMIT,
|
||||
RADIO_OVERLAY_SOURCE_OPTIONS,
|
||||
DEFAULT_RADIO_FILTER,
|
||||
} from './policy.js';
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { cachedGroundFloor } = services.ground;
|
||||
const { clearOverlaySource, setOverlaySourceVisible, setOverlayEntries } =
|
||||
services.overlays;
|
||||
const { governorRequestRender } = services.render;
|
||||
|
||||
function markerPosition(station, liftM = MARKER_LIFT_M) {
|
||||
const floor = cachedGroundFloor(station.lat, station.lon);
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
station.lon,
|
||||
station.lat,
|
||||
(Number.isFinite(floor) ? floor : 0) + liftM,
|
||||
);
|
||||
}
|
||||
|
||||
function updateSelectionEntity() {
|
||||
if (layerState._selectedEntity && layerState._viewer)
|
||||
layerState._viewer.entities.remove(layerState._selectedEntity);
|
||||
layerState._selectedEntity = null;
|
||||
const station = parts.queries.selectedPresentationStation();
|
||||
if (
|
||||
!parts.interaction.radioPresentationAllowed() ||
|
||||
!layerState._viewer ||
|
||||
!station
|
||||
) {
|
||||
scheduleRadioOverlayPublish();
|
||||
return;
|
||||
}
|
||||
const selectionColor = parts.model.radioCategoryColor(
|
||||
parts.model.radioStationCategoryId(station),
|
||||
);
|
||||
const bracketImage = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg(selectionColor))}`;
|
||||
|
||||
layerState._selectedEntity = layerState._viewer.entities.add({
|
||||
id: `${RADIO_PREFIX}selected:${station.id}`,
|
||||
position: markerPosition(station, SELECTED_LIFT_M),
|
||||
point: {
|
||||
pixelSize: 14,
|
||||
color: Cesium.Color.fromCssColorString(selectionColor),
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
||||
0,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
),
|
||||
},
|
||||
billboard: {
|
||||
image: bracketImage,
|
||||
width: 40,
|
||||
height: 40,
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
||||
0,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
),
|
||||
scaleByDistance: new Cesium.NearFarScalar(100_000, 1, 12_000_000, 0.72),
|
||||
},
|
||||
});
|
||||
scheduleRadioOverlayPublish();
|
||||
}
|
||||
|
||||
function clusterPointCollection() {
|
||||
return layerState._dataSource?.clustering?._clusterPointCollection || null;
|
||||
}
|
||||
|
||||
function publishRadioOverlayEntries() {
|
||||
layerState._overlayPublishTimer = null;
|
||||
if (!parts.interaction.radioPresentationAllowed()) {
|
||||
parts.clustering.resetRadioClusterOverlayIdentities();
|
||||
layerState._overlayDiagnostics =
|
||||
parts.clustering.emptyRadioOverlayDiagnostics();
|
||||
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
|
||||
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
const station = parts.queries.selectedPresentationStation();
|
||||
if (station) {
|
||||
const selectedEntry = parts.labels.createRadioSelectedOverlayEntry(
|
||||
station,
|
||||
markerPosition(station, SELECTED_LIFT_M),
|
||||
);
|
||||
if (selectedEntry) entries.push(selectedEntry);
|
||||
}
|
||||
|
||||
const clusterCandidates = [];
|
||||
const clusteredStationIds = new Set();
|
||||
const points = clusterPointCollection();
|
||||
for (let index = 0; index < (points?.length || 0); index += 1) {
|
||||
const point = points.get(index);
|
||||
if (
|
||||
!point?.show ||
|
||||
!point.position ||
|
||||
!Array.isArray(point.id) ||
|
||||
point.id.length < 3
|
||||
)
|
||||
continue;
|
||||
const stationIds = point.id
|
||||
.map((entity) => String(entity?.id || '').slice(RADIO_PREFIX.length))
|
||||
.filter((id) => {
|
||||
const station = layerState._stationById.get(id);
|
||||
return (
|
||||
station &&
|
||||
parts.categories.stationMatchesRadioCategory(
|
||||
station,
|
||||
layerState._filter,
|
||||
)
|
||||
);
|
||||
})
|
||||
.sort();
|
||||
if (stationIds.length < 3) continue;
|
||||
for (const stationId of stationIds) clusteredStationIds.add(stationId);
|
||||
const clusteredStations = stationIds.map((id) =>
|
||||
layerState._stationById.get(id),
|
||||
);
|
||||
const category = parts.model.radioClusterCategoryId(
|
||||
clusteredStations,
|
||||
layerState._filter,
|
||||
);
|
||||
clusterCandidates.push({
|
||||
id: `${stationIds[0]}:${stationIds.at(-1)}:${stationIds.length}`,
|
||||
stationIds,
|
||||
point,
|
||||
text: parts.model.radioClusterBadgeText(category, stationIds.length),
|
||||
accent: parts.model.radioCategoryColor(category),
|
||||
stationCount: stationIds.length,
|
||||
});
|
||||
}
|
||||
const selectedClusterCandidates =
|
||||
parts.clustering.selectRadioClusterCandidates(clusterCandidates);
|
||||
layerState._clusterOverlayIdentities =
|
||||
parts.clustering.reconcileRadioClusterCandidates(
|
||||
selectedClusterCandidates,
|
||||
layerState._clusterOverlayIdentities,
|
||||
() => `stable:${++layerState._clusterOverlayIdentitySequence}`,
|
||||
);
|
||||
for (const candidate of layerState._clusterOverlayIdentities) {
|
||||
const clusterEntry = parts.labels.createRadioClusterOverlayEntry({
|
||||
...candidate,
|
||||
position: () => candidate.point.position,
|
||||
});
|
||||
if (clusterEntry) entries.push(clusterEntry);
|
||||
}
|
||||
|
||||
const cameraPosition = layerState._viewer?.camera?.positionWC;
|
||||
const singletonAllowance = Math.min(
|
||||
parts.clustering.radioSingletonLabelLimit(
|
||||
layerState._viewer?.camera?.positionCartographic?.height,
|
||||
),
|
||||
Math.max(
|
||||
0,
|
||||
RADIO_OVERLAY_COHORT_LIMIT - selectedClusterCandidates.length,
|
||||
),
|
||||
);
|
||||
const singletonCandidates = parts.clustering.selectRadioSingletonCandidates(
|
||||
[...layerState._renderById.values()]
|
||||
.filter(
|
||||
(record) =>
|
||||
record.entity?.show &&
|
||||
record.station?.id !== station?.id &&
|
||||
!clusteredStationIds.has(record.station?.id),
|
||||
)
|
||||
.map((record) => ({
|
||||
...record,
|
||||
distanceM: cameraPosition
|
||||
? Cesium.Cartesian3.distance(cameraPosition, record.position)
|
||||
: Number.POSITIVE_INFINITY,
|
||||
})),
|
||||
singletonAllowance,
|
||||
);
|
||||
for (let index = 0; index < singletonCandidates.length; index += 1) {
|
||||
const candidate = singletonCandidates[index];
|
||||
const singletonEntry = parts.labels.createRadioSingletonOverlayEntry({
|
||||
station: candidate.station,
|
||||
position: candidate.position,
|
||||
// Cluster priorities begin at three. Keep clusters authoritative while
|
||||
// retaining nearest-first singleton ordering inside the ambient lane.
|
||||
priority: 2 - index / Math.max(1, singletonCandidates.length + 1),
|
||||
});
|
||||
if (singletonEntry) entries.push(singletonEntry);
|
||||
}
|
||||
|
||||
setOverlayEntries(
|
||||
RADIO_OVERLAY_SOURCE_ID,
|
||||
entries,
|
||||
RADIO_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, true);
|
||||
layerState._overlayDiagnostics = {
|
||||
entryCount: entries.length,
|
||||
selectedCount: entries.filter((entry) => entry.selected).length,
|
||||
singletonTexts: entries
|
||||
.filter((entry) => entry.id.startsWith('station:'))
|
||||
.map((entry) => entry.title),
|
||||
singletonIds: entries
|
||||
.filter((entry) => entry.id.startsWith('station:'))
|
||||
.map((entry) => entry.id),
|
||||
clusterTexts: entries
|
||||
.filter((entry) => entry.id.startsWith('cluster:'))
|
||||
.map((entry) => entry.title),
|
||||
clusterIds: entries
|
||||
.filter((entry) => entry.id.startsWith('cluster:'))
|
||||
.map((entry) => entry.id),
|
||||
clusterMemberships: layerState._clusterOverlayIdentities.map(
|
||||
(candidate) => ({
|
||||
membershipId: candidate.membershipId,
|
||||
entryId: `cluster:${candidate.id}`,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleRadioOverlayPublish() {
|
||||
if (layerState._overlayPublishTimer)
|
||||
clearTimeout(layerState._overlayPublishTimer);
|
||||
const sessionGeneration = layerState._sessionGeneration;
|
||||
layerState._overlayPublishTimer = setTimeout(() => {
|
||||
if (sessionGeneration === layerState._sessionGeneration)
|
||||
publishRadioOverlayEntries();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function updateRenderVisibility({ force = true } = {}) {
|
||||
if (!layerState._viewer || !layerState._dataSource) return;
|
||||
const cameraPosition = layerState._viewer.camera?.positionWC;
|
||||
if (
|
||||
!force &&
|
||||
!parts.navigation.radioCameraPositionChanged(
|
||||
layerState._lastHorizonCameraPosition,
|
||||
cameraPosition,
|
||||
)
|
||||
)
|
||||
return;
|
||||
layerState._lastHorizonCameraPosition = cameraPosition
|
||||
? { x: cameraPosition.x, y: cameraPosition.y, z: cameraPosition.z }
|
||||
: null;
|
||||
layerState._horizonScanCount += 1;
|
||||
const occluder = horizonOccluder(layerState._viewer.camera);
|
||||
let visibilityChanged = false;
|
||||
for (const [id, record] of layerState._renderById) {
|
||||
const matches = parts.categories.stationMatchesRadioCategory(
|
||||
record.station,
|
||||
layerState._filter,
|
||||
);
|
||||
const visible = matches && occluder.isPointVisible(record.position);
|
||||
if (record.entity.show !== visible) visibilityChanged = true;
|
||||
record.entity.show = visible;
|
||||
}
|
||||
if (layerState._selectedEntity) {
|
||||
const position = layerState._selectedEntity.position?.getValue?.(
|
||||
Cesium.JulianDate.now(),
|
||||
);
|
||||
const selectedVisible = !position || occluder.isPointVisible(position);
|
||||
if (layerState._selectedEntity.show !== selectedVisible)
|
||||
visibilityChanged = true;
|
||||
layerState._selectedEntity.show = selectedVisible;
|
||||
}
|
||||
scheduleRadioOverlayPublish();
|
||||
// The horizon timer can commit AFTER the camera settles and the governor
|
||||
// parks the scene — a changed show flag needs one frame. (perf wave 2 fix)
|
||||
if (visibilityChanged) governorRequestRender('radio-horizon');
|
||||
}
|
||||
|
||||
function reconcileStations(stations) {
|
||||
if (!layerState._tuningActive)
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._clusterOverlayIdentities =
|
||||
parts.clustering.retainRadioClusterIdentitiesForStations(
|
||||
layerState._clusterOverlayIdentities,
|
||||
stations,
|
||||
);
|
||||
layerState._stations = Object.freeze([...stations]);
|
||||
layerState._stationById = new Map(
|
||||
stations.map((station) => [station.id, station]),
|
||||
);
|
||||
layerState._categories = Object.freeze(
|
||||
parts.categories
|
||||
.buildRadioCategories(stations)
|
||||
.map((category) => Object.freeze(category)),
|
||||
);
|
||||
if (
|
||||
layerState._filter === DEFAULT_RADIO_FILTER &&
|
||||
!parts.categories.filterRadioStations(stations, DEFAULT_RADIO_FILTER)
|
||||
.length
|
||||
) {
|
||||
layerState._filter = 'all';
|
||||
}
|
||||
if (
|
||||
layerState._selectedId &&
|
||||
!layerState._stationById.has(layerState._selectedId)
|
||||
) {
|
||||
if (layerState._audioStationId === layerState._selectedId)
|
||||
parts.playback.stopRadioPlayback();
|
||||
layerState._selectedId = null;
|
||||
}
|
||||
|
||||
if (layerState._dataSource) layerState._dataSource.entities.removeAll();
|
||||
layerState._renderById.clear();
|
||||
for (const station of stations) {
|
||||
const position = markerPosition(station);
|
||||
const markerColor = parts.model.radioCategoryColor(
|
||||
parts.model.radioStationCategoryId(station),
|
||||
);
|
||||
const entity = layerState._dataSource.entities.add({
|
||||
id: `${RADIO_PREFIX}${station.id}`,
|
||||
position,
|
||||
point: {
|
||||
pixelSize: 13,
|
||||
color: Cesium.Color.fromCssColorString(markerColor).withAlpha(0.86),
|
||||
outlineColor: Cesium.Color.fromCssColorString('#071b25'),
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
scaleByDistance: new Cesium.NearFarScalar(
|
||||
100_000,
|
||||
1.15,
|
||||
12_000_000,
|
||||
1,
|
||||
),
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
||||
0,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
),
|
||||
},
|
||||
});
|
||||
layerState._renderById.set(station.id, { station, entity, position });
|
||||
}
|
||||
updateSelectionEntity();
|
||||
updateRenderVisibility();
|
||||
}
|
||||
|
||||
function installClusterStyling() {
|
||||
if (!layerState._dataSource || layerState._removeClusterListener) return;
|
||||
const clustering = layerState._dataSource.clustering;
|
||||
clustering.enabled = true;
|
||||
clustering.pixelRange = 42;
|
||||
clustering.minimumClusterSize = 3;
|
||||
clustering.clusterPoints = true;
|
||||
clustering.clusterLabels = false;
|
||||
clustering.clusterBillboards = false;
|
||||
layerState._removeClusterListener =
|
||||
clustering.clusterEvent.addEventListener((clusteredEntities, cluster) => {
|
||||
const clusteredStations = [];
|
||||
for (const entity of clusteredEntities) {
|
||||
const stationId = String(entity.id || '').slice(RADIO_PREFIX.length);
|
||||
const station = layerState._renderById.get(stationId)?.station;
|
||||
if (station) clusteredStations.push(station);
|
||||
}
|
||||
const clusterCategory = parts.model.radioClusterCategoryId(
|
||||
clusteredStations,
|
||||
layerState._filter,
|
||||
);
|
||||
const clusterColor = Cesium.Color.fromCssColorString(
|
||||
parts.model.radioCategoryColor(clusterCategory),
|
||||
);
|
||||
// Cesium assigns the entity-id array only to the generated cluster label.
|
||||
// Mirror it to the visible point so clicking either part of the callout
|
||||
// resolves the first station and remains a direct playback gesture.
|
||||
cluster.point.id = clusteredEntities;
|
||||
cluster.billboard.id = clusteredEntities;
|
||||
cluster.label.show = false;
|
||||
cluster.label.text = '';
|
||||
cluster.point.show = true;
|
||||
cluster.point.pixelSize = Math.min(
|
||||
26,
|
||||
12 + Math.log2(clusteredEntities.length) * 1.6,
|
||||
);
|
||||
cluster.point.color = clusterColor.withAlpha(0.9);
|
||||
cluster.point.outlineColor = Cesium.Color.BLACK;
|
||||
cluster.point.outlineWidth = 2;
|
||||
cluster.point.disableDepthTestDistance = Number.POSITIVE_INFINITY;
|
||||
cluster.point.distanceDisplayCondition =
|
||||
new Cesium.DistanceDisplayCondition(
|
||||
0,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
);
|
||||
scheduleRadioOverlayPublish();
|
||||
});
|
||||
}
|
||||
return {
|
||||
markerPosition,
|
||||
updateSelectionEntity,
|
||||
clusterPointCollection,
|
||||
publishRadioOverlayEntries,
|
||||
scheduleRadioOverlayPublish,
|
||||
updateRenderVisibility,
|
||||
reconcileStations,
|
||||
installClusterStyling,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Select a station. Playback occurs only when autoplay is explicitly true. */
|
||||
|
||||
function selectRadioStation(
|
||||
id,
|
||||
{
|
||||
autoplay = false,
|
||||
focus = false,
|
||||
origin = 'programmatic',
|
||||
attemptId = null,
|
||||
cameraNavigation = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!parts.interaction.radioPresentationAllowed()) return false;
|
||||
const station = layerState._stationById.get(String(id));
|
||||
if (!station) return false;
|
||||
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;
|
||||
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 });
|
||||
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,
|
||||
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;
|
||||
layerState._playFallbackId =
|
||||
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;
|
||||
layerState._playFallbackFocus = 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,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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;
|
||||
layerState._playFallbackId = ranked[1]?.id || null;
|
||||
layerState._playFallbackFocus = false;
|
||||
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DIRECTORY_ENDPOINT, RADIO_UUID_RE } from './policy.js';
|
||||
|
||||
/** Supply directory metadata and click reporting; audio stays with the broadcaster. */
|
||||
export function createRadioSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async getDirectory({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(DIRECTORY_ENDPOINT, { signal });
|
||||
if (!response.ok)
|
||||
throw new Error(`Radio directory returned ${response.status}`);
|
||||
const body = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
return body;
|
||||
},
|
||||
async recordClick(id, { signal } = {}) {
|
||||
if (typeof id !== 'string' || !RADIO_UUID_RE.test(id))
|
||||
throw new Error('Invalid radio station id');
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(
|
||||
`/api/radio/click/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
signal,
|
||||
},
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!response.ok)
|
||||
throw new Error(`Radio click returned ${response.status}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRadioSource, createRadioLayer } from './index.js';
|
||||
|
||||
const id = '00000000-0000-4000-8000-000000000001';
|
||||
|
||||
test('radio source confines directory and click requests to their existing routes', async () => {
|
||||
const calls = [];
|
||||
const body = { stations: [], stale: false };
|
||||
const source = createRadioSource({
|
||||
fetchImpl: async (path, options) => {
|
||||
calls.push({ path, options });
|
||||
return new Response(JSON.stringify(body));
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
assert.deepEqual(
|
||||
await source.getDirectory({ signal: controller.signal }),
|
||||
body,
|
||||
);
|
||||
await source.recordClick(id, { signal: controller.signal });
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.path),
|
||||
['/api/radio/stations', `/api/radio/click/${id}`],
|
||||
);
|
||||
assert.equal(calls[1].options.method, 'POST');
|
||||
assert.ok(calls.every((call) => call.options.signal === controller.signal));
|
||||
for (const invalid of [
|
||||
'../stations',
|
||||
'https://example.com',
|
||||
'',
|
||||
null,
|
||||
`${id}?x=1`,
|
||||
])
|
||||
await assert.rejects(
|
||||
source.recordClick(invalid),
|
||||
/Invalid radio station id/,
|
||||
);
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test('radio source propagates denial and cancels completed body parsing', async () => {
|
||||
const denied = createRadioSource({
|
||||
fetchImpl: async () => new Response('', { status: 403 }),
|
||||
});
|
||||
await assert.rejects(denied.getDirectory(), /403/);
|
||||
await assert.rejects(denied.recordClick(id), /403/);
|
||||
const controller = new AbortController();
|
||||
const source = createRadioSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
controller.abort();
|
||||
return { stations: [] };
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.getDirectory({ signal: controller.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
let called = false;
|
||||
const idle = createRadioSource({
|
||||
fetchImpl: async () => {
|
||||
called = true;
|
||||
},
|
||||
});
|
||||
await assert.rejects(idle.recordClick(id, { signal: controller.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('radio factories keep settings and subscriptions independent without fetching or audio startup', () => {
|
||||
let requested = false;
|
||||
const source = {
|
||||
getDirectory() {
|
||||
requested = true;
|
||||
},
|
||||
recordClick() {
|
||||
requested = true;
|
||||
},
|
||||
};
|
||||
const services = {
|
||||
ground: {},
|
||||
picking: { unregisterPickOwner() {} },
|
||||
globe: {},
|
||||
render: { governorRequestRender() {} },
|
||||
overlays: {
|
||||
clearOverlaySource() {},
|
||||
setOverlaySourceVisible() {},
|
||||
setOverlayEntries() {},
|
||||
},
|
||||
};
|
||||
const first = createRadioLayer({ source, services });
|
||||
const second = createRadioLayer({ source, services });
|
||||
let changes = 0;
|
||||
const off = second.subscribeToRadio(() => changes++);
|
||||
const initialChanges = changes;
|
||||
first.setRadioParams({ volume: 0.3 });
|
||||
assert.equal(first.getRadioParams().volume, 0.3);
|
||||
assert.equal(second.getRadioParams().volume, 0.8);
|
||||
assert.equal(changes, initialChanges);
|
||||
assert.equal(requested, false);
|
||||
off();
|
||||
first.destroy();
|
||||
assert.equal(second.getRadioParams().volume, 0.8);
|
||||
second.destroy();
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
EMPTY_ACCEPTED_CATALOG_SNAPSHOT,
|
||||
DEFAULT_RADIO_FILTER,
|
||||
DEFAULT_RADIO_VOLUME,
|
||||
} from './policy.js';
|
||||
|
||||
export function createState({ services }) {
|
||||
const layerState = {};
|
||||
|
||||
layerState._radioEarthScreenCenter = new Cesium.Cartesian2();
|
||||
|
||||
layerState._radioEarthToCenter = new Cesium.Cartesian3();
|
||||
|
||||
layerState._viewer = null;
|
||||
|
||||
layerState._dataSource = null;
|
||||
|
||||
layerState._enabled = false;
|
||||
|
||||
layerState._managerLifecyclePresentation = null;
|
||||
|
||||
layerState._loading = false;
|
||||
|
||||
layerState._stale = false;
|
||||
|
||||
layerState._degraded = false;
|
||||
|
||||
layerState._error = null;
|
||||
|
||||
layerState._updatedAt = null;
|
||||
|
||||
layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
|
||||
layerState._stations = [];
|
||||
|
||||
layerState._stationById = new Map();
|
||||
|
||||
layerState._categories = [];
|
||||
|
||||
layerState._renderById = new Map();
|
||||
|
||||
layerState._filter = DEFAULT_RADIO_FILTER;
|
||||
|
||||
layerState._selectedId = null;
|
||||
|
||||
layerState._selectedEntity = null;
|
||||
|
||||
layerState._selectionGeneration = 0;
|
||||
|
||||
layerState._selectionTimer = null;
|
||||
|
||||
layerState._radioCameraNavigationGeneration = 0;
|
||||
|
||||
layerState._radioCameraFlightSequence = 0;
|
||||
|
||||
layerState._activeRadioCameraFlight = null;
|
||||
|
||||
layerState._audio = null;
|
||||
|
||||
layerState._audioStationId = null;
|
||||
|
||||
layerState._audioState = 'stopped';
|
||||
|
||||
layerState._audioError = null;
|
||||
|
||||
layerState._userVolume = DEFAULT_RADIO_VOLUME;
|
||||
|
||||
layerState._tuningActive = false;
|
||||
|
||||
layerState._tuningStatic = false;
|
||||
|
||||
layerState._tuningAwaitingStationId = null;
|
||||
|
||||
layerState._tuningPreviewId = null;
|
||||
|
||||
layerState._tuningStartStationId = null;
|
||||
|
||||
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
|
||||
layerState._tuningStationById = new Map();
|
||||
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
|
||||
layerState._tuningCameraNavigation = null;
|
||||
|
||||
layerState._tuningNoiseContext = null;
|
||||
|
||||
layerState._tuningNoiseSource = null;
|
||||
|
||||
layerState._tuningNoiseFilter = null;
|
||||
|
||||
layerState._tuningNoiseGain = null;
|
||||
|
||||
layerState._voiceDucked = false;
|
||||
|
||||
layerState._voiceRestoring = false;
|
||||
|
||||
layerState._voiceRestoreTimer = null;
|
||||
|
||||
layerState._volumeFadeFrame = null;
|
||||
|
||||
layerState._volumeTransitionGeneration = 0;
|
||||
|
||||
layerState._playGeneration = 0;
|
||||
|
||||
layerState._playAttemptSequence = 0;
|
||||
|
||||
layerState._activePlaybackAttempt = null;
|
||||
|
||||
layerState._playFallbackId = null;
|
||||
|
||||
layerState._playFallbackFocus = null;
|
||||
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
|
||||
layerState._playFallbackAttemptId = null;
|
||||
|
||||
layerState._clickHandler = null;
|
||||
|
||||
layerState._horizonTimer = null;
|
||||
|
||||
layerState._lastHorizonCameraPosition = null;
|
||||
|
||||
layerState._horizonScanCount = 0;
|
||||
|
||||
layerState._abortController = null;
|
||||
|
||||
layerState._requestGeneration = 0;
|
||||
|
||||
layerState._sessionGeneration = 0;
|
||||
|
||||
layerState._removeClusterListener = null;
|
||||
|
||||
layerState._overlayPublishTimer = null;
|
||||
|
||||
layerState._clusterOverlayIdentitySequence = 0;
|
||||
|
||||
layerState._clusterOverlayIdentities = [];
|
||||
|
||||
layerState._overlayDiagnostics = {
|
||||
entryCount: 0,
|
||||
selectedCount: 0,
|
||||
singletonTexts: [],
|
||||
singletonIds: [],
|
||||
clusterTexts: [],
|
||||
clusterIds: [],
|
||||
clusterMemberships: [],
|
||||
};
|
||||
|
||||
layerState._listeners = new Set();
|
||||
|
||||
layerState._playbackControlListeners = new Set();
|
||||
return layerState;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
RADIO_TUNER_STATION_LIMIT,
|
||||
RADIO_TUNER_DIRECTORY_LIMIT,
|
||||
EMPTY_ACCEPTED_CATALOG_SNAPSHOT,
|
||||
} from './policy.js';
|
||||
|
||||
export function createTuning({ state: layerState, services, parts, source }) {
|
||||
/** Bound one already ordered filtered directory without injecting outside selections. */
|
||||
|
||||
function buildRadioTunerBand(
|
||||
rankedStations,
|
||||
selected,
|
||||
limit = RADIO_TUNER_STATION_LIMIT,
|
||||
) {
|
||||
const boundedLimit = Math.min(
|
||||
RADIO_TUNER_DIRECTORY_LIMIT,
|
||||
Math.max(1, Math.floor(Number(limit) || RADIO_TUNER_STATION_LIMIT)),
|
||||
);
|
||||
return (Array.isArray(rankedStations) ? rankedStations : []).slice(
|
||||
0,
|
||||
boundedLimit,
|
||||
);
|
||||
}
|
||||
|
||||
function captureRadioTuningResolutionSnapshot() {
|
||||
return layerState._acceptedCatalogSnapshot;
|
||||
}
|
||||
|
||||
/** Return the complete accepted filtered directory in stable catalog order. */
|
||||
|
||||
function getRadioTunerStations(limit = RADIO_TUNER_STATION_LIMIT) {
|
||||
if (
|
||||
!parts.interaction.radioPresentationAllowed() ||
|
||||
!layerState._acceptedCatalogSnapshot.stations.length
|
||||
)
|
||||
return [];
|
||||
return buildRadioTunerBand(
|
||||
parts.queries.visibleStations(),
|
||||
parts.queries.selectedStation(),
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/** Begin a direct-manipulation tuning gesture and pause the current stream. */
|
||||
|
||||
function beginRadioTuning() {
|
||||
if (
|
||||
!parts.interaction.radioPresentationAllowed() ||
|
||||
!parts.queries.visibleStations().length
|
||||
)
|
||||
return false;
|
||||
const snapshot = captureRadioTuningResolutionSnapshot();
|
||||
if (!Number.isSafeInteger(snapshot.generation) || !snapshot.stations.length)
|
||||
return false;
|
||||
layerState._tuningResolutionSnapshot = snapshot;
|
||||
layerState._tuningStationById = new Map(
|
||||
layerState._tuningResolutionSnapshot.stations.map((station) => [
|
||||
station.id,
|
||||
station,
|
||||
]),
|
||||
);
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
layerState._tuningActive = true;
|
||||
layerState._tuningStatic = false;
|
||||
layerState._tuningAwaitingStationId = null;
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._tuningStartStationId = layerState._selectedId;
|
||||
layerState._tuningPreviewId = layerState._selectedId;
|
||||
parts.tuningNoise.installTuningNoise();
|
||||
parts.playback.pauseRadioPlayback({ origin: 'user' });
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Toggle low-volume synthesized static for tuner preview and stream handoff. */
|
||||
|
||||
function setRadioTuningStatic(active) {
|
||||
if (
|
||||
!parts.interaction.radioPresentationAllowed() ||
|
||||
!layerState._tuningActive
|
||||
)
|
||||
return false;
|
||||
const next = Boolean(active);
|
||||
if (next === layerState._tuningStatic) return true;
|
||||
layerState._tuningStatic = next;
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Preview a tuner station bracket and camera orientation without starting audio. */
|
||||
|
||||
function previewRadioTuningStation(id, { rotate = true } = {}) {
|
||||
if (
|
||||
!parts.interaction.radioPresentationAllowed() ||
|
||||
!layerState._tuningActive
|
||||
)
|
||||
return false;
|
||||
const station = parts.model.tuningResolutionStation(id);
|
||||
const nextId = station?.id || null;
|
||||
const changed = nextId !== layerState._tuningPreviewId;
|
||||
layerState._tuningPreviewId = nextId;
|
||||
layerState._tuningStatic = !station;
|
||||
if (changed) {
|
||||
parts.rendering.updateSelectionEntity();
|
||||
if (station && rotate) {
|
||||
const cameraState = parts.navigation.radioCameraState();
|
||||
layerState._tuningCameraNavigation =
|
||||
parts.navigation.beginRadioCameraNavigation(cameraState);
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
station,
|
||||
0.35,
|
||||
cameraState,
|
||||
layerState._tuningCameraNavigation,
|
||||
);
|
||||
} else if (rotate) {
|
||||
layerState._tuningCameraNavigation = null;
|
||||
parts.navigation.invalidateRadioCameraNavigation();
|
||||
}
|
||||
}
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
return Boolean(station);
|
||||
}
|
||||
|
||||
/** Finish a tuning gesture and release its synthesized noise source. */
|
||||
|
||||
function endRadioTuning() {
|
||||
if (
|
||||
!layerState._tuningActive &&
|
||||
!layerState._tuningStatic &&
|
||||
!layerState._tuningAwaitingStationId &&
|
||||
!layerState._tuningNoiseSource &&
|
||||
!layerState._cancelledTuningPresentationStation
|
||||
)
|
||||
return;
|
||||
parts.tuningNoise.clearRadioTuningNoise();
|
||||
}
|
||||
|
||||
/** Cancel a tuning gesture and restore its frozen start marker for presentation only. */
|
||||
|
||||
function cancelRadioTuning() {
|
||||
if (!layerState._tuningActive) return false;
|
||||
const restoredStation = parts.model.tuningResolutionStation(
|
||||
layerState._tuningStartStationId,
|
||||
);
|
||||
parts.tuningNoise.clearRadioTuningNoise({ restoredStation });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Commit the exact frozen drag resolution or report that it is unavailable. */
|
||||
|
||||
function commitRadioTuningStation(id, { origin = 'programmatic' } = {}) {
|
||||
const frozenStation = parts.model.tuningResolutionStation(id);
|
||||
const currentStation = layerState._stationById.get(String(id)) || null;
|
||||
const generation = layerState._tuningResolutionSnapshot.generation;
|
||||
if (
|
||||
!parts.interaction.radioPresentationAllowed() ||
|
||||
!layerState._tuningActive ||
|
||||
!frozenStation
|
||||
) {
|
||||
endRadioTuning();
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: 'not-tuning',
|
||||
stationId: String(id || ''),
|
||||
generation,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!parts.model.radioStationResolutionMatches(frozenStation, currentStation)
|
||||
) {
|
||||
const stationId = frozenStation.id;
|
||||
// A failed exact release owns the visible outcome. Do not let the previous
|
||||
// selection—or changed metadata under the same ID—reappear between the
|
||||
// frozen preview and the unavailable state.
|
||||
layerState._selectedId = null;
|
||||
layerState._selectionGeneration += 1;
|
||||
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||
layerState._tuningUnavailableStationId = stationId;
|
||||
parts.presentation.emitState();
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: 'station-unavailable',
|
||||
stationId,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
layerState._tuningActive = false;
|
||||
layerState._tuningStatic = true;
|
||||
layerState._tuningAwaitingStationId = frozenStation.id;
|
||||
layerState._tuningPreviewId = null;
|
||||
layerState._tuningStartStationId = null;
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
const cameraNavigation = layerState._tuningCameraNavigation;
|
||||
layerState._tuningCameraNavigation = null;
|
||||
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
layerState._tuningStationById = new Map();
|
||||
// An exact tuner release owns its frozen target. A fallback armed by an
|
||||
// earlier non-playing selection must never retarget this gesture if the
|
||||
// broadcaster fails after release.
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
const selected = parts.selection.selectRadioStation(frozenStation.id, {
|
||||
autoplay: true,
|
||||
focus: false,
|
||||
origin,
|
||||
cameraNavigation,
|
||||
});
|
||||
return Object.freeze({
|
||||
ok: selected,
|
||||
reason: selected ? null : 'station-unavailable',
|
||||
stationId: frozenStation.id,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
return {
|
||||
buildRadioTunerBand,
|
||||
captureRadioTuningResolutionSnapshot,
|
||||
getRadioTunerStations,
|
||||
beginRadioTuning,
|
||||
setRadioTuningStatic,
|
||||
previewRadioTuningStation,
|
||||
endRadioTuning,
|
||||
cancelRadioTuning,
|
||||
commitRadioTuningStation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
RADIO_TUNER_STATIC_MAX_GAIN,
|
||||
EMPTY_ACCEPTED_CATALOG_SNAPSHOT,
|
||||
} from './policy.js';
|
||||
|
||||
export function createTuningNoise({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/** Decide whether tuner static should be audible for the current handoff state. */
|
||||
|
||||
function radioTuningStaticShouldPlay({
|
||||
tuningActive = false,
|
||||
tuningStatic = false,
|
||||
awaitingStationId = null,
|
||||
voiceDucked = false,
|
||||
} = {}) {
|
||||
return Boolean(
|
||||
tuningStatic && !voiceDucked && (tuningActive || awaitingStationId),
|
||||
);
|
||||
}
|
||||
|
||||
function stopTuningNoiseSource() {
|
||||
if (layerState._tuningNoiseSource) {
|
||||
try {
|
||||
layerState._tuningNoiseSource.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
try {
|
||||
layerState._tuningNoiseSource.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
}
|
||||
try {
|
||||
layerState._tuningNoiseFilter?.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
try {
|
||||
layerState._tuningNoiseGain?.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
layerState._tuningNoiseSource = null;
|
||||
layerState._tuningNoiseFilter = null;
|
||||
layerState._tuningNoiseGain = null;
|
||||
}
|
||||
|
||||
function syncTuningNoiseGain() {
|
||||
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
|
||||
const audible = radioTuningStaticShouldPlay({
|
||||
tuningActive: layerState._tuningActive,
|
||||
tuningStatic: layerState._tuningStatic,
|
||||
awaitingStationId: layerState._tuningAwaitingStationId,
|
||||
voiceDucked: layerState._voiceDucked,
|
||||
});
|
||||
const target = audible
|
||||
? Math.min(RADIO_TUNER_STATIC_MAX_GAIN, layerState._userVolume * 0.03)
|
||||
: 0;
|
||||
const now = layerState._tuningNoiseContext.currentTime;
|
||||
layerState._tuningNoiseGain.gain.cancelScheduledValues(now);
|
||||
layerState._tuningNoiseGain.gain.setValueAtTime(
|
||||
layerState._tuningNoiseGain.gain.value,
|
||||
now,
|
||||
);
|
||||
layerState._tuningNoiseGain.gain.linearRampToValueAtTime(
|
||||
target,
|
||||
now + 0.025,
|
||||
);
|
||||
}
|
||||
|
||||
function installTuningNoise() {
|
||||
const AudioContextClass =
|
||||
globalThis.AudioContext || globalThis.webkitAudioContext;
|
||||
if (!AudioContextClass) return false;
|
||||
if (!layerState._tuningNoiseContext)
|
||||
layerState._tuningNoiseContext = new AudioContextClass();
|
||||
const resumed = layerState._tuningNoiseContext.resume?.();
|
||||
if (resumed?.catch) void resumed.catch(() => {});
|
||||
if (layerState._tuningNoiseSource) return true;
|
||||
|
||||
const frameCount = Math.max(
|
||||
1,
|
||||
Math.floor(layerState._tuningNoiseContext.sampleRate * 0.75),
|
||||
);
|
||||
const buffer = layerState._tuningNoiseContext.createBuffer(
|
||||
1,
|
||||
frameCount,
|
||||
layerState._tuningNoiseContext.sampleRate,
|
||||
);
|
||||
const channel = buffer.getChannelData(0);
|
||||
for (let index = 0; index < channel.length; index += 1)
|
||||
channel[index] = Math.random() * 2 - 1;
|
||||
|
||||
layerState._tuningNoiseSource =
|
||||
layerState._tuningNoiseContext.createBufferSource();
|
||||
layerState._tuningNoiseFilter =
|
||||
layerState._tuningNoiseContext.createBiquadFilter();
|
||||
layerState._tuningNoiseGain = layerState._tuningNoiseContext.createGain();
|
||||
layerState._tuningNoiseSource.buffer = buffer;
|
||||
layerState._tuningNoiseSource.loop = true;
|
||||
layerState._tuningNoiseFilter.type = 'bandpass';
|
||||
layerState._tuningNoiseFilter.frequency.value = 1_650;
|
||||
layerState._tuningNoiseFilter.Q.value = 0.55;
|
||||
layerState._tuningNoiseGain.gain.value = 0;
|
||||
layerState._tuningNoiseSource.connect(layerState._tuningNoiseFilter);
|
||||
layerState._tuningNoiseFilter.connect(layerState._tuningNoiseGain);
|
||||
layerState._tuningNoiseGain.connect(
|
||||
layerState._tuningNoiseContext.destination,
|
||||
);
|
||||
layerState._tuningNoiseSource.start();
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearRadioTuningNoise({ emit = true, restoredStation = null } = {}) {
|
||||
if (layerState._tuningCameraNavigation)
|
||||
parts.navigation.invalidateRadioCameraNavigation();
|
||||
layerState._tuningActive = false;
|
||||
layerState._tuningStatic = false;
|
||||
layerState._tuningAwaitingStationId = null;
|
||||
layerState._tuningPreviewId = null;
|
||||
layerState._tuningStartStationId = null;
|
||||
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
layerState._tuningStationById = new Map();
|
||||
layerState._tuningCameraNavigation = null;
|
||||
layerState._cancelledTuningPresentationStation = restoredStation;
|
||||
parts.rendering.updateSelectionEntity();
|
||||
syncTuningNoiseGain();
|
||||
stopTuningNoiseSource();
|
||||
const suspended = layerState._tuningNoiseContext?.suspend?.();
|
||||
if (suspended?.catch) void suspended.catch(() => {});
|
||||
if (emit) parts.presentation.emitState();
|
||||
}
|
||||
return {
|
||||
radioTuningStaticShouldPlay,
|
||||
stopTuningNoiseSource,
|
||||
syncTuningNoiseGain,
|
||||
installTuningNoise,
|
||||
clearRadioTuningNoise,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { VOICE_RESTORE_DELAY_MS, VOICE_RESTORE_DURATION_MS } from './policy.js';
|
||||
|
||||
export function createVolume({ state: layerState, services, parts, source }) {
|
||||
function clampRadioVolume(value) {
|
||||
return Math.min(1, Math.max(0, Number(value) || 0));
|
||||
}
|
||||
|
||||
function cancelRadioVolumeTransition() {
|
||||
layerState._volumeTransitionGeneration += 1;
|
||||
if (layerState._voiceRestoreTimer)
|
||||
clearTimeout(layerState._voiceRestoreTimer);
|
||||
layerState._voiceRestoreTimer = null;
|
||||
if (layerState._volumeFadeFrame) {
|
||||
if (typeof cancelAnimationFrame === 'function')
|
||||
cancelAnimationFrame(layerState._volumeFadeFrame);
|
||||
else clearTimeout(layerState._volumeFadeFrame);
|
||||
}
|
||||
layerState._volumeFadeFrame = null;
|
||||
}
|
||||
|
||||
function scheduleVolumeFrame(callback) {
|
||||
if (typeof requestAnimationFrame === 'function')
|
||||
return requestAnimationFrame(callback);
|
||||
return setTimeout(() => callback(Date.now()), 16);
|
||||
}
|
||||
|
||||
function volumeClock() {
|
||||
return typeof performance !== 'undefined' &&
|
||||
typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
|
||||
/** Set shared audio volume, clamped to [0, 1]. */
|
||||
|
||||
function setRadioVolume(value) {
|
||||
if (!parts.interaction.radioPresentationAllowed()) return false;
|
||||
const volume = clampRadioVolume(value);
|
||||
layerState._userVolume = volume;
|
||||
parts.playback.installAudio();
|
||||
if (!layerState._voiceDucked) {
|
||||
cancelRadioVolumeTransition();
|
||||
layerState._voiceRestoring = false;
|
||||
if (layerState._audio) layerState._audio.volume = volume;
|
||||
}
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute Radio during a live voice turn, then gently restore the user-owned
|
||||
* volume after voice returns to standby. Repeated state sync is idempotent.
|
||||
*/
|
||||
|
||||
function setRadioVoiceDucking(
|
||||
ducked,
|
||||
{
|
||||
restoreDelayMs = VOICE_RESTORE_DELAY_MS,
|
||||
restoreDurationMs = VOICE_RESTORE_DURATION_MS,
|
||||
} = {},
|
||||
) {
|
||||
const shouldDuck = Boolean(ducked);
|
||||
if (
|
||||
shouldDuck === layerState._voiceDucked &&
|
||||
(shouldDuck || layerState._voiceRestoring)
|
||||
)
|
||||
return;
|
||||
if (
|
||||
!shouldDuck &&
|
||||
!layerState._voiceDucked &&
|
||||
!layerState._voiceRestoring &&
|
||||
(!layerState._audio ||
|
||||
Math.abs(layerState._audio.volume - layerState._userVolume) < 0.001)
|
||||
)
|
||||
return;
|
||||
|
||||
cancelRadioVolumeTransition();
|
||||
layerState._voiceDucked = shouldDuck;
|
||||
layerState._voiceRestoring = false;
|
||||
|
||||
if (shouldDuck) {
|
||||
if (layerState._audio) layerState._audio.volume = 0;
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
return;
|
||||
}
|
||||
if (!layerState._audio) {
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
layerState._voiceRestoring = false;
|
||||
parts.presentation.emitState();
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = layerState._volumeTransitionGeneration;
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
layerState._voiceRestoring = true;
|
||||
parts.presentation.emitState();
|
||||
const beginRestore = () => {
|
||||
layerState._voiceRestoreTimer = null;
|
||||
if (
|
||||
generation !== layerState._volumeTransitionGeneration ||
|
||||
layerState._voiceDucked ||
|
||||
!layerState._audio
|
||||
)
|
||||
return;
|
||||
const startedAt = volumeClock();
|
||||
const initialVolume = layerState._audio.volume;
|
||||
const duration = Math.max(0, Number(restoreDurationMs) || 0);
|
||||
const step = (now) => {
|
||||
if (
|
||||
generation !== layerState._volumeTransitionGeneration ||
|
||||
layerState._voiceDucked ||
|
||||
!layerState._audio
|
||||
)
|
||||
return;
|
||||
const progress =
|
||||
duration === 0
|
||||
? 1
|
||||
: Math.min(1, Math.max(0, (now - startedAt) / duration));
|
||||
const eased = progress * progress * (3 - 2 * progress);
|
||||
layerState._audio.volume = clampRadioVolume(
|
||||
initialVolume + (layerState._userVolume - initialVolume) * eased,
|
||||
);
|
||||
if (progress < 1) {
|
||||
layerState._volumeFadeFrame = scheduleVolumeFrame(step);
|
||||
return;
|
||||
}
|
||||
layerState._volumeFadeFrame = null;
|
||||
layerState._voiceRestoring = false;
|
||||
parts.presentation.emitState();
|
||||
};
|
||||
layerState._volumeFadeFrame = scheduleVolumeFrame(step);
|
||||
};
|
||||
const delay = Math.max(0, Number(restoreDelayMs) || 0);
|
||||
if (delay > 0)
|
||||
layerState._voiceRestoreTimer = setTimeout(beginRestore, delay);
|
||||
else beginRestore();
|
||||
}
|
||||
return {
|
||||
clampRadioVolume,
|
||||
cancelRadioVolumeTransition,
|
||||
scheduleVolumeFrame,
|
||||
volumeClock,
|
||||
setRadioVolume,
|
||||
setRadioVoiceDucking,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import test from 'node:test';
|
||||
|
||||
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
||||
const radio = readFileSync(new URL('./data/radio.js', import.meta.url), 'utf8');
|
||||
const radio = ['playback', 'interaction'].map(name =>
|
||||
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
|
||||
).join('\n').replace(/layerState\.|parts\.\w+\./g, '');
|
||||
const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8');
|
||||
const realtime = readFileSync(new URL('./voice/gevRealtime.js', import.meta.url), 'utf8');
|
||||
const voice = ['tools', 'instructions'].map(name => readFileSync(new URL(`../server/providers/openai/${name}.js`, import.meta.url), 'utf8')).join('\n');
|
||||
@@ -341,13 +343,13 @@ test('Radio disclosure is explicit, starts closed while off, and preserves playb
|
||||
});
|
||||
|
||||
test('successful explicit user playback hands the speaker from voice to Radio', () => {
|
||||
const playStart = radio.indexOf('export async function playSelectedRadio');
|
||||
const playMethod = radio.slice(playStart, radio.indexOf('\n/**', playStart + 10));
|
||||
const playStart = radio.indexOf('async function playSelectedRadio');
|
||||
const playMethod = radio.slice(playStart, radio.indexOf('function confirmRadioPlayback', playStart + 10)).replace(/\s+/g, ' ');
|
||||
const confirmedPlaying = playMethod.indexOf("_audioState = 'playing'");
|
||||
const takeoverSignal = playMethod.indexOf("if (origin === 'user') emitPlaybackControl('play', origin, ownedAttemptId)");
|
||||
assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying);
|
||||
assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/);
|
||||
assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/);
|
||||
assert.match(radio, /startPlayback: \(\) =>\s*playSelectedRadio\(\{\s*origin: 'voice',\s*attemptId: options\.attemptId,?\s*\}\)/);
|
||||
assert.match(radio, /selectRadioStation\(stationId, \{\s*autoplay: true,\s*origin: 'user',?\s*\}\)/);
|
||||
assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/);
|
||||
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
|
||||
assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
|
||||
|
||||
Reference in new issue
Block a user