fix: 修复图文动图播放被重置与进度停滞问题

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-04 01:41:47 +08:00
co-authored by Cursor
parent 10f2520279
commit bcf1e6e349
3 changed files with 83 additions and 75 deletions
@@ -6,7 +6,6 @@ import {
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { ImageData, LoopMode, ObjectFit } from "../types.ts";
import { releaseMediaElement } from "../utils";
@@ -54,13 +53,15 @@ export const ImageCarousel = memo(
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 pendingSeekRef = useRef<{
imageId: string;
progress: number;
} | null>(null);
const [readyVideoId, setReadyVideoId] = useState<string | null>(null);
const shouldLoopCurrentVideo =
loopMode === "single" ||
@@ -117,11 +118,13 @@ export const ImageCarousel = memo(
// 组件卸载时释放所有残留的 <video> 硬件解码缓冲
useEffect(() => {
const map = videoRefs.current;
const callbacks = videoRefCallbacks.current;
return () => {
map.forEach((videoEl) => {
releaseMediaElement(videoEl);
});
map.clear();
callbacks.clear();
};
}, []);
@@ -283,26 +286,29 @@ export const ImageCarousel = memo(
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));
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) {
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);
}
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,
);
}
};
videoRefCallbacks.current.set(imageId, callback);
return callback;
},
[onAnimatedDuration],
);
@@ -339,7 +345,6 @@ export const ImageCarousel = memo(
Boolean(img.animated) &&
!failedAnimatedMap[img.id];
const animatedSrc = canShowAnimated ? img.animated : null;
const isVideoVisible = canShowAnimated && readyVideoId === img.id;
return (
<div
@@ -353,39 +358,19 @@ export const ImageCarousel = memo(
}}
onClick={onTogglePlay}
>
{/* 底层静态图:作为静态页本体及动图首帧就绪前的无缝封面 */}
<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 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,
}}
/>
{/* 仅当前激活页挂载单个 <video>,避免 iOS 多解码器并发卡死 */}
{animatedSrc ? (
<video
ref={(el) => handleVideoRef(el, img.id)}
ref={getVideoRefCallback(img.id)}
src={animatedSrc}
poster={img.url}
muted
playsInline
autoPlay={isPlaying}
loop={shouldLoopCurrentVideo}
preload="auto"
controls={false}
disablePictureInPicture
className={`absolute max-w-full max-h-full transition-opacity duration-150 ${
isVideoVisible ? "opacity-100" : "opacity-0"
}`}
className="max-w-full max-h-full"
style={{
width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined,
@@ -413,11 +398,9 @@ export const ImageCarousel = memo(
}
}}
onLoadedData={() => {
setReadyVideoId(img.id);
onMediaReady(img.id);
}}
onCanPlay={(e) => {
setReadyVideoId(img.id);
onMediaReady(img.id);
if (
pendingSeekRef.current &&
@@ -468,7 +451,23 @@ export const ImageCarousel = memo(
onAnimatedFailed(img.id);
}}
/>
) : null}
) : (
<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>
);
})}