feat(reels): swipe horizontal animé avec suivi du doigt + snap
All checks were successful
CI / test (pull_request) Successful in 2m16s
All checks were successful
CI / test (pull_request) Successful in 2m16s
This commit is contained in:
parent
5449ec9047
commit
d5732917e3
1 changed files with 216 additions and 74 deletions
|
|
@ -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<HTMLVideoElement>(null);
|
||||
const [dragX, setDragX] = useState(0);
|
||||
const [transitioning, setTransitioning] = useState(false);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const videoRefs = useRef<Map<number, HTMLVideoElement>>(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<number>();
|
||||
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 (
|
||||
<div
|
||||
className="relative h-full w-full bg-black"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Média */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{current.type === "VIDEO" ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={shouldPreload ? current.url : undefined}
|
||||
data-src={current.url}
|
||||
muted={muted}
|
||||
playsInline
|
||||
loop
|
||||
preload={shouldPreload ? "auto" : "none"}
|
||||
className="h-full w-full object-cover"
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={shouldPreload ? current.url : undefined}
|
||||
srcSet={shouldPreload ? buildSrcSet(current.url) : undefined}
|
||||
sizes="(min-width: 768px) 800px, 100vw"
|
||||
data-src={current.url}
|
||||
alt={`${carbet.title} — média ${mediaIndex + 1}`}
|
||||
loading={shouldPreload ? "eager" : "lazy"}
|
||||
fetchPriority={shouldPreload ? "high" : "auto"}
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="relative h-full w-full overflow-hidden bg-black">
|
||||
{/* Track : tous les médias en ligne, transformX selon index + drag */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 flex"
|
||||
style={{
|
||||
width: `${total * 100}%`,
|
||||
transform: `translateX(calc(${offsetPct / total}% + ${dragX}px))`,
|
||||
transition: transitioning ? "transform 320ms cubic-bezier(0.22, 0.61, 0.36, 1)" : "none",
|
||||
touchAction: "pan-y",
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTransitionEnd={() => setTransitioning(false)}
|
||||
>
|
||||
{carbet.media.map((m, idx) => {
|
||||
const visible = preloadIndexes.has(idx) || shouldPreload;
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="relative flex h-full shrink-0 items-center justify-center"
|
||||
style={{ width: `${100 / total}%` }}
|
||||
aria-hidden={idx !== mediaIndex}
|
||||
>
|
||||
{m.type === "VIDEO" ? (
|
||||
<video
|
||||
ref={(el) => {
|
||||
if (el) videoRefs.current.set(idx, el);
|
||||
else videoRefs.current.delete(idx);
|
||||
}}
|
||||
src={visible ? m.url : undefined}
|
||||
muted={muted}
|
||||
playsInline
|
||||
loop
|
||||
preload={visible ? "auto" : "none"}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={visible ? m.url : undefined}
|
||||
srcSet={visible ? buildSrcSet(m.url) : undefined}
|
||||
sizes="(min-width: 768px) 800px, 100vw"
|
||||
alt={`${carbet.title} — média ${idx + 1}`}
|
||||
loading={idx === mediaIndex ? "eager" : "lazy"}
|
||||
fetchPriority={idx === mediaIndex ? "high" : "auto"}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className="h-full w-full select-none object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Voile dégradé en bas pour lisibilité */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
|
||||
|
||||
{/* Indicateurs progression médias (sticks en haut) */}
|
||||
{carbet.media.length > 1 ? (
|
||||
{total > 1 ? (
|
||||
<div className="pointer-events-none absolute left-3 right-3 top-12 flex gap-1">
|
||||
{carbet.media.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={
|
||||
"h-0.5 flex-1 rounded-full " +
|
||||
(i === mediaIndex ? "bg-white" : i < mediaIndex ? "bg-white/60" : "bg-white/30")
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{carbet.media.map((_, i) => {
|
||||
const isActiveStick = i === mediaIndex;
|
||||
const wasSeen = i < mediaIndex;
|
||||
// Progression visuelle pendant le drag (preview du swipe)
|
||||
const progress = isActiveStick && Math.abs(dragX) > 0 && containerWidth > 0
|
||||
? Math.min(1, Math.abs(dragX) / containerWidth)
|
||||
: 0;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={
|
||||
"relative h-0.5 flex-1 overflow-hidden rounded-full " +
|
||||
(isActiveStick ? "bg-white/30" : wasSeen ? "bg-white/60" : "bg-white/30")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"absolute inset-y-0 left-0 bg-white " +
|
||||
(isActiveStick ? "w-full" : wasSeen ? "w-full" : "w-0")
|
||||
}
|
||||
style={progress > 0 ? { width: `${progress * 100}%` } : undefined}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue