feat: coordinate adaptive audio intent

This commit is contained in:
2026-09-04 15:34:32 +03:00
parent 7632c8b9c0
commit 5250a3174c
6 changed files with 812 additions and 15 deletions

View File

@@ -0,0 +1,105 @@
import {
onMounted,
onUnmounted,
readonly,
shallowRef,
watch,
type Ref,
} from 'vue';
import {
createAudioIntentCoordinator,
type AudioIntentCoordinator,
type AudioIntentUpdate,
} from '@/audio/audio-intent-coordinator';
import {
DEFAULT_DRIVE_ENERGY,
resolveChapterIntent,
} from '@/audio/journey-music-state';
import type { ChapterIntent, DriveEnergySample } from '@/audio/types';
import type { JourneySectionsBinding } from '@/composables/useJourneySections';
import type { SectionId } from '@/content/types';
import type { NavigationState } from '@/journey/navigation-controller';
import type { JourneySnapshot } from '@/journey/types';
export interface AudioIntentSources {
readonly canonicalIds: readonly SectionId[];
readonly journey: JourneySectionsBinding;
readonly navigation: Readonly<Ref<NavigationState>>;
}
export interface AudioIntentBinding {
readonly chapter: Readonly<Ref<ChapterIntent>>;
readonly drive: Readonly<Ref<DriveEnergySample>>;
}
export function useAudioIntent(sources: AudioIntentSources): AudioIntentBinding {
const firstId = sources.canonicalIds[0];
if (firstId === undefined) throw new Error('Audio intent requires one section.');
const chapter = shallowRef(resolveChapterIntent(firstId));
const drive = shallowRef<DriveEnergySample>(DEFAULT_DRIVE_ENERGY);
let coordinator: AudioIntentCoordinator | null = null;
let removeFrame = (): void => {};
const publish = (update: AudioIntentUpdate): boolean => {
if (update.chapter !== null) chapter.value = update.chapter;
if (update.drive !== null) drive.value = update.drive;
return update.needsFrame;
};
const sample = (now: number, snapshot: JourneySnapshot): boolean => {
if (coordinator === null) {
coordinator = createAudioIntentCoordinator(
sources.canonicalIds,
snapshot.activeSectionId,
snapshot.sectionActivation.layoutRevision,
snapshot.scrollY,
);
const seeded = coordinator.current();
chapter.value = seeded.chapter;
drive.value = seeded.drive;
}
return publish(coordinator.sample({
now,
visible: document.visibilityState !== 'hidden',
scrollY: snapshot.scrollY,
activeSectionId: snapshot.activeSectionId,
acceleration: snapshot.acceleration,
sectionActivation: snapshot.sectionActivation,
navigation: sources.navigation.value,
}));
};
const stopNavigationWatch = watch(
sources.navigation,
() => sources.journey.requestFrame(),
{ flush: 'sync' },
);
onMounted(() => {
removeFrame = sources.journey.subscribeFrame(sample);
const resetFromCurrentSnapshot = (): void => {
if (coordinator === null) return;
const snapshot = sources.journey.snapshot.value;
publish(coordinator.reset({
navigation: sources.navigation.value,
scrollY: snapshot.scrollY,
sectionActivation: snapshot.sectionActivation,
}));
};
const onVisibility = (): void => {
resetFromCurrentSnapshot();
if (document.visibilityState !== 'hidden') sources.journey.requestFrame();
};
document.addEventListener('visibilitychange', onVisibility);
removeFrame = combineCleanup(removeFrame, () =>
document.removeEventListener('visibilitychange', onVisibility));
sources.journey.requestFrame();
});
onUnmounted(() => {
stopNavigationWatch();
removeFrame();
});
return { chapter: readonly(chapter), drive: readonly(drive) };
}
function combineCleanup(left: () => void, right: () => void): () => void {
return () => { left(); right(); };
}