douyin-archive/app/aweme/[awemeId]/hooks/useImageCarousel.ts
2026-04-28 17:11:42 +08:00

345 lines
9.1 KiB
TypeScript

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;
loopMode: LoopMode;
neighbors: Neighbors;
volume: number;
audioRef: RefObject<HTMLAudioElement | null>;
setProgress: (progress: number) => void;
/** 单张图片显示时长(毫秒),默认 5000ms */
segmentMs?: number;
}
export function useImageCarousel({
images,
isPlaying,
loopMode,
neighbors,
volume,
audioRef,
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 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;
if (!el) return;
el.volume = volume;
if (isPlaying) {
el.play().catch(() => {});
} else {
el.pause();
}
}, [audioRef, isPlaying, volume]);
useEffect(() => {
return () => {
audioRef.current?.pause();
};
}, [audioRef]);
// 自动切页
useEffect(() => {
if (!images?.length || !segmentDurations.length || totalDurationMs <= 0)
return;
const now = performance.now();
const currentSegmentDuration =
segmentDurations[idxRef.current] ?? fallbackDuration;
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
if (!isPlaying) return;
const tick = () => {
if (!images?.length) return;
const ts = performance.now();
let start = segStartRef.current!;
let localIdx = idxRef.current;
let elapsed = ts - start;
let currentSegmentDuration =
segmentDurations[localIdx] ?? fallbackDuration;
while (elapsed >= currentSegmentDuration) {
if (loopMode === "single") {
elapsed %= currentSegmentDuration;
break;
}
elapsed -= currentSegmentDuration;
if (localIdx >= images.length - 1) {
if (loopMode === "sequential" && neighbors?.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`);
return;
}
localIdx = 0;
} else {
localIdx = localIdx + 1;
}
currentSegmentDuration = segmentDurations[localIdx] ?? fallbackDuration;
}
segStartRef.current = ts - elapsed;
const indexChanged = localIdx !== idxRef.current;
if (indexChanged) {
idxRef.current = localIdx;
setIdx(localIdx);
}
const localSeg = Math.max(
0,
Math.min(1, elapsed / currentSegmentDuration),
);
if (
indexChanged ||
ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
) {
lastUiUpdateRef.current = ts;
syncProgress(localIdx, localSeg, indexChanged);
}
};
tick();
timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS);
return () => {
if (timerRef.current) window.clearInterval(timerRef.current);
timerRef.current = null;
};
}, [
fallbackDuration,
images,
isPlaying,
loopMode,
neighbors?.next,
router,
segmentDurations,
syncProgress,
totalDurationMs,
]);
return {
idx,
setIdx,
segProgress,
segmentDurations,
totalDurationMs,
mediaSyncToken,
segStartRef,
idxRef,
setSegmentDuration,
goToIndex,
seekTo,
};
}