use-roll.ts 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. "use client";
  2. import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from "react";
  3. import type { Movie } from "@/types/movie";
  4. import { selectRandomMovie } from "@/lib/dice/randomizer";
  5. export type RollState = "idle" | "rolling" | "complete";
  6. // Aligned with ListRollCarousel's visible settle: ENTRANCE_MS (500) +
  7. // PRE_SPIN_PAUSE_MS (100) + SPIN_DURATION_MS (2750) = 3350ms. RollAnnouncer
  8. // fires its "complete" aria-live message off this state transition, so it
  9. // must match what sighted users see — earlier values announced the result
  10. // while the carousel was still spinning.
  11. const ANIMATION_DURATION_MS = 3350;
  12. const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
  13. function subscribeToReducedMotion(callback: () => void) {
  14. const mq = window.matchMedia(REDUCED_MOTION_QUERY);
  15. mq.addEventListener("change", callback);
  16. return () => mq.removeEventListener("change", callback);
  17. }
  18. function getReducedMotionSnapshot() {
  19. return window.matchMedia(REDUCED_MOTION_QUERY).matches;
  20. }
  21. function getReducedMotionServerSnapshot() {
  22. return false;
  23. }
  24. function usePrefersReducedMotion(): boolean {
  25. return useSyncExternalStore(
  26. subscribeToReducedMotion,
  27. getReducedMotionSnapshot,
  28. getReducedMotionServerSnapshot,
  29. );
  30. }
  31. export interface UseRollReturn {
  32. result: Movie | null;
  33. rollState: RollState;
  34. /** Omit `eligibleMovies` to re-roll from the previously captured snapshot. */
  35. roll: (eligibleMovies?: Movie[]) => void;
  36. reset: () => void;
  37. }
  38. export function useRoll(): UseRollReturn {
  39. const [result, setResult] = useState<Movie | null>(null);
  40. const [rollState, setRollState] = useState<RollState>("idle");
  41. const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  42. const capturedPoolRef = useRef<Movie[]>([]);
  43. const prefersReducedMotion = usePrefersReducedMotion();
  44. useEffect(() => {
  45. return () => {
  46. if (timerRef.current) {
  47. clearTimeout(timerRef.current);
  48. timerRef.current = null;
  49. }
  50. };
  51. }, []);
  52. const roll = useCallback(
  53. (eligibleMovies?: Movie[]) => {
  54. if (timerRef.current) {
  55. clearTimeout(timerRef.current);
  56. timerRef.current = null;
  57. }
  58. // Capture by value so concurrent real-time cache mutations to the upstream
  59. // array cannot change the in-flight winner.
  60. const snapshot = eligibleMovies !== undefined ? [...eligibleMovies] : capturedPoolRef.current;
  61. capturedPoolRef.current = snapshot;
  62. const winner = selectRandomMovie(snapshot);
  63. setResult(winner);
  64. if (prefersReducedMotion) {
  65. setRollState("complete");
  66. return;
  67. }
  68. setRollState("rolling");
  69. timerRef.current = setTimeout(() => {
  70. setRollState("complete");
  71. timerRef.current = null;
  72. }, ANIMATION_DURATION_MS);
  73. },
  74. [prefersReducedMotion],
  75. );
  76. const reset = useCallback(() => {
  77. if (timerRef.current) {
  78. clearTimeout(timerRef.current);
  79. timerRef.current = null;
  80. }
  81. capturedPoolRef.current = [];
  82. setResult(null);
  83. setRollState("idle");
  84. }, []);
  85. return { result, rollState, roll, reset };
  86. }