- 播放上下文:作品链接带上来源(搜索、标签、作者、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>
286 lines
8.2 KiB
TypeScript
286 lines
8.2 KiB
TypeScript
import { useRouter } from "next/navigation";
|
|
import type { RefObject } from "react";
|
|
import { useEffect, useRef } from "react";
|
|
import type { AbLoop, LoopMode, Neighbors } from "../types.ts";
|
|
import { cleanupExpiredProgressStorage, releaseMediaElement } from "../utils";
|
|
|
|
interface UseVideoPlayerProps {
|
|
enabled?: boolean;
|
|
awemeId: string;
|
|
videoRef: RefObject<HTMLVideoElement | null>;
|
|
volume: number;
|
|
rate: number;
|
|
loopMode: LoopMode;
|
|
abLoop?: AbLoop;
|
|
neighbors: Neighbors;
|
|
progressRestored: boolean;
|
|
setIsPlaying: (playing: boolean) => void;
|
|
setProgress: (progress: number) => void;
|
|
setProgressRestored: (restored: boolean) => void;
|
|
}
|
|
|
|
export function useVideoPlayer({
|
|
enabled = true,
|
|
awemeId,
|
|
videoRef,
|
|
volume,
|
|
rate,
|
|
loopMode,
|
|
abLoop,
|
|
neighbors,
|
|
progressRestored,
|
|
setIsPlaying,
|
|
setProgress,
|
|
setProgressRestored,
|
|
}: UseVideoPlayerProps): void {
|
|
const router = useRouter();
|
|
const restoredAwemeIdRef = useRef<string | null>(null);
|
|
const lastProgressRef = useRef(0);
|
|
const abLoopRef = useRef<AbLoop | undefined>(abLoop);
|
|
abLoopRef.current = abLoop;
|
|
|
|
// 挂载时清理过期的播放进度缓存
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
cleanupExpiredProgressStorage();
|
|
}, [enabled]);
|
|
|
|
// 恢复播放进度
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
if (progressRestored && restoredAwemeIdRef.current === awemeId) return;
|
|
const v = videoRef.current;
|
|
if (!v) return;
|
|
|
|
const onLoadedMetadata = (): void => {
|
|
if (restoredAwemeIdRef.current === awemeId) return;
|
|
restoredAwemeIdRef.current = awemeId;
|
|
|
|
try {
|
|
const key = `aweme_progress_${awemeId}`;
|
|
const saved = localStorage.getItem(key);
|
|
if (!saved) {
|
|
setProgressRestored(true);
|
|
return;
|
|
}
|
|
|
|
const parsed = JSON.parse(saved) as {
|
|
time?: unknown;
|
|
timestamp?: unknown;
|
|
};
|
|
const time = typeof parsed.time === "number" ? parsed.time : 0;
|
|
const timestamp =
|
|
typeof parsed.timestamp === "number" ? parsed.timestamp : 0;
|
|
const now = Date.now();
|
|
const fiveMinutes = 5 * 60 * 1000;
|
|
|
|
if (
|
|
now - timestamp < fiveMinutes &&
|
|
time > 1 &&
|
|
time < v.duration - 1
|
|
) {
|
|
v.currentTime = time;
|
|
} else if (now - timestamp >= fiveMinutes) {
|
|
localStorage.removeItem(key);
|
|
}
|
|
} catch (e) {
|
|
console.error("恢复播放进度失败", e);
|
|
}
|
|
|
|
setProgressRestored(true);
|
|
};
|
|
|
|
if (v.readyState >= 1) {
|
|
onLoadedMetadata();
|
|
return;
|
|
}
|
|
|
|
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
|
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
|
}, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]);
|
|
|
|
// 保存播放进度并在卸载/切页时主动释放媒体解码缓冲区
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
const v = videoRef.current;
|
|
if (!v) return;
|
|
|
|
const saveProgress = (): void => {
|
|
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
|
|
|
|
try {
|
|
const key = `aweme_progress_${awemeId}`;
|
|
const value = JSON.stringify({
|
|
time: v.currentTime,
|
|
timestamp: Date.now(),
|
|
});
|
|
localStorage.setItem(key, value);
|
|
} catch (e) {
|
|
console.error("保存播放进度失败", e);
|
|
}
|
|
};
|
|
|
|
const interval = window.setInterval(saveProgress, 2000);
|
|
const onBeforeUnload = (): void => saveProgress();
|
|
window.addEventListener("beforeunload", onBeforeUnload);
|
|
|
|
return () => {
|
|
window.clearInterval(interval);
|
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
saveProgress();
|
|
releaseMediaElement(v);
|
|
};
|
|
}, [awemeId, enabled, videoRef]);
|
|
|
|
// 监听播放状态和进度(含 AB 循环区间控制)
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
const v = videoRef.current;
|
|
if (!v) return;
|
|
|
|
const syncVideoProgress = (): void => {
|
|
if (!v.duration || Number.isNaN(v.duration)) return;
|
|
const currentAb = abLoopRef.current;
|
|
const hasAb = Boolean(
|
|
currentAb && (currentAb.a !== null || currentAb.b !== null),
|
|
);
|
|
|
|
if (hasAb && currentAb) {
|
|
const startTime = Math.max(0, currentAb.a ?? 0);
|
|
const endTime =
|
|
currentAb.b !== null ? Math.min(v.duration, currentAb.b) : v.duration;
|
|
|
|
if (
|
|
endTime > startTime &&
|
|
(v.currentTime >= endTime ||
|
|
(currentAb.b === null && v.currentTime >= v.duration - 0.03))
|
|
) {
|
|
v.currentTime = startTime;
|
|
if (v.paused) {
|
|
void v.play().catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
const nextProgress = Math.max(0, Math.min(1, v.currentTime / v.duration));
|
|
if (Math.abs(nextProgress - lastProgressRef.current) >= 0.001) {
|
|
lastProgressRef.current = nextProgress;
|
|
setProgress(nextProgress);
|
|
}
|
|
};
|
|
|
|
const onPlay = (): void => setIsPlaying(true);
|
|
const onPause = (): void => setIsPlaying(false);
|
|
const onEnded = (): void => {
|
|
const currentAb = abLoopRef.current;
|
|
if (currentAb && (currentAb.a !== null || currentAb.b !== null)) {
|
|
const startTime = Math.max(0, currentAb.a ?? 0);
|
|
v.currentTime = startTime;
|
|
void v.play().catch(() => {});
|
|
return;
|
|
}
|
|
|
|
if (loopMode === "sequential" && neighbors?.next) {
|
|
router.push(neighbors.next.href);
|
|
}
|
|
};
|
|
|
|
v.addEventListener("timeupdate", syncVideoProgress);
|
|
v.addEventListener("loadedmetadata", syncVideoProgress);
|
|
v.addEventListener("play", onPlay);
|
|
v.addEventListener("pause", onPause);
|
|
v.addEventListener("ended", onEnded);
|
|
|
|
return () => {
|
|
v.removeEventListener("timeupdate", syncVideoProgress);
|
|
v.removeEventListener("loadedmetadata", syncVideoProgress);
|
|
v.removeEventListener("play", onPlay);
|
|
v.removeEventListener("pause", onPause);
|
|
v.removeEventListener("ended", onEnded);
|
|
};
|
|
}, [
|
|
enabled,
|
|
loopMode,
|
|
neighbors?.next,
|
|
router,
|
|
videoRef,
|
|
setIsPlaying,
|
|
setProgress,
|
|
]);
|
|
|
|
// 当启用 AB 循环时,使用 rAF 精确检测终点 B 并无缝跳回起点 A
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
if (!abLoop || (abLoop.a === null && abLoop.b === null)) return;
|
|
|
|
let rafId: number | null = null;
|
|
const checkAbLoop = (): void => {
|
|
const v = videoRef.current;
|
|
if (v && !v.paused && Number.isFinite(v.duration) && v.duration > 0) {
|
|
const startTime = Math.max(0, abLoop.a ?? 0);
|
|
const endTime =
|
|
abLoop.b !== null ? Math.min(v.duration, abLoop.b) : v.duration;
|
|
|
|
if (
|
|
endTime > startTime &&
|
|
(v.currentTime >= endTime ||
|
|
(abLoop.b === null && v.currentTime >= v.duration - 0.03))
|
|
) {
|
|
v.currentTime = startTime;
|
|
const nextProgress = Math.max(0, Math.min(1, startTime / v.duration));
|
|
lastProgressRef.current = nextProgress;
|
|
setProgress(nextProgress);
|
|
}
|
|
}
|
|
rafId = window.requestAnimationFrame(checkAbLoop);
|
|
};
|
|
|
|
rafId = window.requestAnimationFrame(checkAbLoop);
|
|
return () => {
|
|
if (rafId !== null) {
|
|
window.cancelAnimationFrame(rafId);
|
|
}
|
|
};
|
|
}, [abLoop, enabled, setProgress, videoRef]);
|
|
|
|
// 自动播放检测
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
const v = videoRef.current;
|
|
if (!v) return;
|
|
|
|
const checkAutoplay = async (): Promise<void> => {
|
|
try {
|
|
await v.play();
|
|
setIsPlaying(true);
|
|
} catch {
|
|
setIsPlaying(false);
|
|
}
|
|
};
|
|
|
|
if (v.readyState >= 1) {
|
|
void checkAutoplay();
|
|
return;
|
|
}
|
|
|
|
const onLoaded = (): void => {
|
|
void checkAutoplay();
|
|
};
|
|
v.addEventListener("loadedmetadata", onLoaded, { once: true });
|
|
return () => v.removeEventListener("loadedmetadata", onLoaded);
|
|
}, [awemeId, enabled, videoRef, setIsPlaying]);
|
|
|
|
// 更新音量和倍速
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
const v = videoRef.current;
|
|
if (v) v.volume = volume;
|
|
}, [enabled, volume, videoRef]);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
const v = videoRef.current;
|
|
if (v) v.playbackRate = rate;
|
|
}, [enabled, rate, videoRef]);
|
|
}
|