refactor: 重构作品播放页性能、媒体同步与内存管理
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,233 +1,482 @@
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ImageData, LoopMode } from "../types.ts";
|
||||
import type { ImageData, LoopMode, ObjectFit } from "../types.ts";
|
||||
import { releaseMediaElement } from "../utils";
|
||||
|
||||
interface ImageCarouselProps {
|
||||
images: ImageData["images"];
|
||||
currentIndex: number;
|
||||
isPlaying: boolean;
|
||||
segmentProgress: 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;
|
||||
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 = 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 {}
|
||||
}
|
||||
export const ImageCarousel = memo(
|
||||
forwardRef<HTMLDivElement, ImageCarouselProps>(
|
||||
(
|
||||
{
|
||||
images,
|
||||
currentIndex,
|
||||
isPlaying,
|
||||
segmentProgressRef,
|
||||
mediaSyncToken,
|
||||
loopMode,
|
||||
hasNextNeighbor,
|
||||
rotation,
|
||||
objectFit,
|
||||
failedAnimatedMap,
|
||||
onTogglePlay,
|
||||
onAnimatedDuration,
|
||||
onMediaReady,
|
||||
onAnimatedFailed,
|
||||
onAnimatedProgress,
|
||||
onAnimatedEnded,
|
||||
},
|
||||
[],
|
||||
);
|
||||
ref,
|
||||
) => {
|
||||
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
|
||||
const activeVideoIdRef = useRef<string | null>(null);
|
||||
const lastMediaSyncTokenRef = useRef(mediaSyncToken);
|
||||
const pendingSeekRef = useRef<{
|
||||
imageId: string;
|
||||
progress: number;
|
||||
} | null>(null);
|
||||
const [readyVideoId, setReadyVideoId] = useState<string | null>(null);
|
||||
|
||||
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 shouldLoopCurrentVideo =
|
||||
loopMode === "single" ||
|
||||
(images.length === 1 &&
|
||||
(loopMode !== "sequential" || !hasNextNeighbor));
|
||||
|
||||
// 虚拟滚动:只渲染当前图片和前后各一张
|
||||
const visibleIndices = useMemo(() => {
|
||||
if (useLightweightMode) return [currentIndex];
|
||||
const seekVideoToProgress = useCallback(
|
||||
(videoEl: HTMLVideoElement, progress: number): boolean => {
|
||||
const duration = videoEl.duration;
|
||||
if (!Number.isFinite(duration) || duration <= 0) return false;
|
||||
|
||||
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]);
|
||||
const clampedProgress = Math.max(0, Math.min(1, progress));
|
||||
const maxTime = Math.max(0, duration - 0.05);
|
||||
const targetTime = Math.min(maxTime, duration * clampedProgress);
|
||||
|
||||
// iOS Safari 对多个同屏 video 很敏感,这里只让当前动图持有 video 元素并跟随全局播放状态。
|
||||
useEffect(() => {
|
||||
const currentImage = images[currentIndex];
|
||||
if (!currentImage?.animated) {
|
||||
activeVideoIdRef.current = null;
|
||||
videoRefs.current.forEach((videoEl) => videoEl.pause());
|
||||
return;
|
||||
}
|
||||
if (Math.abs(videoEl.currentTime - targetTime) > 0.08) {
|
||||
try {
|
||||
videoEl.currentTime = targetTime;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const videoEl = videoRefs.current.get(currentImage.id);
|
||||
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],
|
||||
);
|
||||
|
||||
videoRefs.current.forEach((itemVideoEl, imageId) => {
|
||||
if (imageId !== currentImage.id || !isPlaying) {
|
||||
itemVideoEl.pause();
|
||||
// 虚拟窗口:同时渲染前一张、当前张、后一张静态图以保证切页无白屏,同时仅当前张挂载单个 <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;
|
||||
return () => {
|
||||
map.forEach((videoEl) => {
|
||||
releaseMediaElement(videoEl);
|
||||
});
|
||||
map.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;
|
||||
|
||||
if (videoEl) {
|
||||
if (activeVideoIdRef.current !== currentImage.id) {
|
||||
activeVideoIdRef.current = currentImage.id;
|
||||
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
|
||||
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) {
|
||||
videoEl.play().catch(() => {});
|
||||
tryPlayVideo(videoEl, currentImage.id);
|
||||
} else {
|
||||
videoEl.pause();
|
||||
}
|
||||
}
|
||||
}, [currentIndex, images, isPlaying, loopMode, syncVideoToSegmentProgress]);
|
||||
}, [
|
||||
currentIndex,
|
||||
failedAnimatedMap,
|
||||
images,
|
||||
isPlaying,
|
||||
seekVideoToProgress,
|
||||
segmentProgressRef,
|
||||
shouldLoopCurrentVideo,
|
||||
tryPlayVideo,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentImage = images[currentIndex];
|
||||
if (!currentImage?.animated) return;
|
||||
// 响应用户主动 Seek 或外部强制同步
|
||||
useEffect(() => {
|
||||
if (lastMediaSyncTokenRef.current === mediaSyncToken) return;
|
||||
lastMediaSyncTokenRef.current = mediaSyncToken;
|
||||
|
||||
const videoEl = videoRefs.current.get(currentImage.id);
|
||||
if (!videoEl) return;
|
||||
const currentImage = images[currentIndex];
|
||||
if (!currentImage?.animated || failedAnimatedMap[currentImage.id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
|
||||
if (isPlaying) videoEl.play().catch(() => {});
|
||||
}, [
|
||||
currentIndex,
|
||||
images,
|
||||
isPlaying,
|
||||
loopMode,
|
||||
mediaSyncToken,
|
||||
syncVideoToSegmentProgress,
|
||||
]);
|
||||
const targetProgress = segmentProgressRef.current;
|
||||
const videoEl = videoRefs.current.get(currentImage.id);
|
||||
if (!videoEl) {
|
||||
pendingSeekRef.current = {
|
||||
imageId: currentImage.id,
|
||||
progress: targetProgress,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => {
|
||||
if (el) {
|
||||
videoRefs.current.set(imageId, el);
|
||||
} else {
|
||||
videoRefs.current.delete(imageId);
|
||||
}
|
||||
};
|
||||
const applied = seekVideoToProgress(videoEl, targetProgress);
|
||||
if (applied) {
|
||||
pendingSeekRef.current = null;
|
||||
} else {
|
||||
pendingSeekRef.current = {
|
||||
imageId: currentImage.id,
|
||||
progress: targetProgress,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
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 handleVideoRef = useCallback(
|
||||
(el: HTMLVideoElement | null, imageId: string): void => {
|
||||
if (el) {
|
||||
videoRefs.current.set(imageId, el);
|
||||
if (Number.isFinite(el.duration) && el.duration > 0) {
|
||||
onAnimatedDuration(imageId, Math.round(el.duration * 1000));
|
||||
}
|
||||
if (el.readyState >= 2) {
|
||||
setReadyVideoId(imageId);
|
||||
}
|
||||
} else {
|
||||
const prevEl = videoRefs.current.get(imageId);
|
||||
if (prevEl) {
|
||||
releaseMediaElement(prevEl);
|
||||
videoRefs.current.delete(imageId);
|
||||
}
|
||||
setReadyVideoId((current) =>
|
||||
current === imageId ? null : current,
|
||||
);
|
||||
}
|
||||
},
|
||||
[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;
|
||||
const isVideoVisible = canShowAnimated && readyVideoId === img.id;
|
||||
|
||||
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}
|
||||
>
|
||||
{/* 底层静态图:作为静态页本体及动图首帧就绪前的无缝封面 */}
|
||||
<img
|
||||
ref={(el) => handleImgRef(el, img.id)}
|
||||
src={img.url}
|
||||
alt={`image-${i + 1}`}
|
||||
loading={isCurrent ? "eager" : "lazy"}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onLoad={() => onMediaReady(img.id)}
|
||||
onError={() => onMediaReady(img.id)}
|
||||
className={`max-w-full max-h-full transition-opacity duration-150 ${
|
||||
isVideoVisible ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
style={{
|
||||
width: img.width ? `${img.width}px` : undefined,
|
||||
height: img.height ? `${img.height}px` : undefined,
|
||||
...mediaStyle,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 仅当前激活页挂载单个 <video>,避免 iOS 多解码器并发卡死 */}
|
||||
{animatedSrc ? (
|
||||
<video
|
||||
ref={(el) => handleVideoRef(el, img.id)}
|
||||
src={animatedSrc}
|
||||
muted
|
||||
playsInline
|
||||
loop={shouldLoopCurrentVideo}
|
||||
preload="auto"
|
||||
controls={false}
|
||||
disablePictureInPicture
|
||||
className={`absolute max-w-full max-h-full transition-opacity duration-150 ${
|
||||
isVideoVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
style={{
|
||||
width: img.width ? `${img.width}px` : undefined,
|
||||
height: img.height ? `${img.height}px` : undefined,
|
||||
...mediaStyle,
|
||||
}}
|
||||
onLoadedMetadata={(e) => {
|
||||
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={() => {
|
||||
setReadyVideoId(img.id);
|
||||
onMediaReady(img.id);
|
||||
}}
|
||||
onCanPlay={(e) => {
|
||||
setReadyVideoId(img.id);
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
ImageCarousel.displayName = "ImageCarousel";
|
||||
|
||||
Reference in New Issue
Block a user