mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Traffic now retries a failed destination after city navigation without a layer
|
- 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
|
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
|
readout starts hidden each session and releases its timer and frame listener
|
||||||
when hidden or when the application is disposed.
|
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/awareness": "./src/layers/awareness/index.js",
|
||||||
"./layers/traffic": "./src/layers/traffic/index.js",
|
"./layers/traffic": "./src/layers/traffic/index.js",
|
||||||
"./layers/bikeshare": "./src/layers/bikeshare/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/ground.js",
|
||||||
"src/layers/cctv/health.js",
|
"src/layers/cctv/health.js",
|
||||||
"src/data/cctv.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"
|
"src/overlays/worldOverlayTokens.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"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 html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||||
const ui = readFileSync(new URL('./ui/applicationShell.js', 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 rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8');
|
||||||
const realtime = readFileSync(new URL('./voice/gevRealtime.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');
|
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', () => {
|
test('successful explicit user playback hands the speaker from voice to Radio', () => {
|
||||||
const playStart = radio.indexOf('export async function playSelectedRadio');
|
const playStart = radio.indexOf('async function playSelectedRadio');
|
||||||
const playMethod = radio.slice(playStart, radio.indexOf('\n/**', playStart + 10));
|
const playMethod = radio.slice(playStart, radio.indexOf('function confirmRadioPlayback', playStart + 10)).replace(/\s+/g, ' ');
|
||||||
const confirmedPlaying = playMethod.indexOf("_audioState = 'playing'");
|
const confirmedPlaying = playMethod.indexOf("_audioState = 'playing'");
|
||||||
const takeoverSignal = playMethod.indexOf("if (origin === 'user') emitPlaybackControl('play', origin, ownedAttemptId)");
|
const takeoverSignal = playMethod.indexOf("if (origin === 'user') emitPlaybackControl('play', origin, ownedAttemptId)");
|
||||||
assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying);
|
assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying);
|
||||||
assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/);
|
assert.match(radio, /startPlayback: \(\) =>\s*playSelectedRadio\(\{\s*origin: 'voice',\s*attemptId: options\.attemptId,?\s*\}\)/);
|
||||||
assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/);
|
assert.match(radio, /selectRadioStation\(stationId, \{\s*autoplay: true,\s*origin: 'user',?\s*\}\)/);
|
||||||
assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/);
|
assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/);
|
||||||
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
|
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
|
||||||
assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
|
assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
|
||||||
|
|||||||
Reference in new issue
Block a user