|
|
@@ -0,0 +1,280 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
|
+import { getTMDBImageUrl } from "@/types/tmdb";
|
|
|
+import type { Movie } from "@/types/movie";
|
|
|
+import type { RollState } from "@/hooks/use-roll";
|
|
|
+import { ListMoreInfoModal } from "./list-more-info-modal";
|
|
|
+
|
|
|
+const ITEM_WIDTH = 112; // w-28
|
|
|
+const ITEM_GAP = 12; // gap-3
|
|
|
+const POSTER_STRIDE = ITEM_WIDTH + ITEM_GAP;
|
|
|
+// Carousel pops in via the dice-emerge keyframe (~500ms scale-bounce), then
|
|
|
+// pauses briefly, then spins. useRoll's own 2500ms timer governs `state`
|
|
|
+// transitions but doesn't gate the visual settle — the carousel owns its
|
|
|
+// timeline and runs slightly longer for a punchier feel.
|
|
|
+const ENTRANCE_MS = 500; // matches @utility animate-emerge duration
|
|
|
+const PRE_SPIN_PAUSE_MS = 100;
|
|
|
+const SPIN_DURATION_MS = 2750;
|
|
|
+const FAST_SPEED = 38;
|
|
|
+const SPREAD_AMOUNT = 110;
|
|
|
+const MIN_TILE_COUNT = 10;
|
|
|
+
|
|
|
+type Phase = "entrance" | "spinning" | "settled";
|
|
|
+
|
|
|
+const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
|
|
+
|
|
|
+function subscribeRM(cb: () => void) {
|
|
|
+ const mq = window.matchMedia(REDUCED_MOTION_QUERY);
|
|
|
+ mq.addEventListener("change", cb);
|
|
|
+ return () => mq.removeEventListener("change", cb);
|
|
|
+}
|
|
|
+function rmSnapshot() {
|
|
|
+ return window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
|
|
+}
|
|
|
+function rmServerSnapshot() {
|
|
|
+ return false;
|
|
|
+}
|
|
|
+function usePrefersReducedMotion() {
|
|
|
+ return useSyncExternalStore(subscribeRM, rmSnapshot, rmServerSnapshot);
|
|
|
+}
|
|
|
+
|
|
|
+interface ListRollCarouselProps {
|
|
|
+ /**
|
|
|
+ * Pool of movies to *display* in the strip. Pass the full list so watched
|
|
|
+ * movies still appear visually — winner-eligibility is decided upstream by
|
|
|
+ * useRoll, not here.
|
|
|
+ */
|
|
|
+ pool: Movie[];
|
|
|
+ winner: Movie | null;
|
|
|
+ state: RollState;
|
|
|
+ onWatchedToggle: (movie: Movie) => void;
|
|
|
+}
|
|
|
+
|
|
|
+export function ListRollCarousel({ pool, winner, state, onWatchedToggle }: ListRollCarouselProps) {
|
|
|
+ const prefersReducedMotion = usePrefersReducedMotion();
|
|
|
+ const [phase, setPhase] = useState<Phase>("entrance");
|
|
|
+ const [viewportWidth, setViewportWidth] = useState(0);
|
|
|
+
|
|
|
+ const stripRef = useRef<HTMLDivElement>(null);
|
|
|
+ const viewportRef = useRef<HTMLDivElement>(null);
|
|
|
+ const animRef = useRef<number | null>(null);
|
|
|
+ const offsetRef = useRef(0);
|
|
|
+ const spinStartRef = useRef(0);
|
|
|
+
|
|
|
+ // Tile the pool so we always have enough posters for a smooth continuous
|
|
|
+ // scroll (landing carousel uses tripled tiling with modular wraparound).
|
|
|
+ const tiled = (() => {
|
|
|
+ if (!pool.length) return [];
|
|
|
+ const out: Movie[] = [];
|
|
|
+ while (out.length < MIN_TILE_COUNT) {
|
|
|
+ for (const m of pool) {
|
|
|
+ out.push(m);
|
|
|
+ if (out.length >= MIN_TILE_COUNT && out.length % pool.length === 0) break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ })();
|
|
|
+ const tripled = tiled.length > 0 ? [...tiled, ...tiled, ...tiled] : [];
|
|
|
+ const SET_WIDTH = tiled.length * POSTER_STRIDE;
|
|
|
+
|
|
|
+ // Measure viewport width for spread/center math.
|
|
|
+ useEffect(() => {
|
|
|
+ if (!viewportRef.current) return;
|
|
|
+ const measure = () => {
|
|
|
+ if (viewportRef.current) setViewportWidth(viewportRef.current.offsetWidth);
|
|
|
+ };
|
|
|
+ measure();
|
|
|
+ const ro = new ResizeObserver(measure);
|
|
|
+ ro.observe(viewportRef.current);
|
|
|
+ return () => ro.disconnect();
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // Reset to entrance phase whenever a new roll starts. The spin kicks off
|
|
|
+ // after ENTRANCE_MS + PRE_SPIN_PAUSE_MS, giving the carousel time to
|
|
|
+ // animate in before it starts moving.
|
|
|
+ useEffect(() => {
|
|
|
+ if (state === "rolling") {
|
|
|
+ setPhase("entrance");
|
|
|
+ spinStartRef.current = 0;
|
|
|
+ const t = setTimeout(() => setPhase("spinning"), ENTRANCE_MS + PRE_SPIN_PAUSE_MS);
|
|
|
+ return () => clearTimeout(t);
|
|
|
+ }
|
|
|
+ }, [state]);
|
|
|
+
|
|
|
+ // Animation loop. Runs while we're in a non-idle state and not reduced-motion.
|
|
|
+ useEffect(() => {
|
|
|
+ if (state === "idle") return;
|
|
|
+ if (!tiled.length) return;
|
|
|
+ if (prefersReducedMotion) {
|
|
|
+ // Skip animation entirely — settle immediately so the teaser emerges.
|
|
|
+ setPhase("settled");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (phase !== "spinning") return;
|
|
|
+
|
|
|
+ function applyTransform() {
|
|
|
+ if (stripRef.current) {
|
|
|
+ stripRef.current.style.transform = `translateX(-${offsetRef.current}px)`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function tick(ts: number) {
|
|
|
+ if (spinStartRef.current === 0) spinStartRef.current = ts;
|
|
|
+ const elapsed = ts - spinStartRef.current;
|
|
|
+
|
|
|
+ if (elapsed >= SPIN_DURATION_MS) {
|
|
|
+ // Snap so a gap between posters lands at viewport center — opens an
|
|
|
+ // even bracket for the teaser card to emerge into.
|
|
|
+ const halfVp = viewportRef.current ? viewportRef.current.offsetWidth / 2 : 0;
|
|
|
+ const gapMidOffset = ITEM_WIDTH + ITEM_GAP / 2;
|
|
|
+ const i = Math.round((offsetRef.current + halfVp - gapMidOffset) / POSTER_STRIDE);
|
|
|
+ const snapped = i * POSTER_STRIDE + gapMidOffset - halfVp;
|
|
|
+ offsetRef.current = ((snapped % SET_WIDTH) + SET_WIDTH) % SET_WIDTH;
|
|
|
+ applyTransform();
|
|
|
+ setPhase("settled");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const progress = elapsed / SPIN_DURATION_MS;
|
|
|
+ const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
|
|
|
+ const speed = FAST_SPEED * (1 - eased);
|
|
|
+ offsetRef.current += speed;
|
|
|
+ if (offsetRef.current >= SET_WIDTH) offsetRef.current -= SET_WIDTH;
|
|
|
+ applyTransform();
|
|
|
+
|
|
|
+ animRef.current = requestAnimationFrame(tick);
|
|
|
+ }
|
|
|
+
|
|
|
+ animRef.current = requestAnimationFrame(tick);
|
|
|
+ return () => {
|
|
|
+ if (animRef.current) cancelAnimationFrame(animRef.current);
|
|
|
+ };
|
|
|
+ }, [state, phase, prefersReducedMotion, tiled.length, SET_WIDTH]);
|
|
|
+
|
|
|
+ if (state === "idle") return null;
|
|
|
+ if (!tiled.length) return null;
|
|
|
+
|
|
|
+ const halfVp = viewportWidth / 2;
|
|
|
+ const showTeaser = phase === "settled" && winner;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col items-center gap-4">
|
|
|
+ <div className="relative flex min-h-[22rem] w-full items-center justify-center animate-emerge">
|
|
|
+ <div
|
|
|
+ ref={viewportRef}
|
|
|
+ className="absolute inset-x-0 top-1/2 -translate-y-1/2 overflow-hidden"
|
|
|
+ >
|
|
|
+ <div ref={stripRef} className="flex gap-3" aria-label="Movie poster carousel">
|
|
|
+ {/* eslint-disable-next-line react-hooks/refs -- offsetRef is stable in settled phase; rAF loop writes transform imperatively */}
|
|
|
+ {tripled.map((movie, i) => {
|
|
|
+ const url = getTMDBImageUrl(movie.poster_path, "reel");
|
|
|
+ const center = i * POSTER_STRIDE - offsetRef.current + ITEM_WIDTH / 2;
|
|
|
+ const spread =
|
|
|
+ showTeaser && halfVp > 0 ? (center < halfVp ? -SPREAD_AMOUNT : SPREAD_AMOUNT) : 0;
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={`${movie.id}-${i}`}
|
|
|
+ className="h-40 w-28 flex-shrink-0 transition-transform duration-500 ease-out"
|
|
|
+ style={{ transform: `translateX(${spread}px)` }}
|
|
|
+ >
|
|
|
+ {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 className="flex h-full w-full items-center justify-center rounded-lg bg-foreground/10 p-2 text-center text-xs text-foreground/60">
|
|
|
+ {movie.title}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center"
|
|
|
+ aria-live="polite"
|
|
|
+ >
|
|
|
+ {showTeaser && (
|
|
|
+ <div className="pointer-events-auto animate-emerge">
|
|
|
+ <ListTeaserCard movie={winner} onWatchedToggle={onWatchedToggle} />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function ListTeaserCard({
|
|
|
+ movie,
|
|
|
+ onWatchedToggle,
|
|
|
+}: {
|
|
|
+ movie: Movie;
|
|
|
+ onWatchedToggle: (movie: Movie) => void;
|
|
|
+}) {
|
|
|
+ const [modalOpen, setModalOpen] = useState(false);
|
|
|
+ const posterUrl = getTMDBImageUrl(movie.poster_path, "grid");
|
|
|
+
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <div
|
|
|
+ className="flex w-44 flex-col items-center rounded-xl bg-background/95 p-3 ring-2 ring-yellow-400/70 shadow-[0_0_32px_rgba(250,204,21,0.55)] backdrop-blur"
|
|
|
+ aria-live="polite"
|
|
|
+ >
|
|
|
+ {posterUrl ? (
|
|
|
+ /* eslint-disable-next-line @next/next/no-img-element */
|
|
|
+ <img
|
|
|
+ src={posterUrl}
|
|
|
+ alt={`Movie poster for ${movie.title}`}
|
|
|
+ loading="lazy"
|
|
|
+ className="h-auto w-36 rounded-lg"
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <div className="flex h-52 w-36 items-center justify-center rounded-lg bg-foreground/10 text-xs text-foreground/40">
|
|
|
+ No poster
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <h3 className="mt-2 text-center text-sm font-semibold leading-tight line-clamp-2">
|
|
|
+ {movie.title}
|
|
|
+ {movie.year > 0 && (
|
|
|
+ <span className="ml-1 text-xs font-normal text-foreground/50">({movie.year})</span>
|
|
|
+ )}
|
|
|
+ </h3>
|
|
|
+ <button
|
|
|
+ onClick={() => setModalOpen(true)}
|
|
|
+ aria-label={`More info about ${movie.title}`}
|
|
|
+ className="mt-1.5 flex h-6 w-6 items-center justify-center rounded-full border border-foreground/30 text-xs font-serif italic text-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
|
|
+ >
|
|
|
+ i
|
|
|
+ </button>
|
|
|
+ {movie.genres.length > 0 && (
|
|
|
+ <div className="mt-1.5 flex flex-wrap justify-center gap-1">
|
|
|
+ {movie.genres.slice(0, 2).map((genre) => (
|
|
|
+ <span
|
|
|
+ key={genre}
|
|
|
+ className="rounded-full bg-foreground/10 px-2 py-0.5 text-[10px] text-foreground/70"
|
|
|
+ >
|
|
|
+ {genre}
|
|
|
+ </span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {modalOpen && (
|
|
|
+ <ListMoreInfoModal
|
|
|
+ movie={movie}
|
|
|
+ onClose={() => setModalOpen(false)}
|
|
|
+ onWatchedToggle={onWatchedToggle}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ );
|
|
|
+}
|