|
|
@@ -0,0 +1,264 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import { useState, useEffect, useRef, useSyncExternalStore } from "react";
|
|
|
+import { getTMDBImageUrl } from "@/types/tmdb";
|
|
|
+import type { TMDBMovie } from "@/types/tmdb";
|
|
|
+import { TeaserCard } from "./teaser-card";
|
|
|
+import { GenreRollModal } from "./genre-roll-modal";
|
|
|
+
|
|
|
+interface ReelPoster {
|
|
|
+ tmdb_id: number;
|
|
|
+ poster_path: string;
|
|
|
+ title: string;
|
|
|
+}
|
|
|
+
|
|
|
+const ITEM_WIDTH = 112; // w-28 = 7rem
|
|
|
+const ITEM_GAP = 12; // gap-3 = 0.75rem
|
|
|
+const POSTER_STRIDE = ITEM_WIDTH + ITEM_GAP; // 124px
|
|
|
+const AUTO_SPEED = 0.5; // px per rAF frame
|
|
|
+const FAST_SPEED = 18; // px per frame during spin
|
|
|
+const SPIN_DURATION = 3500; // ms
|
|
|
+const RESUME_DELAY = 15000; // ms before auto-scroll resumes after settle
|
|
|
+
|
|
|
+type CarouselPhase = "auto-scroll" | "spinning" | "settled";
|
|
|
+
|
|
|
+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 function CarouselSection() {
|
|
|
+ const [posters, setPosters] = useState<ReelPoster[]>([]);
|
|
|
+ const [phase, setPhase] = useState<CarouselPhase>("auto-scroll");
|
|
|
+ const [result, setResult] = useState<TMDBMovie | null>(null);
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
+ const [modalOpen, setModalOpen] = useState(false);
|
|
|
+ const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
|
|
|
+
|
|
|
+ const scrollOffsetRef = useRef(0);
|
|
|
+ const stripRef = useRef<HTMLDivElement>(null);
|
|
|
+ const animRef = useRef<number | null>(null);
|
|
|
+ const spinStartRef = useRef(0);
|
|
|
+ const genreRollBtnRef = useRef<HTMLButtonElement>(null);
|
|
|
+ const resumeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
+ const pendingResultRef = useRef<TMDBMovie | null>(null);
|
|
|
+
|
|
|
+ const prefersReducedMotion = usePrefersReducedMotion();
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ fetch("/api/tmdb/reel-posters")
|
|
|
+ .then((res) => res.json())
|
|
|
+ .then((data: { posters: ReelPoster[] }) => {
|
|
|
+ if (data.posters?.length) setPosters(data.posters);
|
|
|
+ })
|
|
|
+ .catch(() => {});
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const SET_WIDTH = posters.length * POSTER_STRIDE;
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!posters.length) return;
|
|
|
+ if (prefersReducedMotion && phase === "auto-scroll") return;
|
|
|
+
|
|
|
+ function applyTransform() {
|
|
|
+ if (stripRef.current) {
|
|
|
+ stripRef.current.style.transform = `translateX(-${scrollOffsetRef.current}px)`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function tick(timestamp: number) {
|
|
|
+ if (phase === "auto-scroll") {
|
|
|
+ scrollOffsetRef.current += AUTO_SPEED;
|
|
|
+ if (scrollOffsetRef.current >= SET_WIDTH) {
|
|
|
+ scrollOffsetRef.current -= SET_WIDTH;
|
|
|
+ }
|
|
|
+ applyTransform();
|
|
|
+ } else if (phase === "spinning") {
|
|
|
+ const elapsed = timestamp - spinStartRef.current;
|
|
|
+ if (elapsed >= SPIN_DURATION) {
|
|
|
+ // Snap to nearest poster
|
|
|
+ const snapped =
|
|
|
+ Math.round(scrollOffsetRef.current / POSTER_STRIDE) * POSTER_STRIDE;
|
|
|
+ scrollOffsetRef.current = snapped % SET_WIDTH;
|
|
|
+ applyTransform();
|
|
|
+ const idx =
|
|
|
+ Math.round(scrollOffsetRef.current / POSTER_STRIDE) % posters.length;
|
|
|
+ setHighlightIndex(idx);
|
|
|
+ setPhase("settled");
|
|
|
+ setResult(pendingResultRef.current);
|
|
|
+ setLoading(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const progress = elapsed / SPIN_DURATION;
|
|
|
+ const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
|
|
|
+ const currentSpeed = FAST_SPEED * (1 - eased);
|
|
|
+ scrollOffsetRef.current += currentSpeed;
|
|
|
+ if (scrollOffsetRef.current >= SET_WIDTH) {
|
|
|
+ scrollOffsetRef.current -= SET_WIDTH;
|
|
|
+ }
|
|
|
+ applyTransform();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (phase !== "settled") {
|
|
|
+ animRef.current = requestAnimationFrame(tick);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ animRef.current = requestAnimationFrame(tick);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ if (animRef.current) cancelAnimationFrame(animRef.current);
|
|
|
+ };
|
|
|
+ }, [phase, posters.length, prefersReducedMotion, SET_WIDTH]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (phase === "settled") {
|
|
|
+ resumeTimerRef.current = setTimeout(() => {
|
|
|
+ setPhase("auto-scroll");
|
|
|
+ setResult(null);
|
|
|
+ setHighlightIndex(null);
|
|
|
+ }, RESUME_DELAY);
|
|
|
+ }
|
|
|
+ return () => {
|
|
|
+ if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current);
|
|
|
+ };
|
|
|
+ }, [phase]);
|
|
|
+
|
|
|
+ const startRoll = (movies: TMDBMovie[]) => {
|
|
|
+ const chosen = movies[Math.floor(Math.random() * movies.length)];
|
|
|
+ pendingResultRef.current = chosen;
|
|
|
+
|
|
|
+ if (posters.length > 0 && !prefersReducedMotion) {
|
|
|
+ spinStartRef.current = performance.now();
|
|
|
+ setPhase("spinning");
|
|
|
+ } else {
|
|
|
+ setResult(chosen);
|
|
|
+ setLoading(false);
|
|
|
+ setPhase("settled");
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const fetchAndRoll = async (url: string) => {
|
|
|
+ if (phase === "spinning" || loading) return;
|
|
|
+ setResult(null);
|
|
|
+ setHighlightIndex(null);
|
|
|
+ setLoading(true);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(url);
|
|
|
+ const data: { results: TMDBMovie[] } = await res.json();
|
|
|
+ const movies = data.results ?? [];
|
|
|
+
|
|
|
+ if (movies.length === 0) {
|
|
|
+ setLoading(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ startRoll(movies);
|
|
|
+ } catch {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleRandomRoll = () => fetchAndRoll("/api/tmdb/popular");
|
|
|
+
|
|
|
+ const handleGenreRoll = (genreIds: number[]) => {
|
|
|
+ setModalOpen(false);
|
|
|
+ return fetchAndRoll(`/api/tmdb/discover?with_genres=${genreIds.join(",")}`);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleModalClose = () => {
|
|
|
+ setModalOpen(false);
|
|
|
+ genreRollBtnRef.current?.focus();
|
|
|
+ };
|
|
|
+
|
|
|
+ const tripled = posters.length > 0 ? [...posters, ...posters, ...posters] : [];
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col items-center gap-6">
|
|
|
+ <div className="w-full overflow-hidden">
|
|
|
+ <div ref={stripRef} className="flex gap-3" aria-label="Movie poster carousel">
|
|
|
+ {tripled.map((poster, i) => {
|
|
|
+ const url = getTMDBImageUrl(poster.poster_path, "reel");
|
|
|
+ const isHighlighted =
|
|
|
+ phase === "settled" &&
|
|
|
+ highlightIndex !== null &&
|
|
|
+ i % posters.length === highlightIndex;
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={`${poster.tmdb_id}-${i}`}
|
|
|
+ className={`h-40 w-28 flex-shrink-0 transition-transform duration-300 ${
|
|
|
+ isHighlighted ? "z-10 scale-110 ring-2 ring-foreground/50" : ""
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {url && (
|
|
|
+ /* eslint-disable-next-line @next/next/no-img-element */
|
|
|
+ <img
|
|
|
+ src={url}
|
|
|
+ alt=""
|
|
|
+ aria-hidden="true"
|
|
|
+ loading="lazy"
|
|
|
+ className="h-full w-full rounded-lg object-cover"
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex gap-3">
|
|
|
+ <button
|
|
|
+ onClick={handleRandomRoll}
|
|
|
+ disabled={phase === "spinning" || loading}
|
|
|
+ className="rounded-xl bg-foreground px-8 py-3 text-lg font-semibold text-background transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
|
+ >
|
|
|
+ {phase === "spinning" ? "Rolling..." : "Roll the Dice"}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ ref={genreRollBtnRef}
|
|
|
+ onClick={() => setModalOpen(true)}
|
|
|
+ disabled={phase === "spinning" || loading}
|
|
|
+ className="rounded-xl border border-foreground/20 px-8 py-3 text-lg font-semibold transition-colors hover:bg-foreground/5 disabled:opacity-50"
|
|
|
+ >
|
|
|
+ Genre Roll
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div aria-live="polite" className="min-h-[1px]">
|
|
|
+ {result && (
|
|
|
+ <div className="mt-4 flex justify-center">
|
|
|
+ <TeaserCard movie={result} />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {modalOpen && (
|
|
|
+ <GenreRollModal
|
|
|
+ onClose={handleModalClose}
|
|
|
+ onRoll={handleGenreRoll}
|
|
|
+ loading={loading}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|