fix: 修复图文动图播放被重置与进度停滞问题
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -111,7 +112,10 @@ export default function AwemeDetailClient({
|
||||
}, []);
|
||||
|
||||
// 图文轮播状态
|
||||
const images = isVideo ? [] : (data as ImageData).images;
|
||||
const images = useMemo(
|
||||
() => (isVideo ? [] : (data as ImageData).images),
|
||||
[data, isVideo],
|
||||
);
|
||||
const imageCount = images.length;
|
||||
const imageCarouselState = useImageCarousel({
|
||||
images,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -84,12 +84,15 @@ export function useImageCarousel({
|
||||
>({});
|
||||
const [mediaSyncToken, setMediaSyncToken] = useState(0);
|
||||
|
||||
const imagesRef = useRef(images);
|
||||
imagesRef.current = images;
|
||||
const segStartRef = useRef<number | null>(null);
|
||||
const idxRef = useRef<number>(0);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const lastUiUpdateRef = useRef(0);
|
||||
const segProgressRef = useRef(0);
|
||||
const totalProgressRef = useRef(0);
|
||||
const prevImageKeyRef = useRef<string | null>(null);
|
||||
|
||||
const fallbackDuration = normalizeDuration(segmentMs, 5000);
|
||||
const segmentDurations = useMemo(
|
||||
@@ -178,7 +181,8 @@ export function useImageCarousel({
|
||||
|
||||
const advanceSegment = useCallback(
|
||||
(fromIndex: number): void => {
|
||||
if (!images.length || idxRef.current !== fromIndex) return;
|
||||
const currentImages = imagesRef.current;
|
||||
if (!currentImages.length || idxRef.current !== fromIndex) return;
|
||||
|
||||
const now = performance.now();
|
||||
|
||||
@@ -189,7 +193,7 @@ export function useImageCarousel({
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromIndex >= images.length - 1) {
|
||||
if (fromIndex >= currentImages.length - 1) {
|
||||
if (loopMode === "sequential" && neighbors?.next) {
|
||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||
return;
|
||||
@@ -199,7 +203,7 @@ export function useImageCarousel({
|
||||
segStartRef.current = now;
|
||||
lastUiUpdateRef.current = now;
|
||||
syncProgress(0, 0, true);
|
||||
if (images.length === 1) {
|
||||
if (currentImages.length === 1) {
|
||||
setMediaSyncToken((token) => token + 1);
|
||||
}
|
||||
return;
|
||||
@@ -212,12 +216,12 @@ export function useImageCarousel({
|
||||
lastUiUpdateRef.current = now;
|
||||
syncProgress(nextIndex, 0, true);
|
||||
},
|
||||
[images.length, loopMode, neighbors?.next, router, syncProgress],
|
||||
[loopMode, neighbors?.next, router, syncProgress],
|
||||
);
|
||||
|
||||
const handleAnimatedProgress = useCallback(
|
||||
(imageId: string, progress: number): void => {
|
||||
const currentImage = images[idxRef.current];
|
||||
const currentImage = imagesRef.current[idxRef.current];
|
||||
if (!currentImage || currentImage.id !== imageId) return;
|
||||
|
||||
const now = performance.now();
|
||||
@@ -233,23 +237,24 @@ export function useImageCarousel({
|
||||
syncProgress(idxRef.current, clamped, wrapped);
|
||||
}
|
||||
},
|
||||
[images, syncProgress],
|
||||
[syncProgress],
|
||||
);
|
||||
|
||||
const handleAnimatedEnded = useCallback(
|
||||
(imageId: string): void => {
|
||||
const currentImage = images[idxRef.current];
|
||||
const currentImage = imagesRef.current[idxRef.current];
|
||||
if (!currentImage || currentImage.id !== imageId) return;
|
||||
advanceSegment(idxRef.current);
|
||||
},
|
||||
[advanceSegment, images],
|
||||
[advanceSegment],
|
||||
);
|
||||
|
||||
const goToIndex = useCallback(
|
||||
(index: number, segmentProgress = 0): void => {
|
||||
if (!images.length) return;
|
||||
const count = imagesRef.current.length;
|
||||
if (!count) return;
|
||||
|
||||
const nextIndex = Math.max(0, Math.min(images.length - 1, index));
|
||||
const nextIndex = Math.max(0, Math.min(count - 1, index));
|
||||
const nextSegmentProgress = Math.max(0, Math.min(1, segmentProgress));
|
||||
const now = performance.now();
|
||||
const segmentDuration = segmentDurations[nextIndex] ?? fallbackDuration;
|
||||
@@ -261,12 +266,12 @@ export function useImageCarousel({
|
||||
syncProgress(nextIndex, nextSegmentProgress, true);
|
||||
setMediaSyncToken((token) => token + 1);
|
||||
},
|
||||
[fallbackDuration, images.length, segmentDurations, syncProgress],
|
||||
[fallbackDuration, segmentDurations, syncProgress],
|
||||
);
|
||||
|
||||
const seekTo = useCallback(
|
||||
(ratio: number): void => {
|
||||
if (!images.length || totalDurationMs <= 0) return;
|
||||
if (!imagesRef.current.length || totalDurationMs <= 0) return;
|
||||
|
||||
const clampedRatio = Math.max(0, Math.min(1, ratio));
|
||||
const targetMs = Math.min(
|
||||
@@ -298,22 +303,22 @@ export function useImageCarousel({
|
||||
targetDuration > 0 ? segmentElapsedMs / targetDuration : 0;
|
||||
goToIndex(targetIndex, nextSegmentProgress);
|
||||
},
|
||||
[
|
||||
fallbackDuration,
|
||||
goToIndex,
|
||||
images.length,
|
||||
segmentDurations,
|
||||
totalDurationMs,
|
||||
],
|
||||
[fallbackDuration, goToIndex, segmentDurations, totalDurationMs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
idxRef.current = idx;
|
||||
}, [idx]);
|
||||
|
||||
// 切换作品或图片集合变化时重置所有轮播状态
|
||||
// 仅当真正切换到另一个作品(imageKey 变化)时重置状态,避免首次挂载覆盖子组件已上报的 ready 状态
|
||||
useEffect(() => {
|
||||
if (!imageKey) return;
|
||||
if (prevImageKeyRef.current === null) {
|
||||
prevImageKeyRef.current = imageKey;
|
||||
return;
|
||||
}
|
||||
if (prevImageKeyRef.current === imageKey) return;
|
||||
prevImageKeyRef.current = imageKey;
|
||||
|
||||
idxRef.current = 0;
|
||||
setIdx(0);
|
||||
segStartRef.current = null;
|
||||
@@ -356,7 +361,7 @@ export function useImageCarousel({
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!images?.length || !segmentDurations.length || totalDurationMs <= 0) {
|
||||
if (!images.length || !segmentDurations.length || totalDurationMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -373,10 +378,10 @@ export function useImageCarousel({
|
||||
const now = performance.now();
|
||||
const currentSegmentDuration = segmentDurations[idx] ?? fallbackDuration;
|
||||
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
|
||||
lastUiUpdateRef.current = now;
|
||||
|
||||
const tick = (): void => {
|
||||
if (!images?.length) return;
|
||||
const currentImages = imagesRef.current;
|
||||
if (!currentImages.length) return;
|
||||
|
||||
const ts = performance.now();
|
||||
const start = segStartRef.current ?? ts;
|
||||
@@ -407,7 +412,7 @@ export function useImageCarousel({
|
||||
advanceSegment,
|
||||
fallbackDuration,
|
||||
idx,
|
||||
images,
|
||||
images.length,
|
||||
isCurrentAnimated,
|
||||
isCurrentStaticReady,
|
||||
isPlaying,
|
||||
|
||||
Reference in New Issue
Block a user