"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(null); const [rollState, setRollState] = useState("idle"); const timerRef = useRef | null>(null); const capturedPoolRef = useRef([]); 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 }; }