345 lines
9.1 KiB
TypeScript
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,
|
|
};
|
|
}
|