import { onMounted, onUnmounted, readonly, shallowRef, type Ref } from 'vue'; import { resolveMotionPolicy, type MotionPolicy, type QualityTier, } from '@/journey/motion-policy'; export const MOTION_PREFERENCE_KEYS = Object.freeze({ pause: 'portfolio.motion.pause', maximumQuality: 'portfolio.motion.maximum-quality', volume: 'portfolio.audio.volume', }); const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true }; const QUALITY_TIERS = new Set(['high', 'medium', 'low', 'static']); interface NetworkInformationLike extends EventTarget { saveData?: boolean; } export interface MotionPreferences { policy: Readonly>; paused: Readonly>; maximumQuality: Readonly>; volume: Readonly>; systemReduce: Readonly>; dataSaver: Readonly>; setPaused(value: boolean): void; setMaximumQuality(value: QualityTier): void; setVolume(value: number): void; } const failClosedPolicy = (): MotionPolicy => resolveMotionPolicy({ systemReduce: false, savedPause: true, savedStatic: true, dataSaver: false, width: 0, coarsePointer: true, savedMaximum: 'static', }); export function useMotionPreferences(): MotionPreferences { const policy = shallowRef(failClosedPolicy()); const paused = shallowRef(true); const maximumQuality = shallowRef('static'); const volume = shallowRef(0); const systemReduce = shallowRef(false); const dataSaver = shallowRef(false); let reducedQuery: MediaQueryList | undefined; let coarseQuery: MediaQueryList | undefined; let connection: NetworkInformationLike | undefined; let teardown = (): void => {}; const viewportWidth = (): number => window.visualViewport?.width ?? window.innerWidth; const updatePolicy = (): void => { const coarsePointer = coarseQuery?.matches ?? true; policy.value = resolveMotionPolicy({ systemReduce: systemReduce.value, savedPause: paused.value, savedStatic: maximumQuality.value === 'static', dataSaver: dataSaver.value, width: viewportWidth(), coarsePointer, savedMaximum: maximumQuality.value, }); }; const persist = (key: string, value: string): void => { try { if (typeof window !== 'undefined') { window.localStorage.setItem(key, value); } } catch { // Preference persistence is optional; the in-memory safe state still applies. } }; const setPaused = (value: boolean): void => { paused.value = value; persist(MOTION_PREFERENCE_KEYS.pause, String(value)); if (typeof window !== 'undefined') { updatePolicy(); } }; const setMaximumQuality = (value: QualityTier): void => { maximumQuality.value = value; persist(MOTION_PREFERENCE_KEYS.maximumQuality, value); if (typeof window !== 'undefined') { updatePolicy(); } }; const setVolume = (value: number): void => { volume.value = Number.isFinite(value) && value >= 0 && value <= 1 ? value : 0; persist(MOTION_PREFERENCE_KEYS.volume, String(volume.value)); }; onMounted(() => { const disposers: Array<() => void> = []; try { const stored = readStoredPreferences(window.localStorage); paused.value = stored.paused; maximumQuality.value = stored.maximumQuality; volume.value = stored.volume; reducedQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); coarseQuery = window.matchMedia('(pointer: coarse)'); connection = (window.navigator as Navigator & { connection?: NetworkInformationLike }).connection; const updateReduced = (): void => { systemReduce.value = reducedQuery?.matches ?? true; updatePolicy(); }; const updateCoarse = (): void => updatePolicy(); const updateConnection = (): void => { dataSaver.value = connection?.saveData === true; updatePolicy(); }; const updateViewport = (): void => updatePolicy(); systemReduce.value = reducedQuery.matches; dataSaver.value = connection?.saveData === true; updatePolicy(); reducedQuery.addEventListener('change', updateReduced); coarseQuery.addEventListener('change', updateCoarse); connection?.addEventListener('change', updateConnection); window.addEventListener('resize', updateViewport, PASSIVE_LISTENER); disposers.push( () => reducedQuery?.removeEventListener('change', updateReduced), () => coarseQuery?.removeEventListener('change', updateCoarse), () => connection?.removeEventListener('change', updateConnection), () => window.removeEventListener('resize', updateViewport, PASSIVE_LISTENER), ); } catch { paused.value = true; maximumQuality.value = 'static'; volume.value = 0; systemReduce.value = true; dataSaver.value = true; policy.value = failClosedPolicy(); } teardown = (): void => { for (const dispose of disposers) { dispose(); } disposers.length = 0; }; }); onUnmounted(() => teardown()); return { policy: readonly(policy), paused: readonly(paused), maximumQuality: readonly(maximumQuality), volume: readonly(volume), systemReduce: readonly(systemReduce), dataSaver: readonly(dataSaver), setPaused, setMaximumQuality, setVolume, }; } function readStoredPreferences(storage: Storage): { paused: boolean; maximumQuality: QualityTier; volume: number; } { const storedPause = storage.getItem(MOTION_PREFERENCE_KEYS.pause); const storedMaximum = storage.getItem(MOTION_PREFERENCE_KEYS.maximumQuality); const storedVolume = storage.getItem(MOTION_PREFERENCE_KEYS.volume); return { paused: storedPause === null ? false : storedPause === 'false' ? false : true, maximumQuality: storedMaximum === null ? 'high' : QUALITY_TIERS.has(storedMaximum as QualityTier) ? storedMaximum as QualityTier : 'static', volume: parseVolume(storedVolume), }; } function parseVolume(value: string | null): number { if (value === null) { return 0.4; } const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0; }