Files
douyin-archive/app/aweme/[awemeId]/hooks/useImageCarousel.ts
T

439 lines
13 KiB
TypeScript

import { useRouter } from "next/navigation";
import type { RefObject } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ImageData, LoopMode, Neighbors } from "../types.ts";
import { releaseMediaElement } from "../utils";
const MIN_SEGMENT_MS = 250;
const UI_UPDATE_INTERVAL_MS = 33;
const PROGRESS_EPSILON = 0.001;
function normalizeDuration(
duration: number | null | undefined,
fallback: number,
): 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): 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 interface UseImageCarouselReturn {
idx: number;
setIdx: (idx: number) => void;
segProgress: number;
segProgressRef: RefObject<number>;
segmentDurations: number[];
totalDurationMs: number;
mediaSyncToken: number;
segStartRef: RefObject<number | null>;
idxRef: RefObject<number>;
failedAnimatedMap: Record<string, boolean>;
setSegmentDuration: (imageId: string, durationMs: number) => void;
markMediaReady: (imageId: string) => void;
markAnimatedFailed: (imageId: string) => void;
handleAnimatedProgress: (imageId: string, progress: number) => void;
handleAnimatedEnded: (imageId: string) => void;
goToIndex: (index: number, segmentProgress?: number) => void;
seekTo: (ratio: number) => void;
}
export function useImageCarousel({
images,
isPlaying,
loopMode,
neighbors,
volume,
audioRef,
setProgress,
segmentMs = 5000,
}: UseImageCarouselProps): UseImageCarouselReturn {
const router = useRouter();
const [idx, setIdx] = useState(0);
const [segProgress, setSegProgress] = useState(0);
const [durationOverrides, setDurationOverrides] = useState<
Record<string, number>
>({});
const [readyMediaMap, setReadyMediaMap] = useState<Record<string, boolean>>(
{},
);
const [failedAnimatedMap, setFailedAnimatedMap] = useState<
Record<string, boolean>
>({});
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): void => {
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): void => {
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 markMediaReady = useCallback((imageId: string): void => {
setReadyMediaMap((current) => {
if (current[imageId]) return current;
return { ...current, [imageId]: true };
});
}, []);
const markAnimatedFailed = useCallback((imageId: string): void => {
setFailedAnimatedMap((current) => {
if (current[imageId]) return current;
return { ...current, [imageId]: true };
});
setReadyMediaMap((current) => {
if (current[imageId]) return current;
return { ...current, [imageId]: true };
});
}, []);
const advanceSegment = useCallback(
(fromIndex: number): void => {
if (!images.length || idxRef.current !== fromIndex) return;
const now = performance.now();
if (loopMode === "single") {
segStartRef.current = now;
lastUiUpdateRef.current = now;
syncProgress(fromIndex, 0, true);
return;
}
if (fromIndex >= images.length - 1) {
if (loopMode === "sequential" && neighbors?.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`);
return;
}
idxRef.current = 0;
setIdx(0);
segStartRef.current = now;
lastUiUpdateRef.current = now;
syncProgress(0, 0, true);
if (images.length === 1) {
setMediaSyncToken((token) => token + 1);
}
return;
}
const nextIndex = fromIndex + 1;
idxRef.current = nextIndex;
setIdx(nextIndex);
segStartRef.current = now;
lastUiUpdateRef.current = now;
syncProgress(nextIndex, 0, true);
},
[images.length, loopMode, neighbors?.next, router, syncProgress],
);
const handleAnimatedProgress = useCallback(
(imageId: string, progress: number): void => {
const currentImage = images[idxRef.current];
if (!currentImage || currentImage.id !== imageId) return;
const now = performance.now();
const clamped = Math.max(0, Math.min(1, progress));
const wrapped = clamped < segProgressRef.current - 0.2;
if (
wrapped ||
clamped >= 0.995 ||
now - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
) {
lastUiUpdateRef.current = now;
syncProgress(idxRef.current, clamped, wrapped);
}
},
[images, syncProgress],
);
const handleAnimatedEnded = useCallback(
(imageId: string): void => {
const currentImage = images[idxRef.current];
if (!currentImage || currentImage.id !== imageId) return;
advanceSegment(idxRef.current);
},
[advanceSegment, images],
);
const goToIndex = useCallback(
(index: number, segmentProgress = 0): void => {
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): void => {
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(() => {
if (!imageKey) return;
idxRef.current = 0;
setIdx(0);
segStartRef.current = null;
segProgressRef.current = 0;
totalProgressRef.current = 0;
setSegProgress(0);
setProgress(0);
setDurationOverrides({});
setReadyMediaMap({});
setFailedAnimatedMap({});
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(() => {
const el = audioRef.current;
return () => {
releaseMediaElement(el);
};
}, [audioRef]);
// 静态图自动切页(仅在当前静态图已加载完成后开始计时;动图由 <video> 自身进度与 onEnded 驱动)
const currentImage = images[idx];
const isCurrentAnimated =
Boolean(currentImage?.animated) &&
!failedAnimatedMap[currentImage?.id ?? ""];
const isCurrentStaticReady = currentImage
? Boolean(readyMediaMap[currentImage.id])
: false;
useEffect(() => {
if (!images?.length || !segmentDurations.length || totalDurationMs <= 0) {
return;
}
// 当前页为正常动图时,交由 ImageCarousel 内 <video> 的真实播放进度和 ended 事件推进
if (isCurrentAnimated) {
return;
}
// 静态图尚未加载完成或处于暂停态时,冻结分段进度,避免未加载完就偷跑进度裁切展示时长
if (!isPlaying || !isCurrentStaticReady) {
return;
}
const now = performance.now();
const currentSegmentDuration = segmentDurations[idx] ?? fallbackDuration;
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
lastUiUpdateRef.current = now;
const tick = (): void => {
if (!images?.length) return;
const ts = performance.now();
const start = segStartRef.current ?? ts;
const activeIdx = idxRef.current;
const duration = segmentDurations[activeIdx] ?? fallbackDuration;
const elapsed = ts - start;
if (elapsed >= duration) {
advanceSegment(activeIdx);
return;
}
const localSeg = Math.max(0, Math.min(1, elapsed / duration));
if (ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS) {
lastUiUpdateRef.current = ts;
syncProgress(activeIdx, localSeg, false);
}
};
timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS);
return () => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [
advanceSegment,
fallbackDuration,
idx,
images,
isCurrentAnimated,
isCurrentStaticReady,
isPlaying,
segmentDurations,
syncProgress,
totalDurationMs,
]);
return {
idx,
setIdx,
segProgress,
segProgressRef,
segmentDurations,
totalDurationMs,
mediaSyncToken,
segStartRef,
idxRef,
failedAnimatedMap,
setSegmentDuration,
markMediaReady,
markAnimatedFailed,
handleAnimatedProgress,
handleAnimatedEnded,
goToIndex,
seekTo,
};
}