201 lines
6.4 KiB
TypeScript
201 lines
6.4 KiB
TypeScript
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<QualityTier>(['high', 'medium', 'low', 'static']);
|
|
|
|
interface NetworkInformationLike extends EventTarget {
|
|
saveData?: boolean;
|
|
}
|
|
|
|
export interface MotionPreferences {
|
|
policy: Readonly<Ref<MotionPolicy>>;
|
|
paused: Readonly<Ref<boolean>>;
|
|
maximumQuality: Readonly<Ref<QualityTier>>;
|
|
volume: Readonly<Ref<number>>;
|
|
systemReduce: Readonly<Ref<boolean>>;
|
|
dataSaver: Readonly<Ref<boolean>>;
|
|
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<MotionPolicy>(failClosedPolicy());
|
|
const paused = shallowRef(true);
|
|
const maximumQuality = shallowRef<QualityTier>('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 requiresExplicitEnable = false;
|
|
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' || requiresExplicitEnable,
|
|
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 => {
|
|
if (!value) requiresExplicitEnable = false;
|
|
paused.value = value;
|
|
persist(MOTION_PREFERENCE_KEYS.pause, String(value));
|
|
if (typeof window !== 'undefined') {
|
|
updatePolicy();
|
|
}
|
|
};
|
|
|
|
const setMaximumQuality = (value: QualityTier): void => {
|
|
if (value !== 'static') requiresExplicitEnable = false;
|
|
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 => {
|
|
const nextSystemReduce = reducedQuery?.matches ?? true;
|
|
if (systemReduce.value && !nextSystemReduce) requiresExplicitEnable = true;
|
|
systemReduce.value = nextSystemReduce;
|
|
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;
|
|
}
|