- 播放上下文:作品链接带上来源(搜索、标签、作者、hashtag),上一条/下一条 在该列表内移动;当前作品不在列表中时退回全部作品的发布时间顺序 - 顶部提示标签显示当前列表与位置,点击打开侧栏列表,✕ 退出 - 侧栏改为「评论 | TA的作品 | 搜索结果/#hashtag」标签页,作品封面网格可就地切换 - 作者模式:打开「TA的作品」进入,关闭侧栏不退出,点提示 ✕ 后回到原来的列表 - 从搜索结果进入或点 hashtag 时自动打开对应列表 - 描述中的 hashtag 可点击,按完整 hashtag 精确匹配(#丰川祥子 不含 #丰川祥子cos) - 新增 /api/playlist;搜索 SQL 抽到 lib/search-query.ts 与播放页共用,保证顺序一致 - 移动端:底部抽屉、点作品后收起,媒体区支持上下滑切换 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
450 lines
13 KiB
TypeScript
450 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;
|
|
playbackRate?: number;
|
|
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,
|
|
playbackRate = 1,
|
|
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 imagesRef = useRef(images);
|
|
imagesRef.current = images;
|
|
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 prevImageKeyRef = useRef<string | null>(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(neighbors.next.href);
|
|
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]);
|
|
|
|
// 静态图自动切页(仅在当前静态图已加载完成后开始计时;动图由 <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 safeRate = playbackRate > 0 ? playbackRate : 1;
|
|
const now = performance.now();
|
|
const currentSegmentDuration =
|
|
(segmentDurations[idx] ?? fallbackDuration) / safeRate;
|
|
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
|
|
|
|
const tick = (): void => {
|
|
const currentImages = imagesRef.current;
|
|
if (!currentImages.length) return;
|
|
|
|
const ts = performance.now();
|
|
const start = segStartRef.current ?? ts;
|
|
const activeIdx = idxRef.current;
|
|
const duration =
|
|
(segmentDurations[activeIdx] ?? fallbackDuration) / safeRate;
|
|
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.length,
|
|
isCurrentAnimated,
|
|
isCurrentStaticReady,
|
|
isPlaying,
|
|
playbackRate,
|
|
segmentDurations,
|
|
syncProgress,
|
|
totalDurationMs,
|
|
]);
|
|
|
|
return {
|
|
idx,
|
|
setIdx,
|
|
segProgress,
|
|
segProgressRef,
|
|
segmentDurations,
|
|
totalDurationMs,
|
|
mediaSyncToken,
|
|
segStartRef,
|
|
idxRef,
|
|
failedAnimatedMap,
|
|
setSegmentDuration,
|
|
markMediaReady,
|
|
markAnimatedFailed,
|
|
handleAnimatedProgress,
|
|
handleAnimatedEnded,
|
|
goToIndex,
|
|
seekTo,
|
|
};
|
|
}
|