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; 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 >({}); const [mediaSyncToken, setMediaSyncToken] = useState(0); const segStartRef = useRef(null); const idxRef = useRef(0); const timerRef = useRef(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 = {}; 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, }; }