Files
douyin-archive/app/aweme/[awemeId]/components/ImageCarousel.tsx
T

483 lines
16 KiB
TypeScript

import {
forwardRef,
memo,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { ImageData, LoopMode, ObjectFit } from "../types.ts";
import { releaseMediaElement } from "../utils";
interface ImageCarouselProps {
images: ImageData["images"];
currentIndex: number;
isPlaying: boolean;
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;
onMediaReady: (imageId: string) => void;
onAnimatedFailed: (imageId: string) => void;
onAnimatedProgress: (imageId: string, progress: number) => void;
onAnimatedEnded: (imageId: string) => void;
}
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);
const shouldLoopCurrentVideo =
loopMode === "single" ||
(images.length === 1 &&
(loopMode !== "sequential" || !hasNextNeighbor));
const seekVideoToProgress = useCallback(
(videoEl: HTMLVideoElement, progress: number): boolean => {
const duration = videoEl.duration;
if (!Number.isFinite(duration) || duration <= 0) return false;
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 (Math.abs(videoEl.currentTime - targetTime) > 0.08) {
try {
videoEl.currentTime = targetTime;
} catch {
return false;
}
}
return true;
},
[],
);
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],
);
// 虚拟窗口:同时渲染前一张、当前张、后一张静态图以保证切页无白屏,同时仅当前张挂载单个 <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 (activeVideoIdRef.current !== currentImage.id) {
activeVideoIdRef.current = currentImage.id;
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) {
tryPlayVideo(videoEl, currentImage.id);
} else {
videoEl.pause();
}
}, [
currentIndex,
failedAnimatedMap,
images,
isPlaying,
seekVideoToProgress,
segmentProgressRef,
shouldLoopCurrentVideo,
tryPlayVideo,
]);
// 响应用户主动 Seek 或外部强制同步
useEffect(() => {
if (lastMediaSyncTokenRef.current === mediaSyncToken) return;
lastMediaSyncTokenRef.current = mediaSyncToken;
const currentImage = images[currentIndex];
if (!currentImage?.animated || failedAnimatedMap[currentImage.id]) {
return;
}
const targetProgress = segmentProgressRef.current;
const videoEl = videoRefs.current.get(currentImage.id);
if (!videoEl) {
pendingSeekRef.current = {
imageId: currentImage.id,
progress: targetProgress,
};
return;
}
const applied = seekVideoToProgress(videoEl, targetProgress);
if (applied) {
pendingSeekRef.current = null;
} else {
pendingSeekRef.current = {
imageId: currentImage.id,
progress: targetProgress,
};
}
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="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)}
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>
);
},
),
);
ImageCarousel.displayName = "ImageCarousel";