234 lines
7.7 KiB
TypeScript
234 lines
7.7 KiB
TypeScript
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import type { ImageData, LoopMode } from "../types.ts";
|
|
|
|
interface ImageCarouselProps {
|
|
images: ImageData["images"];
|
|
currentIndex: number;
|
|
isPlaying: boolean;
|
|
segmentProgress: number;
|
|
mediaSyncToken: number;
|
|
loopMode: LoopMode;
|
|
onTogglePlay: () => void;
|
|
onAnimatedDuration?: (imageId: string, durationMs: number) => void;
|
|
}
|
|
|
|
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
|
|
(
|
|
{
|
|
images,
|
|
currentIndex,
|
|
isPlaying,
|
|
segmentProgress,
|
|
mediaSyncToken,
|
|
loopMode,
|
|
onTogglePlay,
|
|
onAnimatedDuration,
|
|
},
|
|
ref,
|
|
) => {
|
|
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
|
|
const activeVideoIdRef = useRef<string | null>(null);
|
|
const segmentProgressRef = useRef(segmentProgress);
|
|
const [useLightweightMode, setUseLightweightMode] = useState(false);
|
|
|
|
useEffect(() => {
|
|
segmentProgressRef.current = segmentProgress;
|
|
}, [segmentProgress]);
|
|
|
|
const syncVideoToSegmentProgress = useCallback(
|
|
(videoEl: HTMLVideoElement, progress: number, force = false) => {
|
|
const duration = videoEl.duration;
|
|
if (!Number.isFinite(duration) || duration <= 0) return;
|
|
|
|
const clampedProgress = Math.max(0, Math.min(1, progress));
|
|
const maxTime = Math.max(0, duration - 0.05);
|
|
const targetTime = Math.min(maxTime, duration * clampedProgress);
|
|
|
|
if (force || Math.abs(videoEl.currentTime - targetTime) > 0.12) {
|
|
try {
|
|
videoEl.currentTime = targetTime;
|
|
} catch {}
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const userAgent = navigator.userAgent;
|
|
const isIOS =
|
|
/iPad|iPhone|iPod/.test(userAgent) ||
|
|
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
|
const isSafari =
|
|
/Safari/.test(userAgent) && !/CriOS|FxiOS|EdgiOS/.test(userAgent);
|
|
setUseLightweightMode(isIOS && isSafari);
|
|
}, []);
|
|
|
|
// 虚拟滚动:只渲染当前图片和前后各一张
|
|
const visibleIndices = useMemo(() => {
|
|
if (useLightweightMode) return [currentIndex];
|
|
|
|
const indices: number[] = [];
|
|
if (currentIndex > 0) indices.push(currentIndex - 1);
|
|
indices.push(currentIndex);
|
|
if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
|
|
return indices;
|
|
}, [currentIndex, images.length, useLightweightMode]);
|
|
|
|
// iOS Safari 对多个同屏 video 很敏感,这里只让当前动图持有 video 元素并跟随全局播放状态。
|
|
useEffect(() => {
|
|
const currentImage = images[currentIndex];
|
|
if (!currentImage?.animated) {
|
|
activeVideoIdRef.current = null;
|
|
videoRefs.current.forEach((videoEl) => videoEl.pause());
|
|
return;
|
|
}
|
|
|
|
const videoEl = videoRefs.current.get(currentImage.id);
|
|
|
|
videoRefs.current.forEach((itemVideoEl, imageId) => {
|
|
if (imageId !== currentImage.id || !isPlaying) {
|
|
itemVideoEl.pause();
|
|
}
|
|
});
|
|
|
|
if (videoEl) {
|
|
if (activeVideoIdRef.current !== currentImage.id) {
|
|
activeVideoIdRef.current = currentImage.id;
|
|
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
|
|
}
|
|
|
|
if (isPlaying) {
|
|
videoEl.play().catch(() => {});
|
|
} else {
|
|
videoEl.pause();
|
|
}
|
|
}
|
|
}, [currentIndex, images, isPlaying, loopMode, syncVideoToSegmentProgress]);
|
|
|
|
useEffect(() => {
|
|
const currentImage = images[currentIndex];
|
|
if (!currentImage?.animated) return;
|
|
|
|
const videoEl = videoRefs.current.get(currentImage.id);
|
|
if (!videoEl) return;
|
|
|
|
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
|
|
if (isPlaying) videoEl.play().catch(() => {});
|
|
}, [
|
|
currentIndex,
|
|
images,
|
|
isPlaying,
|
|
loopMode,
|
|
mediaSyncToken,
|
|
syncVideoToSegmentProgress,
|
|
]);
|
|
|
|
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => {
|
|
if (el) {
|
|
videoRefs.current.set(imageId, el);
|
|
} else {
|
|
videoRefs.current.delete(imageId);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={ref} className="relative h-full w-full overflow-hidden">
|
|
<div className="relative h-full w-full">
|
|
{visibleIndices.map((i) => {
|
|
const img = images[i];
|
|
const isCurrent = i === currentIndex;
|
|
const animatedSrc = isCurrent ? img.animated : null;
|
|
return (
|
|
<div
|
|
key={img.id}
|
|
data-active={isCurrent ? "true" : undefined}
|
|
className="absolute inset-0 h-full w-full flex items-center justify-center bg-black/70 cursor-pointer"
|
|
style={{
|
|
transform: useLightweightMode
|
|
? "translateX(0)"
|
|
: `translateX(${(i - currentIndex) * 100}%)`,
|
|
transition: useLightweightMode
|
|
? "none"
|
|
: "transform 240ms ease-out",
|
|
zIndex: isCurrent ? 1 : 0,
|
|
}}
|
|
onClick={onTogglePlay}
|
|
>
|
|
{animatedSrc ? (
|
|
<video
|
|
ref={(el) => handleVideoRef(el, img.id)}
|
|
src={animatedSrc}
|
|
poster={img.url}
|
|
muted
|
|
playsInline
|
|
loop={loopMode === "single"}
|
|
preload={useLightweightMode ? "none" : "metadata"}
|
|
controls={false}
|
|
disablePictureInPicture
|
|
className="max-w-full max-h-full object-contain"
|
|
style={{
|
|
width: img.width ? `${img.width}px` : undefined,
|
|
height: img.height ? `${img.height}px` : undefined,
|
|
}}
|
|
onLoadedMetadata={(e) => {
|
|
const duration = e.currentTarget.duration;
|
|
if (Number.isFinite(duration) && duration > 0) {
|
|
onAnimatedDuration?.(
|
|
img.id,
|
|
Math.round(duration * 1000),
|
|
);
|
|
if (isCurrent) {
|
|
syncVideoToSegmentProgress(
|
|
e.currentTarget,
|
|
segmentProgressRef.current,
|
|
true,
|
|
);
|
|
}
|
|
}
|
|
}}
|
|
onCanPlay={(e) => {
|
|
if (isCurrent) {
|
|
syncVideoToSegmentProgress(
|
|
e.currentTarget,
|
|
segmentProgressRef.current,
|
|
);
|
|
}
|
|
if (isCurrent && isPlaying)
|
|
e.currentTarget.play().catch(() => {});
|
|
}}
|
|
onEnded={(e) => {
|
|
// 播放结束后停留在最后一帧
|
|
if (loopMode !== "single") e.currentTarget.pause();
|
|
}}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={img.url}
|
|
alt={`image-${i + 1}`}
|
|
loading={isCurrent ? "eager" : "lazy"}
|
|
decoding="async"
|
|
className="max-w-full max-h-full object-contain"
|
|
style={{
|
|
width: img.width ? `${img.width}px` : undefined,
|
|
height: img.height ? `${img.height}px` : undefined,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
ImageCarousel.displayName = "ImageCarousel";
|