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
+5 -1
View File
@@ -6,6 +6,7 @@ import {
type ReactElement, type ReactElement,
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
} from "react"; } 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 imageCount = images.length;
const imageCarouselState = useImageCarousel({ const imageCarouselState = useImageCarousel({
images, images,
@@ -6,7 +6,6 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState,
} from "react"; } from "react";
import type { ImageData, LoopMode, ObjectFit } from "../types.ts"; import type { ImageData, LoopMode, ObjectFit } from "../types.ts";
import { releaseMediaElement } from "../utils"; import { releaseMediaElement } from "../utils";
@@ -54,13 +53,15 @@ export const ImageCarousel = memo(
ref, ref,
) => { ) => {
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map()); 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 activeVideoIdRef = useRef<string | null>(null);
const lastMediaSyncTokenRef = useRef(mediaSyncToken); const lastMediaSyncTokenRef = useRef(mediaSyncToken);
const pendingSeekRef = useRef<{ const pendingSeekRef = useRef<{
imageId: string; imageId: string;
progress: number; progress: number;
} | null>(null); } | null>(null);
const [readyVideoId, setReadyVideoId] = useState<string | null>(null);
const shouldLoopCurrentVideo = const shouldLoopCurrentVideo =
loopMode === "single" || loopMode === "single" ||
@@ -117,11 +118,13 @@ export const ImageCarousel = memo(
// 组件卸载时释放所有残留的 <video> 硬件解码缓冲 // 组件卸载时释放所有残留的 <video> 硬件解码缓冲
useEffect(() => { useEffect(() => {
const map = videoRefs.current; const map = videoRefs.current;
const callbacks = videoRefCallbacks.current;
return () => { return () => {
map.forEach((videoEl) => { map.forEach((videoEl) => {
releaseMediaElement(videoEl); releaseMediaElement(videoEl);
}); });
map.clear(); map.clear();
callbacks.clear();
}; };
}, []); }, []);
@@ -283,26 +286,29 @@ export const ImageCarousel = memo(
onAnimatedProgress, onAnimatedProgress,
]); ]);
const handleVideoRef = useCallback( const getVideoRefCallback = useCallback(
(el: HTMLVideoElement | null, imageId: string): void => { (imageId: string): ((el: HTMLVideoElement | null) => void) => {
if (el) { const existing = videoRefCallbacks.current.get(imageId);
videoRefs.current.set(imageId, el); if (existing) return existing;
if (Number.isFinite(el.duration) && el.duration > 0) {
onAnimatedDuration(imageId, Math.round(el.duration * 1000)); 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);
} videoRefCallbacks.current.set(imageId, callback);
} else { return callback;
const prevEl = videoRefs.current.get(imageId);
if (prevEl) {
releaseMediaElement(prevEl);
videoRefs.current.delete(imageId);
}
setReadyVideoId((current) =>
current === imageId ? null : current,
);
}
}, },
[onAnimatedDuration], [onAnimatedDuration],
); );
@@ -339,7 +345,6 @@ export const ImageCarousel = memo(
Boolean(img.animated) && Boolean(img.animated) &&
!failedAnimatedMap[img.id]; !failedAnimatedMap[img.id];
const animatedSrc = canShowAnimated ? img.animated : null; const animatedSrc = canShowAnimated ? img.animated : null;
const isVideoVisible = canShowAnimated && readyVideoId === img.id;
return ( return (
<div <div
@@ -353,39 +358,19 @@ export const ImageCarousel = memo(
}} }}
onClick={onTogglePlay} 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 ? ( {animatedSrc ? (
<video <video
ref={(el) => handleVideoRef(el, img.id)} ref={getVideoRefCallback(img.id)}
src={animatedSrc} src={animatedSrc}
poster={img.url}
muted muted
playsInline playsInline
autoPlay={isPlaying}
loop={shouldLoopCurrentVideo} loop={shouldLoopCurrentVideo}
preload="auto" preload="auto"
controls={false} controls={false}
disablePictureInPicture disablePictureInPicture
className={`absolute max-w-full max-h-full transition-opacity duration-150 ${ className="max-w-full max-h-full"
isVideoVisible ? "opacity-100" : "opacity-0"
}`}
style={{ style={{
width: img.width ? `${img.width}px` : undefined, width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined, height: img.height ? `${img.height}px` : undefined,
@@ -413,11 +398,9 @@ export const ImageCarousel = memo(
} }
}} }}
onLoadedData={() => { onLoadedData={() => {
setReadyVideoId(img.id);
onMediaReady(img.id); onMediaReady(img.id);
}} }}
onCanPlay={(e) => { onCanPlay={(e) => {
setReadyVideoId(img.id);
onMediaReady(img.id); onMediaReady(img.id);
if ( if (
pendingSeekRef.current && pendingSeekRef.current &&
@@ -468,7 +451,23 @@ export const ImageCarousel = memo(
onAnimatedFailed(img.id); 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> </div>
); );
})} })}
+30 -25
View File
@@ -84,12 +84,15 @@ export function useImageCarousel({
>({}); >({});
const [mediaSyncToken, setMediaSyncToken] = useState(0); const [mediaSyncToken, setMediaSyncToken] = useState(0);
const imagesRef = useRef(images);
imagesRef.current = images;
const segStartRef = useRef<number | null>(null); const segStartRef = useRef<number | null>(null);
const idxRef = useRef<number>(0); const idxRef = useRef<number>(0);
const timerRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const lastUiUpdateRef = useRef(0); const lastUiUpdateRef = useRef(0);
const segProgressRef = useRef(0); const segProgressRef = useRef(0);
const totalProgressRef = useRef(0); const totalProgressRef = useRef(0);
const prevImageKeyRef = useRef<string | null>(null);
const fallbackDuration = normalizeDuration(segmentMs, 5000); const fallbackDuration = normalizeDuration(segmentMs, 5000);
const segmentDurations = useMemo( const segmentDurations = useMemo(
@@ -178,7 +181,8 @@ export function useImageCarousel({
const advanceSegment = useCallback( const advanceSegment = useCallback(
(fromIndex: number): void => { (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(); const now = performance.now();
@@ -189,7 +193,7 @@ export function useImageCarousel({
return; return;
} }
if (fromIndex >= images.length - 1) { if (fromIndex >= currentImages.length - 1) {
if (loopMode === "sequential" && neighbors?.next) { if (loopMode === "sequential" && neighbors?.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`); router.push(`/aweme/${neighbors.next.aweme_id}`);
return; return;
@@ -199,7 +203,7 @@ export function useImageCarousel({
segStartRef.current = now; segStartRef.current = now;
lastUiUpdateRef.current = now; lastUiUpdateRef.current = now;
syncProgress(0, 0, true); syncProgress(0, 0, true);
if (images.length === 1) { if (currentImages.length === 1) {
setMediaSyncToken((token) => token + 1); setMediaSyncToken((token) => token + 1);
} }
return; return;
@@ -212,12 +216,12 @@ export function useImageCarousel({
lastUiUpdateRef.current = now; lastUiUpdateRef.current = now;
syncProgress(nextIndex, 0, true); syncProgress(nextIndex, 0, true);
}, },
[images.length, loopMode, neighbors?.next, router, syncProgress], [loopMode, neighbors?.next, router, syncProgress],
); );
const handleAnimatedProgress = useCallback( const handleAnimatedProgress = useCallback(
(imageId: string, progress: number): void => { (imageId: string, progress: number): void => {
const currentImage = images[idxRef.current]; const currentImage = imagesRef.current[idxRef.current];
if (!currentImage || currentImage.id !== imageId) return; if (!currentImage || currentImage.id !== imageId) return;
const now = performance.now(); const now = performance.now();
@@ -233,23 +237,24 @@ export function useImageCarousel({
syncProgress(idxRef.current, clamped, wrapped); syncProgress(idxRef.current, clamped, wrapped);
} }
}, },
[images, syncProgress], [syncProgress],
); );
const handleAnimatedEnded = useCallback( const handleAnimatedEnded = useCallback(
(imageId: string): void => { (imageId: string): void => {
const currentImage = images[idxRef.current]; const currentImage = imagesRef.current[idxRef.current];
if (!currentImage || currentImage.id !== imageId) return; if (!currentImage || currentImage.id !== imageId) return;
advanceSegment(idxRef.current); advanceSegment(idxRef.current);
}, },
[advanceSegment, images], [advanceSegment],
); );
const goToIndex = useCallback( const goToIndex = useCallback(
(index: number, segmentProgress = 0): void => { (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 nextSegmentProgress = Math.max(0, Math.min(1, segmentProgress));
const now = performance.now(); const now = performance.now();
const segmentDuration = segmentDurations[nextIndex] ?? fallbackDuration; const segmentDuration = segmentDurations[nextIndex] ?? fallbackDuration;
@@ -261,12 +266,12 @@ export function useImageCarousel({
syncProgress(nextIndex, nextSegmentProgress, true); syncProgress(nextIndex, nextSegmentProgress, true);
setMediaSyncToken((token) => token + 1); setMediaSyncToken((token) => token + 1);
}, },
[fallbackDuration, images.length, segmentDurations, syncProgress], [fallbackDuration, segmentDurations, syncProgress],
); );
const seekTo = useCallback( const seekTo = useCallback(
(ratio: number): void => { (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 clampedRatio = Math.max(0, Math.min(1, ratio));
const targetMs = Math.min( const targetMs = Math.min(
@@ -298,22 +303,22 @@ export function useImageCarousel({
targetDuration > 0 ? segmentElapsedMs / targetDuration : 0; targetDuration > 0 ? segmentElapsedMs / targetDuration : 0;
goToIndex(targetIndex, nextSegmentProgress); goToIndex(targetIndex, nextSegmentProgress);
}, },
[ [fallbackDuration, goToIndex, segmentDurations, totalDurationMs],
fallbackDuration,
goToIndex,
images.length,
segmentDurations,
totalDurationMs,
],
); );
useEffect(() => { useEffect(() => {
idxRef.current = idx; idxRef.current = idx;
}, [idx]); }, [idx]);
// 切换作品或图片集合变化时重置所有轮播状态 // 仅当真正切换到另一个作品(imageKey 变化)时重置状态,避免首次挂载覆盖子组件已上报的 ready 状态
useEffect(() => { useEffect(() => {
if (!imageKey) return; if (prevImageKeyRef.current === null) {
prevImageKeyRef.current = imageKey;
return;
}
if (prevImageKeyRef.current === imageKey) return;
prevImageKeyRef.current = imageKey;
idxRef.current = 0; idxRef.current = 0;
setIdx(0); setIdx(0);
segStartRef.current = null; segStartRef.current = null;
@@ -356,7 +361,7 @@ export function useImageCarousel({
: false; : false;
useEffect(() => { useEffect(() => {
if (!images?.length || !segmentDurations.length || totalDurationMs <= 0) { if (!images.length || !segmentDurations.length || totalDurationMs <= 0) {
return; return;
} }
@@ -373,10 +378,10 @@ export function useImageCarousel({
const now = performance.now(); const now = performance.now();
const currentSegmentDuration = segmentDurations[idx] ?? fallbackDuration; const currentSegmentDuration = segmentDurations[idx] ?? fallbackDuration;
segStartRef.current = now - segProgressRef.current * currentSegmentDuration; segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
lastUiUpdateRef.current = now;
const tick = (): void => { const tick = (): void => {
if (!images?.length) return; const currentImages = imagesRef.current;
if (!currentImages.length) return;
const ts = performance.now(); const ts = performance.now();
const start = segStartRef.current ?? ts; const start = segStartRef.current ?? ts;
@@ -407,7 +412,7 @@ export function useImageCarousel({
advanceSegment, advanceSegment,
fallbackDuration, fallbackDuration,
idx, idx,
images, images.length,
isCurrentAnimated, isCurrentAnimated,
isCurrentStaticReady, isCurrentStaticReady,
isPlaying, isPlaying,