优化图文页

This commit is contained in:
2026-04-28 17:11:42 +08:00
parent 674c202264
commit 040273586b
69 changed files with 3939 additions and 2302 deletions
+159 -71
View File
@@ -1,78 +1,134 @@
import { forwardRef, useEffect, useRef, useState } from "react";
import type { ImageData } from "../types.ts";
import {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { ImageData, LoopMode } from "../types.ts";
interface ImageCarouselProps {
images: ImageData["images"];
currentIndex: number;
isPlaying: boolean;
segmentProgress: number;
mediaSyncToken: number;
loopMode: LoopMode;
onTogglePlay: () => void;
onAnimatedDuration?: (imageId: string, durationMs: number) => void;
}
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
({ images, currentIndex, onTogglePlay }, ref) => {
const [offset, setOffset] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
(
{
images,
currentIndex,
isPlaying,
segmentProgress,
mediaSyncToken,
loopMode,
onTogglePlay,
onAnimatedDuration,
},
ref,
) => {
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
const playedVideos = useRef<Set<string>>(new Set());
const activeVideoIdRef = useRef<string | null>(null);
const segmentProgressRef = useRef(segmentProgress);
const [useLightweightMode, setUseLightweightMode] = useState(false);
useEffect(() => {
segmentProgressRef.current = segmentProgress;
}, [segmentProgress]);
const syncVideoToSegmentProgress = useCallback(
(videoEl: HTMLVideoElement, progress: number, force = false) => {
const duration = videoEl.duration;
if (!Number.isFinite(duration) || duration <= 0) return;
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 (force || Math.abs(videoEl.currentTime - targetTime) > 0.12) {
try {
videoEl.currentTime = targetTime;
} catch {}
}
},
[],
);
useEffect(() => {
const userAgent = navigator.userAgent;
const isIOS =
/iPad|iPhone|iPod/.test(userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const isSafari =
/Safari/.test(userAgent) && !/CriOS|FxiOS|EdgiOS/.test(userAgent);
setUseLightweightMode(isIOS && isSafari);
}, []);
// 虚拟滚动:只渲染当前图片和前后各一张
const visibleIndices = (() => {
const visibleIndices = useMemo(() => {
if (useLightweightMode) return [currentIndex];
const indices: number[] = [];
if (currentIndex > 0) indices.push(currentIndex - 1);
indices.push(currentIndex);
if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
return indices;
})();
}, [currentIndex, images.length, useLightweightMode]);
// 当 currentIndex 变化时,触发滚动动画
// iOS Safari 对多个同屏 video 很敏感,这里只让当前动图持有 video 元素并跟随全局播放状态。
useEffect(() => {
setIsTransitioning(true);
setOffset(-currentIndex * 100);
const currentImage = images[currentIndex];
if (!currentImage?.animated) {
activeVideoIdRef.current = null;
videoRefs.current.forEach((videoEl) => videoEl.pause());
return;
}
const timer = setTimeout(() => {
setIsTransitioning(false);
}, 300); // 与 CSS transition 时间匹配
const videoEl = videoRefs.current.get(currentImage.id);
return () => clearTimeout(timer);
}, [currentIndex]);
videoRefs.current.forEach((itemVideoEl, imageId) => {
if (imageId !== currentImage.id || !isPlaying) {
itemVideoEl.pause();
}
});
if (videoEl) {
if (activeVideoIdRef.current !== currentImage.id) {
activeVideoIdRef.current = currentImage.id;
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
}
if (isPlaying) {
videoEl.play().catch(() => {});
} else {
videoEl.pause();
}
}
}, [currentIndex, images, isPlaying, loopMode, syncVideoToSegmentProgress]);
// 管理动图播放:进入视口时播放一次
useEffect(() => {
const currentImage = images[currentIndex];
if (!currentImage?.animated) return;
const videoKey = currentImage.id;
const videoEl = videoRefs.current.get(videoKey);
if (videoEl) {
// 检查是否已经播放过
if (!playedVideos.current.has(videoKey)) {
// 重置并播放
videoEl.currentTime = 0;
videoEl.play().catch(() => {});
playedVideos.current.add(videoKey);
} else {
// 已播放过,重置到开头但不自动播放
videoEl.currentTime = 0;
videoEl.play().catch(() => {});
}
}
}, [currentIndex, images]);
const videoEl = videoRefs.current.get(currentImage.id);
if (!videoEl) return;
// 当切换到其他图片时,清除已播放标记(切回来会重新播放)
useEffect(() => {
const currentImage = images[currentIndex];
if (currentImage?.animated) {
const videoKey = currentImage.id;
// 清除其他视频的播放记录
playedVideos.current.forEach(key => {
if (key !== videoKey) {
playedVideos.current.delete(key);
}
});
}
}, [currentIndex, images]);
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
if (isPlaying) videoEl.play().catch(() => {});
}, [
currentIndex,
images,
isPlaying,
loopMode,
mediaSyncToken,
syncVideoToSegmentProgress,
]);
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => {
if (el) {
@@ -83,49 +139,81 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
};
return (
<div
ref={ref}
className="relative h-full w-full overflow-hidden"
>
<div
ref={containerRef}
className="flex h-full w-full"
style={{
transform: `translateX(${offset}%)`,
transition: isTransitioning ? 'transform 300ms ease-out' : 'none',
}}
>
<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];
const isCurrent = i === currentIndex;
const animatedSrc = isCurrent ? img.animated : null;
return (
<div
key={img.id}
className="relative h-full min-w-full flex items-center justify-center bg-black/70 cursor-pointer"
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 * 100}%)`,
position: 'absolute',
left: 0,
top: 0,
width: '100%',
transform: useLightweightMode
? "translateX(0)"
: `translateX(${(i - currentIndex) * 100}%)`,
transition: useLightweightMode
? "none"
: "transform 240ms ease-out",
zIndex: isCurrent ? 1 : 0,
}}
onClick={onTogglePlay}
>
{img.animated ? (
{animatedSrc ? (
<video
ref={(el) => handleVideoRef(el, img.id)}
src={img.animated}
src={animatedSrc}
poster={img.url}
muted
playsInline
loop={loopMode === "single"}
preload={useLightweightMode ? "none" : "metadata"}
controls={false}
disablePictureInPicture
className="max-w-full max-h-full object-contain"
style={{
width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined,
}}
onLoadedMetadata={(e) => {
const duration = e.currentTarget.duration;
if (Number.isFinite(duration) && duration > 0) {
onAnimatedDuration?.(
img.id,
Math.round(duration * 1000),
);
if (isCurrent) {
syncVideoToSegmentProgress(
e.currentTarget,
segmentProgressRef.current,
true,
);
}
}
}}
onCanPlay={(e) => {
if (isCurrent) {
syncVideoToSegmentProgress(
e.currentTarget,
segmentProgressRef.current,
);
}
if (isCurrent && isPlaying)
e.currentTarget.play().catch(() => {});
}}
onEnded={(e) => {
// 播放结束后停留在最后一帧
e.currentTarget.pause();
if (loopMode !== "single") e.currentTarget.pause();
}}
/>
) : (
<img
src={img.url}
alt={`image-${i + 1}`}
loading={isCurrent ? "eager" : "lazy"}
decoding="async"
className="max-w-full max-h-full object-contain"
style={{
width: img.width ? `${img.width}px` : undefined,
@@ -139,7 +227,7 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
</div>
</div>
);
}
},
);
ImageCarousel.displayName = "ImageCarousel";