490 lines
16 KiB
TypeScript
490 lines
16 KiB
TypeScript
import {
|
|
forwardRef,
|
|
memo,
|
|
type RefObject,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
} from "react";
|
|
import type { ImageData, LoopMode, ObjectFit } from "../types.ts";
|
|
import { releaseMediaElement } from "../utils";
|
|
|
|
interface ImageCarouselProps {
|
|
images: ImageData["images"];
|
|
currentIndex: number;
|
|
isPlaying: boolean;
|
|
playbackRate?: number;
|
|
segmentProgressRef: RefObject<number>;
|
|
mediaSyncToken: number;
|
|
loopMode: LoopMode;
|
|
hasNextNeighbor: boolean;
|
|
rotation: number;
|
|
objectFit: ObjectFit;
|
|
failedAnimatedMap: Record<string, boolean>;
|
|
onTogglePlay: () => void;
|
|
onAnimatedDuration: (imageId: string, durationMs: number) => void;
|
|
onMediaReady: (imageId: string) => void;
|
|
onAnimatedFailed: (imageId: string) => void;
|
|
onAnimatedProgress: (imageId: string, progress: number) => void;
|
|
onAnimatedEnded: (imageId: string) => void;
|
|
}
|
|
|
|
export const ImageCarousel = memo(
|
|
forwardRef<HTMLDivElement, ImageCarouselProps>(
|
|
(
|
|
{
|
|
images,
|
|
currentIndex,
|
|
isPlaying,
|
|
playbackRate = 1,
|
|
segmentProgressRef,
|
|
mediaSyncToken,
|
|
loopMode,
|
|
hasNextNeighbor,
|
|
rotation,
|
|
objectFit,
|
|
failedAnimatedMap,
|
|
onTogglePlay,
|
|
onAnimatedDuration,
|
|
onMediaReady,
|
|
onAnimatedFailed,
|
|
onAnimatedProgress,
|
|
onAnimatedEnded,
|
|
},
|
|
ref,
|
|
) => {
|
|
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
|
|
const videoRefCallbacks = useRef<
|
|
Map<string, (el: HTMLVideoElement | null) => void>
|
|
>(new Map());
|
|
const activeVideoIdRef = useRef<string | null>(null);
|
|
const lastMediaSyncTokenRef = useRef(mediaSyncToken);
|
|
const playbackRateRef = useRef(playbackRate);
|
|
playbackRateRef.current = playbackRate;
|
|
const pendingSeekRef = useRef<{
|
|
imageId: string;
|
|
progress: number;
|
|
} | null>(null);
|
|
|
|
const shouldLoopCurrentVideo =
|
|
loopMode === "single" ||
|
|
(images.length === 1 &&
|
|
(loopMode !== "sequential" || !hasNextNeighbor));
|
|
|
|
const seekVideoToProgress = useCallback(
|
|
(videoEl: HTMLVideoElement, progress: number): boolean => {
|
|
const duration = videoEl.duration;
|
|
if (!Number.isFinite(duration) || duration <= 0) return false;
|
|
|
|
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 (Math.abs(videoEl.currentTime - targetTime) > 0.08) {
|
|
try {
|
|
videoEl.currentTime = targetTime;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const tryPlayVideo = useCallback(
|
|
(videoEl: HTMLVideoElement, imageId: string): void => {
|
|
videoEl.play().catch((err: unknown) => {
|
|
const errorName = err instanceof DOMException ? err.name : "";
|
|
if (
|
|
errorName === "NotAllowedError" ||
|
|
errorName === "NotSupportedError"
|
|
) {
|
|
onAnimatedFailed(imageId);
|
|
}
|
|
});
|
|
},
|
|
[onAnimatedFailed],
|
|
);
|
|
|
|
// 虚拟窗口:同时渲染前一张、当前张、后一张静态图以保证切页无白屏,同时仅当前张挂载单个 <video>
|
|
const visibleIndices = useMemo((): number[] => {
|
|
const indices: number[] = [];
|
|
if (currentIndex > 0) indices.push(currentIndex - 1);
|
|
if (currentIndex >= 0 && currentIndex < images.length) {
|
|
indices.push(currentIndex);
|
|
}
|
|
if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
|
|
return indices;
|
|
}, [currentIndex, images.length]);
|
|
|
|
// 组件卸载时释放所有残留的 <video> 硬件解码缓冲
|
|
useEffect(() => {
|
|
const map = videoRefs.current;
|
|
const callbacks = videoRefCallbacks.current;
|
|
return () => {
|
|
map.forEach((videoEl) => {
|
|
releaseMediaElement(videoEl);
|
|
});
|
|
map.clear();
|
|
callbacks.clear();
|
|
};
|
|
}, []);
|
|
|
|
// 管理当前动图的播放、暂停与切换
|
|
useEffect(() => {
|
|
const currentImage = images[currentIndex];
|
|
const isAnimated =
|
|
Boolean(currentImage?.animated) &&
|
|
!failedAnimatedMap[currentImage?.id ?? ""];
|
|
|
|
if (!currentImage || !isAnimated) {
|
|
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) return;
|
|
|
|
videoEl.loop = shouldLoopCurrentVideo;
|
|
videoEl.playbackRate = playbackRate;
|
|
|
|
if (activeVideoIdRef.current !== currentImage.id) {
|
|
activeVideoIdRef.current = currentImage.id;
|
|
const initialProgress = segmentProgressRef.current;
|
|
if (initialProgress > 0.01) {
|
|
const applied = seekVideoToProgress(videoEl, initialProgress);
|
|
if (!applied) {
|
|
pendingSeekRef.current = {
|
|
imageId: currentImage.id,
|
|
progress: initialProgress,
|
|
};
|
|
}
|
|
} else {
|
|
pendingSeekRef.current = null;
|
|
if (videoEl.currentTime > 0.05) {
|
|
try {
|
|
videoEl.currentTime = 0;
|
|
} catch {}
|
|
}
|
|
}
|
|
} else if (
|
|
isPlaying &&
|
|
(videoEl.ended ||
|
|
(Number.isFinite(videoEl.duration) &&
|
|
videoEl.duration > 0 &&
|
|
videoEl.currentTime >= videoEl.duration - 0.05))
|
|
) {
|
|
try {
|
|
videoEl.currentTime = 0;
|
|
} catch {}
|
|
}
|
|
|
|
if (isPlaying) {
|
|
tryPlayVideo(videoEl, currentImage.id);
|
|
} else {
|
|
videoEl.pause();
|
|
}
|
|
}, [
|
|
currentIndex,
|
|
failedAnimatedMap,
|
|
images,
|
|
isPlaying,
|
|
playbackRate,
|
|
seekVideoToProgress,
|
|
segmentProgressRef,
|
|
shouldLoopCurrentVideo,
|
|
tryPlayVideo,
|
|
]);
|
|
|
|
// 响应用户主动 Seek 或外部强制同步
|
|
useEffect(() => {
|
|
if (lastMediaSyncTokenRef.current === mediaSyncToken) return;
|
|
lastMediaSyncTokenRef.current = mediaSyncToken;
|
|
|
|
const currentImage = images[currentIndex];
|
|
if (!currentImage?.animated || failedAnimatedMap[currentImage.id]) {
|
|
return;
|
|
}
|
|
|
|
const targetProgress = segmentProgressRef.current;
|
|
const videoEl = videoRefs.current.get(currentImage.id);
|
|
if (!videoEl) {
|
|
pendingSeekRef.current = {
|
|
imageId: currentImage.id,
|
|
progress: targetProgress,
|
|
};
|
|
return;
|
|
}
|
|
|
|
const applied = seekVideoToProgress(videoEl, targetProgress);
|
|
if (applied) {
|
|
pendingSeekRef.current = null;
|
|
} else {
|
|
pendingSeekRef.current = {
|
|
imageId: currentImage.id,
|
|
progress: targetProgress,
|
|
};
|
|
}
|
|
|
|
if (isPlaying) {
|
|
tryPlayVideo(videoEl, currentImage.id);
|
|
}
|
|
}, [
|
|
currentIndex,
|
|
failedAnimatedMap,
|
|
images,
|
|
isPlaying,
|
|
mediaSyncToken,
|
|
seekVideoToProgress,
|
|
segmentProgressRef,
|
|
tryPlayVideo,
|
|
]);
|
|
|
|
// 使用 rAF 跟踪当前动图真实播放进度,只有动图实际走帧时进度条才前进
|
|
useEffect(() => {
|
|
if (!isPlaying) return;
|
|
const currentImage = images[currentIndex];
|
|
if (!currentImage?.animated || failedAnimatedMap[currentImage.id]) {
|
|
return;
|
|
}
|
|
|
|
let rafId: number | null = null;
|
|
const imageId = currentImage.id;
|
|
|
|
const updateLoop = (): void => {
|
|
const videoEl = videoRefs.current.get(imageId);
|
|
if (videoEl) {
|
|
const duration = videoEl.duration;
|
|
if (Number.isFinite(duration) && duration > 0 && !videoEl.paused) {
|
|
const progress = Math.max(
|
|
0,
|
|
Math.min(1, videoEl.currentTime / duration),
|
|
);
|
|
onAnimatedProgress(imageId, progress);
|
|
}
|
|
}
|
|
rafId = window.requestAnimationFrame(updateLoop);
|
|
};
|
|
|
|
rafId = window.requestAnimationFrame(updateLoop);
|
|
return () => {
|
|
if (rafId !== null) {
|
|
window.cancelAnimationFrame(rafId);
|
|
}
|
|
};
|
|
}, [
|
|
currentIndex,
|
|
failedAnimatedMap,
|
|
images,
|
|
isPlaying,
|
|
onAnimatedProgress,
|
|
]);
|
|
|
|
const getVideoRefCallback = useCallback(
|
|
(imageId: string): ((el: HTMLVideoElement | null) => void) => {
|
|
const existing = videoRefCallbacks.current.get(imageId);
|
|
if (existing) return existing;
|
|
|
|
const callback = (el: HTMLVideoElement | null): void => {
|
|
if (el) {
|
|
el.playbackRate = playbackRateRef.current;
|
|
videoRefs.current.set(imageId, el);
|
|
if (Number.isFinite(el.duration) && el.duration > 0) {
|
|
onAnimatedDuration(imageId, Math.round(el.duration * 1000));
|
|
}
|
|
} else {
|
|
const prevEl = videoRefs.current.get(imageId);
|
|
if (prevEl) {
|
|
releaseMediaElement(prevEl);
|
|
videoRefs.current.delete(imageId);
|
|
}
|
|
videoRefCallbacks.current.delete(imageId);
|
|
}
|
|
};
|
|
|
|
videoRefCallbacks.current.set(imageId, callback);
|
|
return callback;
|
|
},
|
|
[onAnimatedDuration],
|
|
);
|
|
|
|
const handleImgRef = useCallback(
|
|
(el: HTMLImageElement | null, imageId: string): void => {
|
|
if (el?.complete) {
|
|
onMediaReady(imageId);
|
|
}
|
|
},
|
|
[onMediaReady],
|
|
);
|
|
|
|
const mediaStyle = useMemo(
|
|
() => ({
|
|
transform:
|
|
rotation % 180 === 0
|
|
? `rotate(${rotation}deg)`
|
|
: `rotate(${rotation}deg) scale(0.85)`,
|
|
objectFit,
|
|
}),
|
|
[objectFit, rotation],
|
|
);
|
|
|
|
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];
|
|
if (!img) return null;
|
|
const isCurrent = i === currentIndex;
|
|
const canShowAnimated =
|
|
isCurrent &&
|
|
Boolean(img.animated) &&
|
|
!failedAnimatedMap[img.id];
|
|
const animatedSrc = canShowAnimated ? 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: `translateX(${(i - currentIndex) * 100}%)`,
|
|
transition: "transform 240ms ease-out",
|
|
zIndex: isCurrent ? 1 : 0,
|
|
}}
|
|
onClick={onTogglePlay}
|
|
>
|
|
{animatedSrc ? (
|
|
<video
|
|
ref={getVideoRefCallback(img.id)}
|
|
src={animatedSrc}
|
|
poster={img.url}
|
|
muted
|
|
playsInline
|
|
autoPlay={isPlaying}
|
|
loop={shouldLoopCurrentVideo}
|
|
preload="auto"
|
|
controls={false}
|
|
disablePictureInPicture
|
|
className="max-w-full max-h-full"
|
|
style={{
|
|
width: img.width ? `${img.width}px` : undefined,
|
|
height: img.height ? `${img.height}px` : undefined,
|
|
...mediaStyle,
|
|
}}
|
|
onLoadedMetadata={(e) => {
|
|
e.currentTarget.playbackRate = playbackRateRef.current;
|
|
const duration = e.currentTarget.duration;
|
|
if (Number.isFinite(duration) && duration > 0) {
|
|
onAnimatedDuration(
|
|
img.id,
|
|
Math.round(duration * 1000),
|
|
);
|
|
if (
|
|
pendingSeekRef.current &&
|
|
pendingSeekRef.current.imageId === img.id
|
|
) {
|
|
const applied = seekVideoToProgress(
|
|
e.currentTarget,
|
|
pendingSeekRef.current.progress,
|
|
);
|
|
if (applied) {
|
|
pendingSeekRef.current = null;
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
onLoadedData={() => {
|
|
onMediaReady(img.id);
|
|
}}
|
|
onCanPlay={(e) => {
|
|
onMediaReady(img.id);
|
|
if (
|
|
pendingSeekRef.current &&
|
|
pendingSeekRef.current.imageId === img.id
|
|
) {
|
|
const applied = seekVideoToProgress(
|
|
e.currentTarget,
|
|
pendingSeekRef.current.progress,
|
|
);
|
|
if (applied) {
|
|
pendingSeekRef.current = null;
|
|
}
|
|
}
|
|
if (isCurrent && isPlaying && e.currentTarget.paused) {
|
|
tryPlayVideo(e.currentTarget, img.id);
|
|
}
|
|
}}
|
|
onTimeUpdate={(e) => {
|
|
const duration = e.currentTarget.duration;
|
|
if (Number.isFinite(duration) && duration > 0) {
|
|
onAnimatedProgress(
|
|
img.id,
|
|
Math.max(
|
|
0,
|
|
Math.min(
|
|
1,
|
|
e.currentTarget.currentTime / duration,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}}
|
|
onEnded={(e) => {
|
|
if (shouldLoopCurrentVideo) {
|
|
try {
|
|
e.currentTarget.currentTime = 0;
|
|
} catch {}
|
|
if (isPlaying) {
|
|
tryPlayVideo(e.currentTarget, img.id);
|
|
}
|
|
onAnimatedEnded(img.id);
|
|
return;
|
|
}
|
|
e.currentTarget.pause();
|
|
onAnimatedEnded(img.id);
|
|
}}
|
|
onError={() => {
|
|
onAnimatedFailed(img.id);
|
|
}}
|
|
/>
|
|
) : (
|
|
<img
|
|
ref={(el) => handleImgRef(el, img.id)}
|
|
src={img.url}
|
|
alt={`image-${i + 1}`}
|
|
loading="eager"
|
|
decoding="async"
|
|
onLoad={() => onMediaReady(img.id)}
|
|
onError={() => onMediaReady(img.id)}
|
|
className="max-w-full max-h-full"
|
|
style={{
|
|
width: img.width ? `${img.width}px` : undefined,
|
|
height: img.height ? `${img.height}px` : undefined,
|
|
...mediaStyle,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
ImageCarousel.displayName = "ImageCarousel";
|