优化图文页
This commit is contained in:
@@ -1,8 +1,32 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ImageData, LoopMode, Neighbors } from "../types.ts";
|
||||
|
||||
const MIN_SEGMENT_MS = 250;
|
||||
const UI_UPDATE_INTERVAL_MS = 33;
|
||||
const PROGRESS_EPSILON = 0.0001;
|
||||
|
||||
function normalizeDuration(
|
||||
duration: number | null | undefined,
|
||||
fallback: number,
|
||||
) {
|
||||
if (
|
||||
typeof duration !== "number" ||
|
||||
!Number.isFinite(duration) ||
|
||||
duration <= 0
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(MIN_SEGMENT_MS, Math.round(duration));
|
||||
}
|
||||
|
||||
function sumDurations(durations: number[], end: number) {
|
||||
let total = 0;
|
||||
for (let i = 0; i < end; i++) total += durations[i] ?? 0;
|
||||
return total;
|
||||
}
|
||||
|
||||
interface UseImageCarouselProps {
|
||||
images: ImageData["images"];
|
||||
isPlaying: boolean;
|
||||
@@ -10,7 +34,6 @@ interface UseImageCarouselProps {
|
||||
neighbors: Neighbors;
|
||||
volume: number;
|
||||
audioRef: RefObject<HTMLAudioElement | null>;
|
||||
scrollerRef: RefObject<HTMLDivElement | null>;
|
||||
setProgress: (progress: number) => void;
|
||||
/** 单张图片显示时长(毫秒),默认 5000ms */
|
||||
segmentMs?: number;
|
||||
@@ -23,22 +46,186 @@ export function useImageCarousel({
|
||||
neighbors,
|
||||
volume,
|
||||
audioRef,
|
||||
scrollerRef,
|
||||
setProgress,
|
||||
segmentMs = 5000,
|
||||
}: UseImageCarouselProps) {
|
||||
const router = useRouter();
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [segProgress, setSegProgress] = useState(0);
|
||||
|
||||
const [durationOverrides, setDurationOverrides] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [mediaSyncToken, setMediaSyncToken] = useState(0);
|
||||
|
||||
const segStartRef = useRef<number | null>(null);
|
||||
const idxRef = useRef<number>(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const lastUiUpdateRef = useRef(0);
|
||||
const segProgressRef = useRef(0);
|
||||
const totalProgressRef = useRef(0);
|
||||
|
||||
const fallbackDuration = normalizeDuration(segmentMs, 5000);
|
||||
const segmentDurations = useMemo(
|
||||
() =>
|
||||
images.map((img) =>
|
||||
normalizeDuration(
|
||||
durationOverrides[img.id] ?? img.duration,
|
||||
fallbackDuration,
|
||||
),
|
||||
),
|
||||
[durationOverrides, fallbackDuration, images],
|
||||
);
|
||||
const imageKey = useMemo(
|
||||
() => images.map((img) => img.id).join("|"),
|
||||
[images],
|
||||
);
|
||||
const totalDurationMs = useMemo(
|
||||
() => segmentDurations.reduce((sum, duration) => sum + duration, 0),
|
||||
[segmentDurations],
|
||||
);
|
||||
|
||||
const syncProgress = useCallback(
|
||||
(index: number, segmentProgress: number, force = false) => {
|
||||
const segmentDuration = segmentDurations[index] ?? fallbackDuration;
|
||||
const completedMs = sumDurations(segmentDurations, index);
|
||||
const totalProgress =
|
||||
totalDurationMs > 0
|
||||
? (completedMs + segmentDuration * segmentProgress) / totalDurationMs
|
||||
: 0;
|
||||
const nextSegProgress = Math.max(0, Math.min(1, segmentProgress));
|
||||
const nextTotalProgress = Math.max(0, Math.min(1, totalProgress));
|
||||
|
||||
if (
|
||||
force ||
|
||||
Math.abs(nextSegProgress - segProgressRef.current) > PROGRESS_EPSILON
|
||||
) {
|
||||
segProgressRef.current = nextSegProgress;
|
||||
setSegProgress(nextSegProgress);
|
||||
}
|
||||
|
||||
if (
|
||||
force ||
|
||||
Math.abs(nextTotalProgress - totalProgressRef.current) >
|
||||
PROGRESS_EPSILON
|
||||
) {
|
||||
totalProgressRef.current = nextTotalProgress;
|
||||
setProgress(nextTotalProgress);
|
||||
}
|
||||
},
|
||||
[fallbackDuration, segmentDurations, setProgress, totalDurationMs],
|
||||
);
|
||||
|
||||
const setSegmentDuration = useCallback(
|
||||
(imageId: string, durationMs: number) => {
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) return;
|
||||
const normalizedDuration = normalizeDuration(
|
||||
durationMs,
|
||||
fallbackDuration,
|
||||
);
|
||||
|
||||
setDurationOverrides((current) => {
|
||||
if (current[imageId] === normalizedDuration) return current;
|
||||
return { ...current, [imageId]: normalizedDuration };
|
||||
});
|
||||
},
|
||||
[fallbackDuration],
|
||||
);
|
||||
|
||||
const goToIndex = useCallback(
|
||||
(index: number, segmentProgress = 0) => {
|
||||
if (!images.length) return;
|
||||
|
||||
const nextIndex = Math.max(0, Math.min(images.length - 1, index));
|
||||
const nextSegmentProgress = Math.max(0, Math.min(1, segmentProgress));
|
||||
const now = performance.now();
|
||||
const segmentDuration = segmentDurations[nextIndex] ?? fallbackDuration;
|
||||
|
||||
idxRef.current = nextIndex;
|
||||
setIdx(nextIndex);
|
||||
segStartRef.current = now - nextSegmentProgress * segmentDuration;
|
||||
lastUiUpdateRef.current = now;
|
||||
syncProgress(nextIndex, nextSegmentProgress, true);
|
||||
setMediaSyncToken((token) => token + 1);
|
||||
},
|
||||
[fallbackDuration, images.length, segmentDurations, syncProgress],
|
||||
);
|
||||
|
||||
const seekTo = useCallback(
|
||||
(ratio: number) => {
|
||||
if (!images.length || totalDurationMs <= 0) return;
|
||||
|
||||
const clampedRatio = Math.max(0, Math.min(1, ratio));
|
||||
const targetMs = Math.min(
|
||||
totalDurationMs - 1,
|
||||
clampedRatio * totalDurationMs,
|
||||
);
|
||||
let accumulatedMs = 0;
|
||||
let targetIndex = 0;
|
||||
let segmentElapsedMs = 0;
|
||||
|
||||
for (let i = 0; i < segmentDurations.length; i++) {
|
||||
const duration = segmentDurations[i] ?? fallbackDuration;
|
||||
if (
|
||||
targetMs < accumulatedMs + duration ||
|
||||
i === segmentDurations.length - 1
|
||||
) {
|
||||
targetIndex = i;
|
||||
segmentElapsedMs = Math.max(
|
||||
0,
|
||||
Math.min(duration, targetMs - accumulatedMs),
|
||||
);
|
||||
break;
|
||||
}
|
||||
accumulatedMs += duration;
|
||||
}
|
||||
|
||||
const targetDuration = segmentDurations[targetIndex] ?? fallbackDuration;
|
||||
const nextSegmentProgress =
|
||||
targetDuration > 0 ? segmentElapsedMs / targetDuration : 0;
|
||||
goToIndex(targetIndex, nextSegmentProgress);
|
||||
},
|
||||
[
|
||||
fallbackDuration,
|
||||
goToIndex,
|
||||
images.length,
|
||||
segmentDurations,
|
||||
totalDurationMs,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
idxRef.current = idx;
|
||||
}, [idx]);
|
||||
|
||||
useEffect(() => {
|
||||
const validIds = new Set(images.map((img) => img.id));
|
||||
setDurationOverrides((current) => {
|
||||
let changed = false;
|
||||
const next: Record<string, number> = {};
|
||||
|
||||
for (const [id, duration] of Object.entries(current)) {
|
||||
if (validIds.has(id)) {
|
||||
next[id] = duration;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [images]);
|
||||
|
||||
useEffect(() => {
|
||||
idxRef.current = 0;
|
||||
setIdx(0);
|
||||
segStartRef.current = null;
|
||||
segProgressRef.current = 0;
|
||||
totalProgressRef.current = 0;
|
||||
setSegProgress(0);
|
||||
setProgress(0);
|
||||
setMediaSyncToken((token) => token + 1);
|
||||
}, [imageKey, setProgress]);
|
||||
|
||||
// BGM 控制
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
@@ -51,33 +238,42 @@ export function useImageCarousel({
|
||||
}
|
||||
}, [audioRef, isPlaying, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current?.pause();
|
||||
};
|
||||
}, [audioRef]);
|
||||
|
||||
// 自动切页
|
||||
useEffect(() => {
|
||||
if (!images?.length) return;
|
||||
if (!images?.length || !segmentDurations.length || totalDurationMs <= 0)
|
||||
return;
|
||||
|
||||
if (segStartRef.current == null) segStartRef.current = performance.now();
|
||||
let lastTs = performance.now();
|
||||
const now = performance.now();
|
||||
const currentSegmentDuration =
|
||||
segmentDurations[idxRef.current] ?? fallbackDuration;
|
||||
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
|
||||
|
||||
const tick = (ts: number) => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const tick = () => {
|
||||
if (!images?.length) return;
|
||||
|
||||
if (!isPlaying) segStartRef.current! += ts - lastTs;
|
||||
lastTs = ts;
|
||||
const ts = performance.now();
|
||||
|
||||
let start = segStartRef.current!;
|
||||
let localIdx = idxRef.current;
|
||||
|
||||
let elapsed = ts - start;
|
||||
|
||||
// 获取当前图片的显示时长(动图使用其 duration,静态图片使用 segmentMs)
|
||||
const getCurrentSegmentDuration = (index: number) => {
|
||||
const img = images[index];
|
||||
return img?.duration ?? segmentMs;
|
||||
};
|
||||
|
||||
let currentSegmentDuration = getCurrentSegmentDuration(localIdx);
|
||||
|
||||
let currentSegmentDuration =
|
||||
segmentDurations[localIdx] ?? fallbackDuration;
|
||||
|
||||
while (elapsed >= currentSegmentDuration) {
|
||||
if (loopMode === "single") {
|
||||
elapsed %= currentSegmentDuration;
|
||||
break;
|
||||
}
|
||||
|
||||
elapsed -= currentSegmentDuration;
|
||||
|
||||
if (localIdx >= images.length - 1) {
|
||||
@@ -89,44 +285,60 @@ export function useImageCarousel({
|
||||
} else {
|
||||
localIdx = localIdx + 1;
|
||||
}
|
||||
|
||||
// 更新下一张图片的时长
|
||||
currentSegmentDuration = getCurrentSegmentDuration(localIdx);
|
||||
|
||||
currentSegmentDuration = segmentDurations[localIdx] ?? fallbackDuration;
|
||||
}
|
||||
segStartRef.current = ts - elapsed;
|
||||
|
||||
if (localIdx !== idxRef.current) {
|
||||
const indexChanged = localIdx !== idxRef.current;
|
||||
if (indexChanged) {
|
||||
idxRef.current = localIdx;
|
||||
setIdx(localIdx);
|
||||
// 虚拟滚动不需要实际滚动 DOM
|
||||
}
|
||||
|
||||
const localSeg = Math.max(0, Math.min(1, elapsed / currentSegmentDuration));
|
||||
setSegProgress(localSeg);
|
||||
|
||||
// 计算总进度:已完成的图片 + 当前图片的进度
|
||||
let totalProgress = 0;
|
||||
for (let i = 0; i < localIdx; i++) {
|
||||
totalProgress += 1;
|
||||
}
|
||||
totalProgress += localSeg;
|
||||
setProgress(totalProgress / images.length);
|
||||
const localSeg = Math.max(
|
||||
0,
|
||||
Math.min(1, elapsed / currentSegmentDuration),
|
||||
);
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
if (
|
||||
indexChanged ||
|
||||
ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
|
||||
) {
|
||||
lastUiUpdateRef.current = ts;
|
||||
syncProgress(localIdx, localSeg, indexChanged);
|
||||
}
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
tick();
|
||||
timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS);
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
};
|
||||
}, [images, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]);
|
||||
}, [
|
||||
fallbackDuration,
|
||||
images,
|
||||
isPlaying,
|
||||
loopMode,
|
||||
neighbors?.next,
|
||||
router,
|
||||
segmentDurations,
|
||||
syncProgress,
|
||||
totalDurationMs,
|
||||
]);
|
||||
|
||||
return {
|
||||
idx,
|
||||
setIdx,
|
||||
segProgress,
|
||||
segmentDurations,
|
||||
totalDurationMs,
|
||||
mediaSyncToken,
|
||||
segStartRef,
|
||||
idxRef,
|
||||
setSegmentDuration,
|
||||
goToIndex,
|
||||
seekTo,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user