fix: 修复图文动图播放被重置与进度停滞问题
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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) => {
|
||||||
|
const existing = videoRefCallbacks.current.get(imageId);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const callback = (el: HTMLVideoElement | null): void => {
|
||||||
if (el) {
|
if (el) {
|
||||||
videoRefs.current.set(imageId, el);
|
videoRefs.current.set(imageId, el);
|
||||||
if (Number.isFinite(el.duration) && el.duration > 0) {
|
if (Number.isFinite(el.duration) && el.duration > 0) {
|
||||||
onAnimatedDuration(imageId, Math.round(el.duration * 1000));
|
onAnimatedDuration(imageId, Math.round(el.duration * 1000));
|
||||||
}
|
}
|
||||||
if (el.readyState >= 2) {
|
|
||||||
setReadyVideoId(imageId);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const prevEl = videoRefs.current.get(imageId);
|
const prevEl = videoRefs.current.get(imageId);
|
||||||
if (prevEl) {
|
if (prevEl) {
|
||||||
releaseMediaElement(prevEl);
|
releaseMediaElement(prevEl);
|
||||||
videoRefs.current.delete(imageId);
|
videoRefs.current.delete(imageId);
|
||||||
}
|
}
|
||||||
setReadyVideoId((current) =>
|
videoRefCallbacks.current.delete(imageId);
|
||||||
current === imageId ? null : current,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
videoRefCallbacks.current.set(imageId, callback);
|
||||||
|
return callback;
|
||||||
},
|
},
|
||||||
[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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user