| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105 |
- "use client";
- import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from "react";
- import type { Movie } from "@/types/movie";
- import { selectRandomMovie } from "@/lib/dice/randomizer";
- export type RollState = "idle" | "rolling" | "complete";
- // Aligned with ListRollCarousel's visible settle: ENTRANCE_MS (500) +
- // PRE_SPIN_PAUSE_MS (100) + SPIN_DURATION_MS (2750) = 3350ms. RollAnnouncer
- // fires its "complete" aria-live message off this state transition, so it
- // must match what sighted users see — earlier values announced the result
- // while the carousel was still spinning.
- const ANIMATION_DURATION_MS = 3350;
- const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
- function subscribeToReducedMotion(callback: () => void) {
- const mq = window.matchMedia(REDUCED_MOTION_QUERY);
- mq.addEventListener("change", callback);
- return () => mq.removeEventListener("change", callback);
- }
- function getReducedMotionSnapshot() {
- return window.matchMedia(REDUCED_MOTION_QUERY).matches;
- }
- function getReducedMotionServerSnapshot() {
- return false;
- }
- function usePrefersReducedMotion(): boolean {
- return useSyncExternalStore(
- subscribeToReducedMotion,
- getReducedMotionSnapshot,
- getReducedMotionServerSnapshot,
- );
- }
- export interface UseRollReturn {
- result: Movie | null;
- rollState: RollState;
- /** Omit `eligibleMovies` to re-roll from the previously captured snapshot. */
- roll: (eligibleMovies?: Movie[]) => void;
- reset: () => void;
- }
- export function useRoll(): UseRollReturn {
- const [result, setResult] = useState<Movie | null>(null);
- const [rollState, setRollState] = useState<RollState>("idle");
- const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
- const capturedPoolRef = useRef<Movie[]>([]);
- const prefersReducedMotion = usePrefersReducedMotion();
- useEffect(() => {
- return () => {
- if (timerRef.current) {
- clearTimeout(timerRef.current);
- timerRef.current = null;
- }
- };
- }, []);
- const roll = useCallback(
- (eligibleMovies?: Movie[]) => {
- if (timerRef.current) {
- clearTimeout(timerRef.current);
- timerRef.current = null;
- }
- // Capture by value so concurrent real-time cache mutations to the upstream
- // array cannot change the in-flight winner.
- const snapshot = eligibleMovies !== undefined ? [...eligibleMovies] : capturedPoolRef.current;
- capturedPoolRef.current = snapshot;
- const winner = selectRandomMovie(snapshot);
- setResult(winner);
- if (prefersReducedMotion) {
- setRollState("complete");
- return;
- }
- setRollState("rolling");
- timerRef.current = setTimeout(() => {
- setRollState("complete");
- timerRef.current = null;
- }, ANIMATION_DURATION_MS);
- },
- [prefersReducedMotion],
- );
- const reset = useCallback(() => {
- if (timerRef.current) {
- clearTimeout(timerRef.current);
- timerRef.current = null;
- }
- capturedPoolRef.current = [];
- setResult(null);
- setRollState("idle");
- }, []);
- return { result, rollState, roll, reset };
- }
|