优化动图
This commit is contained in:
@@ -41,7 +41,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
|
||||
const backgroundCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
// 图文轮播状态
|
||||
const images = isVideo ? [] : (data as ImageData).images;
|
||||
const images = isVideo ? [] : (data as ImageData).images;
|
||||
const imageCarouselState = useImageCarousel({
|
||||
images,
|
||||
isPlaying: playerState.isPlaying,
|
||||
@@ -80,18 +80,43 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
|
||||
return;
|
||||
}
|
||||
if (!images?.length) return;
|
||||
const total = images.length;
|
||||
const exact = ratio * total;
|
||||
const targetIdx = Math.min(total - 1, Math.floor(exact));
|
||||
const remainder = exact - targetIdx;
|
||||
|
||||
// 计算每张图片的时长
|
||||
const durations = images.map(img => img.duration ?? SEGMENT_MS);
|
||||
const totalDuration = durations.reduce((sum, d) => sum + d, 0);
|
||||
const targetTime = ratio * totalDuration;
|
||||
|
||||
// 找到目标时间对应的图片索引和进度
|
||||
let accumulatedTime = 0;
|
||||
let targetIdx = 0;
|
||||
let remainder = 0;
|
||||
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
if (accumulatedTime + durations[i] > targetTime) {
|
||||
targetIdx = i;
|
||||
remainder = (targetTime - accumulatedTime) / durations[i];
|
||||
break;
|
||||
}
|
||||
accumulatedTime += durations[i];
|
||||
if (i === images.length - 1) {
|
||||
targetIdx = i;
|
||||
remainder = 1;
|
||||
}
|
||||
}
|
||||
|
||||
imageCarouselState.idxRef.current = targetIdx;
|
||||
imageCarouselState.setIdx(targetIdx);
|
||||
imageCarouselState.segStartRef.current = performance.now() - remainder * SEGMENT_MS;
|
||||
playerState.setProgress((targetIdx + remainder) / total);
|
||||
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: targetIdx * el.clientWidth, behavior: "smooth" });
|
||||
imageCarouselState.segStartRef.current = performance.now() - remainder * durations[targetIdx];
|
||||
|
||||
// 重新计算总进度
|
||||
let totalProgress = 0;
|
||||
for (let i = 0; i < targetIdx; i++) {
|
||||
totalProgress += 1;
|
||||
}
|
||||
totalProgress += remainder;
|
||||
playerState.setProgress(totalProgress / images.length);
|
||||
|
||||
// 虚拟滚动不需要实际滚动 DOM
|
||||
};
|
||||
|
||||
const togglePlay = async () => {
|
||||
@@ -141,8 +166,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
|
||||
imageCarouselState.idxRef.current = next;
|
||||
imageCarouselState.setIdx(next);
|
||||
imageCarouselState.segStartRef.current = performance.now();
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
|
||||
// 虚拟滚动不需要实际滚动 DOM
|
||||
};
|
||||
|
||||
const nextImg = () => {
|
||||
@@ -151,8 +175,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
|
||||
imageCarouselState.idxRef.current = next;
|
||||
imageCarouselState.setIdx(next);
|
||||
imageCarouselState.segStartRef.current = performance.now();
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
|
||||
// 虚拟滚动不需要实际滚动 DOM
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef } from "react";
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import type { ImageData } from "../types";
|
||||
|
||||
interface ImageCarouselProps {
|
||||
@@ -9,29 +9,134 @@ interface ImageCarouselProps {
|
||||
|
||||
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);
|
||||
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
|
||||
const playedVideos = useRef<Set<string>>(new Set());
|
||||
|
||||
// 虚拟滚动:只渲染当前图片和前后各一张
|
||||
const visibleIndices = (() => {
|
||||
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 变化时,触发滚动动画
|
||||
useEffect(() => {
|
||||
setIsTransitioning(true);
|
||||
setOffset(-currentIndex * 100);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, 300); // 与 CSS transition 时间匹配
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [currentIndex]);
|
||||
|
||||
// 管理动图播放:进入视口时播放一次
|
||||
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]);
|
||||
|
||||
// 当切换到其他图片时,清除已播放标记(切回来会重新播放)
|
||||
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]);
|
||||
|
||||
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => {
|
||||
if (el) {
|
||||
videoRefs.current.set(imageId, el);
|
||||
} else {
|
||||
videoRefs.current.delete(imageId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex h-full w-full snap-x snap-mandatory overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||
className="relative h-full w-full overflow-hidden"
|
||||
>
|
||||
{images.map((img, i) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="relative h-full min-w-full snap-center flex items-center justify-center bg-black/70 cursor-pointer"
|
||||
onClick={onTogglePlay}
|
||||
>{
|
||||
img.animated ? <video src={img.animated} autoPlay muted playsInline className="max-w-full max-h-full object-contain" /> : <img
|
||||
src={img.url}
|
||||
alt={`image-${i + 1}`}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex h-full w-full"
|
||||
style={{
|
||||
transform: `translateX(${offset}%)`,
|
||||
transition: isTransitioning ? 'transform 300ms ease-out' : 'none',
|
||||
}}
|
||||
>
|
||||
{visibleIndices.map((i) => {
|
||||
const img = images[i];
|
||||
return (
|
||||
<div
|
||||
key={img.id}
|
||||
className="relative h-full min-w-full flex items-center justify-center bg-black/70 cursor-pointer"
|
||||
style={{
|
||||
width: img.width ? `${img.width}px` : undefined,
|
||||
height: img.height ? `${img.height}px` : undefined,
|
||||
transform: `translateX(${i * 100}%)`,
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
onClick={onTogglePlay}
|
||||
>
|
||||
{img.animated ? (
|
||||
<video
|
||||
ref={(el) => handleVideoRef(el, img.id)}
|
||||
src={img.animated}
|
||||
muted
|
||||
playsInline
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onEnded={(e) => {
|
||||
// 播放结束后停留在最后一帧
|
||||
e.currentTarget.pause();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={img.url}
|
||||
alt={`image-${i + 1}`}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{
|
||||
width: img.width ? `${img.width}px` : undefined,
|
||||
height: img.height ? `${img.height}px` : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,9 +46,14 @@ export function useBackgroundCanvas({
|
||||
} else {
|
||||
const scroller = scrollerRef.current;
|
||||
if (scroller) {
|
||||
const currentImgContainer = scroller.children[idx] as HTMLElement;
|
||||
if (currentImgContainer) {
|
||||
sourceElement = currentImgContainer.querySelector("img");
|
||||
// 虚拟滚动:查找所有图片容器,找到当前显示的那个
|
||||
const containers = scroller.querySelectorAll<HTMLElement>('div[style*="translateX"]');
|
||||
for (const container of containers) {
|
||||
const img = container.querySelector("img, video");
|
||||
if (img && container.style.transform.includes(`${idx * 100}%`)) {
|
||||
sourceElement = img as HTMLImageElement | HTMLVideoElement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +68,17 @@ export function useImageCarousel({
|
||||
let localIdx = idxRef.current;
|
||||
|
||||
let elapsed = ts - start;
|
||||
while (elapsed >= segmentMs) {
|
||||
elapsed -= segmentMs;
|
||||
|
||||
// 获取当前图片的显示时长(动图使用其 duration,静态图片使用 segmentMs)
|
||||
const getCurrentSegmentDuration = (index: number) => {
|
||||
const img = images[index];
|
||||
return img?.duration ?? segmentMs;
|
||||
};
|
||||
|
||||
let currentSegmentDuration = getCurrentSegmentDuration(localIdx);
|
||||
|
||||
while (elapsed >= currentSegmentDuration) {
|
||||
elapsed -= currentSegmentDuration;
|
||||
|
||||
if (localIdx >= images.length - 1) {
|
||||
if (loopMode === "sequential" && neighbors?.next) {
|
||||
@@ -80,19 +89,28 @@ export function useImageCarousel({
|
||||
} else {
|
||||
localIdx = localIdx + 1;
|
||||
}
|
||||
|
||||
// 更新下一张图片的时长
|
||||
currentSegmentDuration = getCurrentSegmentDuration(localIdx);
|
||||
}
|
||||
segStartRef.current = ts - elapsed;
|
||||
|
||||
if (localIdx !== idxRef.current) {
|
||||
idxRef.current = localIdx;
|
||||
setIdx(localIdx);
|
||||
const el = scrollerRef.current;
|
||||
if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" });
|
||||
// 虚拟滚动不需要实际滚动 DOM
|
||||
}
|
||||
|
||||
const localSeg = Math.max(0, Math.min(1, elapsed / segmentMs));
|
||||
const localSeg = Math.max(0, Math.min(1, elapsed / currentSegmentDuration));
|
||||
setSegProgress(localSeg);
|
||||
setProgress((localIdx + localSeg) / images.length);
|
||||
|
||||
// 计算总进度:已完成的图片 + 当前图片的进度
|
||||
let totalProgress = 0;
|
||||
for (let i = 0; i < localIdx; i++) {
|
||||
totalProgress += 1;
|
||||
}
|
||||
totalProgress += localSeg;
|
||||
setProgress(totalProgress / images.length);
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
@@ -102,7 +120,7 @@ export function useImageCarousel({
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
};
|
||||
}, [images?.length, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]);
|
||||
}, [images, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]);
|
||||
|
||||
return {
|
||||
idx,
|
||||
|
||||
@@ -26,7 +26,7 @@ export type ImageData = {
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
created_at: string | Date;
|
||||
images: { id: string; url: string; width?: number; height?: number, animated?: string }[];
|
||||
images: { id: string; url: string; width?: number; height?: number; animated?: string; duration?: number }[];
|
||||
music_url?: string | null;
|
||||
author: User;
|
||||
comments: Comment[];
|
||||
|
||||
Reference in New Issue
Block a user