浏览代码

[Phase 5] Landing roll: carousel emerge animation, snap-to-gap, no auto-resume

- Result card now emerges in the carousel center (was: below the buttons)
  via a new animate-emerge keyframe (scale 0 -> 1.08 -> 1, ~500ms,
  bouncy easing); skipped under prefers-reduced-motion
- Posters spread apart at viewport center on settle: per-poster
  translateX(±SPREAD_AMOUNT) so the card pops into a clean gap with
  symmetric posters bracketing it
- Snap-to-gap math (gapMidOffset = ITEM_WIDTH + ITEM_GAP/2): spin ends
  with the midpoint of a gap between posters at viewport center, which
  guarantees even spacing regardless of viewport width
- Removed RESUME_DELAY auto-resume timer: card stays settled until the
  user re-rolls (or refreshes)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User 2 月之前
父节点
当前提交
dea71d9
共有 2 个文件被更改,包括 86 次插入60 次删除
  1. 16 0
      src/app/globals.css
  2. 70 60
      src/components/landing/carousel-animation.tsx

+ 16 - 0
src/app/globals.css

@@ -34,3 +34,19 @@ body {
 @utility animate-shake {
   animation: shake 0.5s ease-in-out;
 }
+
+@keyframes dice-emerge {
+  0% { transform: scale(0); opacity: 0; }
+  60% { transform: scale(1.08); opacity: 1; }
+  100% { transform: scale(1); opacity: 1; }
+}
+
+@utility animate-emerge {
+  animation: dice-emerge 500ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .animate-emerge {
+    animation: none;
+  }
+}

+ 70 - 60
src/components/landing/carousel-animation.tsx

@@ -19,7 +19,7 @@ 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
+const SPREAD_AMOUNT = 110; // px each side — opens a gap at the carousel center for the result card
 
 type CarouselPhase = "auto-scroll" | "spinning" | "settled";
 
@@ -53,18 +53,29 @@ export function CarouselSection() {
   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 [viewportCenter, setViewportCenter] = useState(0);
 
   const scrollOffsetRef = useRef(0);
   const stripRef = useRef<HTMLDivElement>(null);
+  const viewportRef = 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(() => {
+    if (!viewportRef.current) return;
+    const measure = () => {
+      if (viewportRef.current) setViewportCenter(viewportRef.current.offsetWidth / 2);
+    };
+    measure();
+    const ro = new ResizeObserver(measure);
+    ro.observe(viewportRef.current);
+    return () => ro.disconnect();
+  }, []);
+
   useEffect(() => {
     fetch("/api/tmdb/reel-posters")
       .then((res) => res.json())
@@ -96,14 +107,18 @@ export function CarouselSection() {
       } 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;
+          // Snap so the midpoint of a gap between posters lands at viewport center.
+          // That way the spread effect creates an evenly-bracketed slot for the card.
+          const halfViewport = viewportRef.current
+            ? viewportRef.current.offsetWidth / 2
+            : 0;
+          const gapMidOffset = ITEM_WIDTH + ITEM_GAP / 2;
+          const i = Math.round(
+            (scrollOffsetRef.current + halfViewport - gapMidOffset) / POSTER_STRIDE,
+          );
+          const snapped = i * POSTER_STRIDE + gapMidOffset - halfViewport;
+          scrollOffsetRef.current = ((snapped % SET_WIDTH) + SET_WIDTH) % SET_WIDTH;
           applyTransform();
-          const idx =
-            Math.round(scrollOffsetRef.current / POSTER_STRIDE) % posters.length;
-          setHighlightIndex(idx);
           setPhase("settled");
           setResult(pendingResultRef.current);
           setLoading(false);
@@ -131,19 +146,6 @@ export function CarouselSection() {
     };
   }, [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;
@@ -161,7 +163,6 @@ export function CarouselSection() {
   const fetchAndRoll = async (url: string) => {
     if (phase === "spinning" || loading) return;
     setResult(null);
-    setHighlightIndex(null);
     setLoading(true);
 
     try {
@@ -209,34 +210,51 @@ export function CarouselSection() {
 
   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 className="relative flex min-h-80 w-full items-center justify-center">
+        <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">
+            {tripled.map((poster, i) => {
+              const url = getTMDBImageUrl(poster.poster_path, "reel");
+              const spread =
+                phase === "settled" && result && viewportCenter > 0
+                  ? i * POSTER_STRIDE - scrollOffsetRef.current + ITEM_WIDTH / 2 < viewportCenter
+                    ? -SPREAD_AMOUNT
+                    : SPREAD_AMOUNT
+                  : 0;
+              return (
+                <div
+                  key={`${poster.tmdb_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>
+              );
+            })}
+          </div>
+        </div>
+
+        <div
+          className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center"
+          aria-live="polite"
+        >
+          {phase === "settled" && result && (
+            <div className="pointer-events-auto animate-emerge">
+              <TeaserCard movie={result} />
+            </div>
+          )}
         </div>
       </div>
 
@@ -258,14 +276,6 @@ export function CarouselSection() {
         </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}