Merge branch 'main' into feat/voice-geometry

Picks up the Radio Next/Previous fix (#1474) and the Codespaces docs
(#1515). The one conflict is the Radio cycle assertion in
gevActions.test.mjs: main's new fallback flag is kept in this branch's
layout.
This commit is contained in:
Manjunath committed 2026-11-03 16:40:39 +05:30
commit 05edae0606
31 files changed
+775 -170

No files matched your search

+6 -3
View File
@@ -344,14 +344,17 @@ export function createPlayback({ state: layerState, services, parts, source }) {
}
if (!parts.interaction.radioPresentationAllowed())
return Promise.resolve(false);
if (!layerState._selectedId) {
if (layerState._selectedId) {
// Drop a fallback left by a voice step whose playback never started.
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
} else {
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,
playFallback: { id: ranked[1]?.id || null, focus: false },
});
}
return playSelectedRadio({ origin });
+46 -16
View File
@@ -73,6 +73,16 @@ export function createSelection({
return { filter: layerState._filter, volume: layerState._userVolume };
}
// A paused or failed stream of another station would outlive its selection.
function releaseStaleRadioStream(stationId) {
if (
layerState._audioStationId &&
layerState._audioStationId !== stationId &&
['paused', 'error'].includes(layerState._audioState)
)
parts.playback.stopRadioPlayback();
}
/** Select a station. Playback occurs only when autoplay is explicitly true. */
function selectRadioStation(
@@ -83,11 +93,18 @@ export function createSelection({
origin = 'programmatic',
attemptId = null,
cameraNavigation = null,
playFallback = null,
} = {},
) {
if (!parts.interaction.radioPresentationAllowed()) return false;
const station = layerState._stationById.get(String(id));
if (!station) return false;
if (!autoplay) releaseStaleRadioStream(station.id);
// Set after the release, which clears fallbacks.
if (playFallback) {
layerState._playFallbackId = playFallback.id;
layerState._playFallbackFocus = playFallback.focus;
}
layerState._tuningUnavailableStationId = null;
if (
!parts.navigation.radioCameraNavigationOwnsSelection(
@@ -104,6 +121,7 @@ export function createSelection({
)
parts.tuning.endRadioTuning();
layerState._cancelledTuningPresentationStation = null;
const changed = layerState._selectedId !== station.id;
layerState._selectedId = station.id;
const generation = ++layerState._selectionGeneration;
const sessionGeneration = layerState._sessionGeneration;
@@ -122,6 +140,9 @@ export function createSelection({
if (focus) parts.navigation.focusStation(station);
parts.presentation.emitState();
if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId });
// A user step to another station cancels a playback voice prepared.
else if (origin === 'user' && changed)
parts.presentation.emitPlaybackControl('select', origin);
return true;
}
@@ -133,6 +154,7 @@ export function createSelection({
rotate = false,
stationIds = null,
autoplay = true,
fallback = autoplay,
origin = 'programmatic',
} = {},
) {
@@ -159,25 +181,29 @@ export function createSelection({
current < 0
? 0
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
layerState._playFallbackId =
ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null;
// Voice defers playback, so its silent step keeps a fallback; silent UI steps do not.
const fallbackId =
fallback && ranked.length > 1
? ranked[(nextIndex + 1) % ranked.length].id
: null;
const rotationCameraState = rotate
? parts.navigation.radioCameraState()
: null;
const rotationNavigation = rotate
? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
: null;
layerState._playFallbackFocus = rotate
? (fallbackStation) => {
parts.navigation.rotateRadioStationIntoView(
fallbackStation,
0.65,
rotationCameraState,
rotationNavigation,
);
return rotationNavigation;
}
: false;
const fallbackFocus =
fallback && rotate
? (fallbackStation) => {
parts.navigation.rotateRadioStationIntoView(
fallbackStation,
0.65,
rotationCameraState,
rotationNavigation,
);
return rotationNavigation;
}
: false;
const station = ranked[nextIndex];
if (rotate)
parts.navigation.rotateRadioStationIntoView(
@@ -191,6 +217,7 @@ export function createSelection({
focus: false,
origin,
cameraNavigation: rotationNavigation,
playFallback: { id: fallbackId, focus: fallbackFocus },
});
}
@@ -217,9 +244,12 @@ export function createSelection({
},
);
if (!ranked.length) return null;
layerState._playFallbackId = ranked[1]?.id || null;
layerState._playFallbackFocus = false;
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
selectRadioStation(ranked[0].id, {
autoplay,
focus: false,
origin,
playFallback: { id: ranked[1]?.id || null, focus: false },
});
return ranked[0];
}
+2
View File
@@ -78,6 +78,8 @@ export function createState({ services }) {
layerState._tuningStartStationId = null;
layerState._tuningResumesPlayback = false;
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._tuningStationById = new Map();
+17 -5
View File
@@ -66,6 +66,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._cancelledTuningPresentationStation = null;
layerState._tuningStartStationId = layerState._selectedId;
layerState._tuningPreviewId = layerState._selectedId;
// Like Previous/Next, the release plays only if a stream was active.
layerState._tuningResumesPlayback = [
'loading',
'buffering',
'playing',
].includes(layerState._audioState);
parts.tuningNoise.installTuningNoise();
parts.playback.pauseRadioPlayback({ origin: 'user' });
parts.tuningNoise.syncTuningNoiseGain();
@@ -179,7 +185,9 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._selectionGeneration += 1;
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
layerState._tuningUnavailableStationId = stationId;
parts.presentation.emitState();
// The drag paused the stream; with no selection, Resume would start the
// nearest station. Stop also publishes the state and cancels a voice handoff.
parts.playback.stopRadioPlayback({ origin });
return Object.freeze({
ok: false,
reason: 'station-unavailable',
@@ -187,9 +195,11 @@ export function createTuning({ state: layerState, services, parts, source }) {
generation,
});
}
const autoplay = layerState._tuningResumesPlayback;
layerState._tuningResumesPlayback = false;
layerState._tuningActive = false;
layerState._tuningStatic = true;
layerState._tuningAwaitingStationId = frozenStation.id;
layerState._tuningStatic = autoplay;
layerState._tuningAwaitingStationId = autoplay ? frozenStation.id : null;
layerState._tuningPreviewId = null;
layerState._tuningStartStationId = null;
layerState._tuningUnavailableStationId = null;
@@ -204,10 +214,12 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
parts.tuningNoise.syncTuningNoiseGain();
// Muting alone would leave the noise graph running after a silent release.
if (autoplay) parts.tuningNoise.syncTuningNoiseGain();
else parts.tuningNoise.clearRadioTuningNoise({ emit: false });
parts.presentation.emitState();
const selected = parts.selection.selectRadioStation(frozenStation.id, {
autoplay: true,
autoplay,
focus: false,
origin,
cameraNavigation,
+22 -4
View File
@@ -50,6 +50,11 @@ export function createTuningNoise({
layerState._tuningNoiseGain = null;
}
function suspendTuningNoiseContext() {
const suspended = layerState._tuningNoiseContext?.suspend?.();
if (suspended?.catch) void suspended.catch(() => {});
}
function syncTuningNoiseGain() {
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
const audible = radioTuningStaticShouldPlay({
@@ -79,8 +84,21 @@ export function createTuningNoise({
if (!AudioContextClass) return false;
if (!layerState._tuningNoiseContext)
layerState._tuningNoiseContext = new AudioContextClass();
const resumed = layerState._tuningNoiseContext.resume?.();
if (resumed?.catch) void resumed.catch(() => {});
const context = layerState._tuningNoiseContext;
const resumed = context.resume?.();
// A release before the device starts finds the context still suspended,
// so its suspend() is a no-op; suspend again once resume() settles.
if (resumed?.then)
void resumed.then(
() => {
if (
layerState._tuningNoiseContext === context &&
!layerState._tuningNoiseSource
)
suspendTuningNoiseContext();
},
() => {},
);
if (layerState._tuningNoiseSource) return true;
const frameCount = Math.max(
@@ -120,6 +138,7 @@ export function createTuningNoise({
if (layerState._tuningCameraNavigation)
parts.navigation.invalidateRadioCameraNavigation();
layerState._tuningActive = false;
layerState._tuningResumesPlayback = false;
layerState._tuningStatic = false;
layerState._tuningAwaitingStationId = null;
layerState._tuningPreviewId = null;
@@ -131,8 +150,7 @@ export function createTuningNoise({
parts.rendering.updateSelectionEntity();
syncTuningNoiseGain();
stopTuningNoiseSource();
const suspended = layerState._tuningNoiseContext?.suspend?.();
if (suspended?.catch) void suspended.catch(() => {});
suspendTuningNoiseContext();
if (emit) parts.presentation.emitState();
}
return {