|
|
@@ -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}
|