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; setProgress: (progress: number) => void; /** 单张图片显示时长(毫秒),默认 5000ms */ segmentMs?: number; } export interface UseImageCarouselReturn { idx: number; setIdx: (idx: number) => void; segProgress: number; segProgressRef: RefObject; segmentDurations: number[]; totalDurationMs: number; mediaSyncToken: number; segStartRef: RefObject; idxRef: RefObject; failedAnimatedMap: Record; 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 >({}); const [readyMediaMap, setReadyMediaMap] = useState>( {}, ); const [failedAnimatedMap, setFailedAnimatedMap] = useState< Record >({}); const [mediaSyncToken, setMediaSyncToken] = useState(0); const imagesRef = useRef(images); imagesRef.current = images; 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 prevImageKeyRef = useRef(null); 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 => { const currentImages = imagesRef.current; if (!currentImages.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 >= currentImages.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 (currentImages.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); }, [loopMode, neighbors?.next, router, syncProgress], ); const handleAnimatedProgress = useCallback( (imageId: string, progress: number): void => { const currentImage = imagesRef.current[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); } }, [syncProgress], ); const handleAnimatedEnded = useCallback( (imageId: string): void => { const currentImage = imagesRef.current[idxRef.current]; if (!currentImage || currentImage.id !== imageId) return; advanceSegment(idxRef.current); }, [advanceSegment], ); const goToIndex = useCallback( (index: number, segmentProgress = 0): void => { const count = imagesRef.current.length; if (!count) return; const nextIndex = Math.max(0, Math.min(count - 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, segmentDurations, syncProgress], ); const seekTo = useCallback( (ratio: number): void => { if (!imagesRef.current.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, segmentDurations, totalDurationMs], ); useEffect(() => { idxRef.current = idx; }, [idx]); // 仅当真正切换到另一个作品(imageKey 变化)时重置状态,避免首次挂载覆盖子组件已上报的 ready 状态 useEffect(() => { if (prevImageKeyRef.current === null) { prevImageKeyRef.current = imageKey; return; } if (prevImageKeyRef.current === imageKey) return; prevImageKeyRef.current = imageKey; 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]); // 静态图自动切页(仅在当前静态图已加载完成后开始计时;动图由