From d5732917e318a4e9fa6a96bacaa95c1826f24a82 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 02:03:23 +0000 Subject: [PATCH] =?UTF-8?q?feat(reels):=20swipe=20horizontal=20anim=C3=A9?= =?UTF-8?q?=20avec=20suivi=20du=20doigt=20+=20snap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/decouvrir/_components/ReelSlide.tsx | 290 +++++++++++++++----- 1 file changed, 216 insertions(+), 74 deletions(-) diff --git a/src/app/decouvrir/_components/ReelSlide.tsx b/src/app/decouvrir/_components/ReelSlide.tsx index a8476f4..7c1b4e7 100644 --- a/src/app/decouvrir/_components/ReelSlide.tsx +++ b/src/app/decouvrir/_components/ReelSlide.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import type { ReelCarbet } from "@/lib/reels"; @@ -14,36 +14,71 @@ type Props = { onToggleFavorite: () => void; }; +const SWIPE_THRESHOLD_RATIO = 0.18; // % de la largeur pour valider le swipe +const VELOCITY_THRESHOLD = 0.4; // px/ms — un flick rapide même court valide + export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggleFavorite }: Props) { const [mediaIndex, setMediaIndex] = useState(0); const [muted, setMuted] = useState(true); - const touchStart = useRef<{ x: number; y: number } | null>(null); - const videoRef = useRef(null); + const [dragX, setDragX] = useState(0); + const [transitioning, setTransitioning] = useState(false); + const [containerWidth, setContainerWidth] = useState(0); + const containerRef = useRef(null); + const videoRefs = useRef>(new Map()); + const drag = useRef<{ + startX: number; + startY: number; + startTime: number; + locked: "horizontal" | "vertical" | null; + } | null>(null); + const total = carbet.media.length; const current = carbet.media[mediaIndex]; - const nextMedia = useCallback(() => { - setMediaIndex((i) => (i + 1) % carbet.media.length); - }, [carbet.media.length]); - const prevMedia = useCallback(() => { - setMediaIndex((i) => (i - 1 + carbet.media.length) % carbet.media.length); - }, [carbet.media.length]); + const goTo = useCallback( + (next: number, animated = true) => { + const clamped = ((next % total) + total) % total; + setTransitioning(animated); + setMediaIndex(clamped); + setDragX(0); + }, + [total], + ); - // Auto-play/pause vidéos quand slide active + const nextMedia = useCallback(() => goTo(mediaIndex + 1), [goTo, mediaIndex]); + const prevMedia = useCallback(() => goTo(mediaIndex - 1), [goTo, mediaIndex]); + + // Suit la largeur du container pour les calculs de seuils / progress useEffect(() => { - if (!videoRef.current) return; - if (isActive && current?.type === "VIDEO") { - videoRef.current.play().catch(() => {}); - } else { - videoRef.current.pause(); - } - }, [isActive, current?.type, mediaIndex]); + const el = containerRef.current; + if (!el) return; + const update = () => setContainerWidth(el.offsetWidth || window.innerWidth); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + window.addEventListener("resize", update); + return () => { + ro.disconnect(); + window.removeEventListener("resize", update); + }; + }, []); - // Reset au changement de slide (différé pour éviter cascading renders) + // Auto-play/pause vidéos selon média actif + useEffect(() => { + videoRefs.current.forEach((video, idx) => { + if (idx === mediaIndex && isActive && carbet.media[idx]?.type === "VIDEO") { + video.play().catch(() => {}); + } else { + video.pause(); + } + }); + }, [isActive, mediaIndex, carbet.media]); + + // Reset au changement de slide carbet (différé pour éviter cascading renders) useEffect(() => { if (isActive) return; - queueMicrotask(() => setMediaIndex(0)); - }, [isActive]); + queueMicrotask(() => goTo(0, false)); + }, [isActive, goTo]); // Navigation clavier ← → useEffect(() => { @@ -65,21 +100,88 @@ export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggl function onTouchStart(e: React.TouchEvent) { const t = e.touches[0]; - touchStart.current = { x: t.clientX, y: t.clientY }; + drag.current = { + startX: t.clientX, + startY: t.clientY, + startTime: Date.now(), + locked: null, + }; + setTransitioning(false); } - function onTouchEnd(e: React.TouchEvent) { - if (!touchStart.current) return; - const t = e.changedTouches[0]; - const dx = t.clientX - touchStart.current.x; - const dy = t.clientY - touchStart.current.y; - touchStart.current = null; - // Seuil horizontal > vertical pour considérer un swipe horizontal - if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.2) { - if (dx < 0) nextMedia(); - else prevMedia(); + + function onTouchMove(e: React.TouchEvent) { + if (!drag.current) return; + const t = e.touches[0]; + const dx = t.clientX - drag.current.startX; + const dy = t.clientY - drag.current.startY; + + // Première détection : verrouille l'axe (horizontal ou vertical) + if (drag.current.locked === null) { + if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return; // trop petit, attend + drag.current.locked = Math.abs(dx) > Math.abs(dy) ? "horizontal" : "vertical"; + } + + if (drag.current.locked !== "horizontal") return; + // Empêche le scroll vertical pendant un swipe horizontal + e.stopPropagation(); + if (e.cancelable) e.preventDefault(); + + // Résistance aux bords : si on swipe gauche sur le 1er ou droite sur le dernier, + // on glisse moins (effet rubber-band) + let effective = dx; + if (total <= 1) { + effective = dx * 0.2; + } else if (mediaIndex === 0 && dx > 0) { + effective = dx * 0.35; + } else if (mediaIndex === total - 1 && dx < 0) { + effective = dx * 0.35; + } + setDragX(effective); + } + + function onTouchEnd() { + if (!drag.current) return; + const wasHorizontal = drag.current.locked === "horizontal"; + const elapsed = Date.now() - drag.current.startTime; + const width = containerWidth || window.innerWidth; + const velocity = Math.abs(dragX) / Math.max(1, elapsed); // px/ms + drag.current = null; + + if (!wasHorizontal) { + setDragX(0); + return; + } + + const distance = Math.abs(dragX); + const isFlick = velocity > VELOCITY_THRESHOLD && distance > 20; + const isSlow = distance > width * SWIPE_THRESHOLD_RATIO; + const shouldChange = (isFlick || isSlow) && total > 1; + + if (shouldChange) { + if (dragX < 0 && mediaIndex < total - 1) { + goTo(mediaIndex + 1); + } else if (dragX > 0 && mediaIndex > 0) { + goTo(mediaIndex - 1); + } else { + // Bord : retour à 0 + setTransitioning(true); + setDragX(0); + } + } else { + setTransitioning(true); + setDragX(0); } } + // Préchargement intelligent : current, current ± 1 + const preloadIndexes = useMemo(() => { + const s = new Set(); + s.add(mediaIndex); + if (mediaIndex > 0) s.add(mediaIndex - 1); + if (mediaIndex < total - 1) s.add(mediaIndex + 1); + return s; + }, [mediaIndex, total]); + const share = useCallback(async () => { const url = `${window.location.origin}/carbets/${carbet.slug}`; const title = carbet.title; @@ -92,57 +194,97 @@ export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggl if (!current) return null; + const offsetPct = -mediaIndex * 100; + return ( -
- {/* Média */} -
- {current.type === "VIDEO" ? ( -