Files
gods-eye-view/src/layers/radio/tuning.js
T
German Khokhlov a55450e2e7 Trim the Radio Next/Previous change to the HAL-722 scope
- Selections take the playback fallback as a `playFallback` option and
  set it after releasing a stale stream, instead of callers arming state
  that the release then had to save and restore.
- Move the tuner `station-unavailable` release and the Pause label while
  a stream connects out of this branch: both were wrong on main already
  and go in a separate change.
- Share the Radio layer test setup in src/testSupport/radioLayerHarness.mjs
  and fold overlapping Next/Previous tests (13 to 7).
- One-line comments and a two-line changelog entry.
2026-10-30 16:37:36 +04:00

234 lines
7.7 KiB
JavaScript

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,
};
}