- 播放上下文:作品链接带上来源(搜索、标签、作者、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>
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import { useRouter } from "next/navigation";
|
|
import type { RefObject } from "react";
|
|
import { useEffect, useRef } from "react";
|
|
import type { Neighbors } from "../types.ts";
|
|
|
|
const LONG_PRESS_DELAY_MS = 220;
|
|
|
|
interface UseNavigationProps {
|
|
neighbors: Neighbors;
|
|
isVideo: boolean;
|
|
mediaContainerRef: RefObject<HTMLDivElement | null>;
|
|
videoRef?: RefObject<HTMLVideoElement | null>;
|
|
prevImg?: () => void;
|
|
nextImg?: () => void;
|
|
togglePlay: () => void;
|
|
onFastForwardChange?: (active: boolean) => void;
|
|
onSetAbStart?: () => void;
|
|
onSetAbEnd?: () => void;
|
|
onClearAbLoop?: () => void;
|
|
}
|
|
|
|
export function useNavigation({
|
|
neighbors,
|
|
isVideo,
|
|
mediaContainerRef,
|
|
videoRef,
|
|
prevImg,
|
|
nextImg,
|
|
togglePlay,
|
|
onFastForwardChange,
|
|
onSetAbStart,
|
|
onSetAbEnd,
|
|
onClearAbLoop,
|
|
}: UseNavigationProps): void {
|
|
const router = useRouter();
|
|
const wheelCooldownRef = useRef<number>(0);
|
|
const rightPressKeyRef = useRef<string | null>(null);
|
|
const longPressActiveRef = useRef<boolean>(false);
|
|
const longPressTimerRef = useRef<number | null>(null);
|
|
|
|
const onFastForwardChangeRef = useRef(onFastForwardChange);
|
|
onFastForwardChangeRef.current = onFastForwardChange;
|
|
const onSetAbStartRef = useRef(onSetAbStart);
|
|
onSetAbStartRef.current = onSetAbStart;
|
|
const onSetAbEndRef = useRef(onSetAbEnd);
|
|
onSetAbEndRef.current = onSetAbEnd;
|
|
const onClearAbLoopRef = useRef(onClearAbLoop);
|
|
onClearAbLoopRef.current = onClearAbLoop;
|
|
|
|
// 预取路由
|
|
useEffect(() => {
|
|
if (!neighbors) return;
|
|
if (neighbors.next) router.prefetch(neighbors.next.href);
|
|
if (neighbors.prev) router.prefetch(neighbors.prev.href);
|
|
}, [neighbors, router]);
|
|
|
|
// 鼠标滚轮切换
|
|
useEffect(() => {
|
|
const el = mediaContainerRef.current;
|
|
if (!el) return;
|
|
|
|
const onWheel = (e: WheelEvent): void => {
|
|
if (e.ctrlKey) return;
|
|
const now = performance.now();
|
|
if (now - wheelCooldownRef.current < 700) return;
|
|
const dy = e.deltaY;
|
|
if (Math.abs(dy) < 40) return;
|
|
|
|
if ((dy > 0 && neighbors?.next) || (dy < 0 && neighbors?.prev)) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
if (dy > 0 && neighbors?.next) {
|
|
wheelCooldownRef.current = now;
|
|
router.push(neighbors.next.href);
|
|
} else if (dy < 0 && neighbors?.prev) {
|
|
wheelCooldownRef.current = now;
|
|
router.push(neighbors.prev.href);
|
|
}
|
|
};
|
|
|
|
el.addEventListener("wheel", onWheel, { passive: false });
|
|
return () => el.removeEventListener("wheel", onWheel);
|
|
}, [neighbors, mediaContainerRef, router]);
|
|
|
|
// 触屏上下滑切换(上滑下一条,下滑上一条)。只认明显的竖向手势,
|
|
// 不影响图文的左右滑动;从按钮、链接、控制栏(data-no-swipe)上开始的触摸不处理。
|
|
useEffect(() => {
|
|
const el = mediaContainerRef.current;
|
|
if (!el) return;
|
|
|
|
let start: { x: number; y: number; t: number } | null = null;
|
|
let vertical = false;
|
|
|
|
const onTouchStart = (e: TouchEvent): void => {
|
|
const target = e.target as HTMLElement | null;
|
|
if (
|
|
e.touches.length !== 1 ||
|
|
target?.closest(
|
|
"button, a, input, textarea, select, [role='slider'], [data-no-swipe]",
|
|
)
|
|
) {
|
|
start = null;
|
|
return;
|
|
}
|
|
const touch = e.touches[0];
|
|
start = { x: touch.clientX, y: touch.clientY, t: performance.now() };
|
|
vertical = false;
|
|
};
|
|
|
|
const onTouchMove = (e: TouchEvent): void => {
|
|
if (!start) return;
|
|
if (e.touches.length !== 1) {
|
|
start = null;
|
|
return;
|
|
}
|
|
const touch = e.touches[0];
|
|
const dx = touch.clientX - start.x;
|
|
const dy = touch.clientY - start.y;
|
|
if (!vertical && Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx) * 1.5) {
|
|
vertical = true;
|
|
}
|
|
// 竖向手势由我们处理,阻止浏览器下拉刷新 / 回弹
|
|
if (vertical && e.cancelable) e.preventDefault();
|
|
};
|
|
|
|
const onTouchEnd = (e: TouchEvent): void => {
|
|
if (!start) return;
|
|
const touch = e.changedTouches[0];
|
|
const dy = touch.clientY - start.y;
|
|
const elapsed = performance.now() - start.t;
|
|
const wasVertical = vertical;
|
|
start = null;
|
|
vertical = false;
|
|
if (!wasVertical) return;
|
|
|
|
// 快速轻扫,或者慢慢拖过屏幕高度的四分之一
|
|
const flick = Math.abs(dy) > 60 && elapsed < 800;
|
|
const longDrag = Math.abs(dy) > window.innerHeight * 0.25;
|
|
if (!flick && !longDrag) return;
|
|
|
|
const now = performance.now();
|
|
if (now - wheelCooldownRef.current < 500) return;
|
|
if (dy < 0 && neighbors?.next) {
|
|
wheelCooldownRef.current = now;
|
|
router.push(neighbors.next.href);
|
|
} else if (dy > 0 && neighbors?.prev) {
|
|
wheelCooldownRef.current = now;
|
|
router.push(neighbors.prev.href);
|
|
}
|
|
};
|
|
|
|
const onTouchCancel = (): void => {
|
|
start = null;
|
|
vertical = false;
|
|
};
|
|
|
|
el.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
el.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
el.addEventListener("touchend", onTouchEnd);
|
|
el.addEventListener("touchcancel", onTouchCancel);
|
|
return () => {
|
|
el.removeEventListener("touchstart", onTouchStart);
|
|
el.removeEventListener("touchmove", onTouchMove);
|
|
el.removeEventListener("touchend", onTouchEnd);
|
|
el.removeEventListener("touchcancel", onTouchCancel);
|
|
};
|
|
}, [neighbors, mediaContainerRef, router]);
|
|
|
|
// 键盘快捷键(支持向右箭头 / D 键区分短按与长按 2 倍速,以及视频 [ ] 设置 AB 循环)
|
|
useEffect(() => {
|
|
const clearLongPressTimer = (): void => {
|
|
if (longPressTimerRef.current !== null) {
|
|
window.clearTimeout(longPressTimerRef.current);
|
|
longPressTimerRef.current = null;
|
|
}
|
|
};
|
|
|
|
const resetRightKeyPress = (): void => {
|
|
clearLongPressTimer();
|
|
const wasLongPress = longPressActiveRef.current;
|
|
rightPressKeyRef.current = null;
|
|
longPressActiveRef.current = false;
|
|
if (wasLongPress) {
|
|
onFastForwardChangeRef.current?.(false);
|
|
}
|
|
};
|
|
|
|
const onKeyDown = (e: KeyboardEvent): void => {
|
|
if (e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
|
|
|
|
const target = e.target as HTMLElement | null;
|
|
if (
|
|
target &&
|
|
(target.tagName === "INPUT" ||
|
|
target.tagName === "TEXTAREA" ||
|
|
target.isContentEditable)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const key = e.key.toLowerCase();
|
|
const code = e.code;
|
|
|
|
if (key === "arrowup" || key === "w" || code === "KeyW") {
|
|
e.preventDefault();
|
|
const now = performance.now();
|
|
if (now - wheelCooldownRef.current < 700) return;
|
|
if (neighbors?.prev) {
|
|
wheelCooldownRef.current = now;
|
|
router.push(neighbors.prev.href);
|
|
}
|
|
} else if (key === "arrowdown" || key === "s" || code === "KeyS") {
|
|
e.preventDefault();
|
|
const now = performance.now();
|
|
if (now - wheelCooldownRef.current < 700) return;
|
|
if (neighbors?.next) {
|
|
wheelCooldownRef.current = now;
|
|
router.push(neighbors.next.href);
|
|
}
|
|
} else if (key === "arrowleft" || key === "a" || code === "KeyA") {
|
|
e.preventDefault();
|
|
if (isVideo) {
|
|
const v = videoRef?.current;
|
|
if (v && v.duration) {
|
|
v.currentTime = Math.max(0, v.currentTime - 5);
|
|
}
|
|
} else {
|
|
prevImg?.();
|
|
}
|
|
} else if (key === "arrowright" || key === "d" || code === "KeyD") {
|
|
e.preventDefault();
|
|
const normalizedKey = code === "KeyD" ? "d" : key;
|
|
// 忽略系统长按重复触发的 keydown,交由定时器统一判定长按阈值
|
|
if (e.repeat || rightPressKeyRef.current !== null) return;
|
|
|
|
rightPressKeyRef.current = normalizedKey;
|
|
longPressActiveRef.current = false;
|
|
clearLongPressTimer();
|
|
|
|
longPressTimerRef.current = window.setTimeout(() => {
|
|
longPressTimerRef.current = null;
|
|
if (
|
|
rightPressKeyRef.current !== null &&
|
|
!longPressActiveRef.current
|
|
) {
|
|
longPressActiveRef.current = true;
|
|
onFastForwardChangeRef.current?.(true);
|
|
}
|
|
}, LONG_PRESS_DELAY_MS);
|
|
} else if (
|
|
isVideo &&
|
|
(code === "BracketLeft" || key === "[" || key === "【" || key === "「")
|
|
) {
|
|
if (e.repeat) return;
|
|
e.preventDefault();
|
|
onSetAbStartRef.current?.();
|
|
} else if (
|
|
isVideo &&
|
|
(code === "BracketRight" || key === "]" || key === "】" || key === "」")
|
|
) {
|
|
if (e.repeat) return;
|
|
e.preventDefault();
|
|
onSetAbEndRef.current?.();
|
|
} else if (
|
|
isVideo &&
|
|
(code === "Backslash" || key === "\\" || key === "、")
|
|
) {
|
|
if (e.repeat) return;
|
|
e.preventDefault();
|
|
onClearAbLoopRef.current?.();
|
|
} else if (key === " " || code === "Space") {
|
|
if (e.repeat) return;
|
|
e.preventDefault();
|
|
togglePlay();
|
|
}
|
|
};
|
|
|
|
const onKeyUp = (e: KeyboardEvent): void => {
|
|
const key = e.key.toLowerCase();
|
|
const code = e.code;
|
|
const normalizedKey = code === "KeyD" ? "d" : key;
|
|
if (normalizedKey !== "arrowright" && normalizedKey !== "d") return;
|
|
if (rightPressKeyRef.current !== normalizedKey) return;
|
|
|
|
e.preventDefault();
|
|
clearLongPressTimer();
|
|
const wasLongPress = longPressActiveRef.current;
|
|
rightPressKeyRef.current = null;
|
|
longPressActiveRef.current = false;
|
|
|
|
if (wasLongPress) {
|
|
onFastForwardChangeRef.current?.(false);
|
|
return;
|
|
}
|
|
|
|
// 短按(单击):视频快进 5 秒,图文切换下一张图片
|
|
if (isVideo) {
|
|
const v = videoRef?.current;
|
|
if (v && v.duration) {
|
|
v.currentTime = Math.min(v.duration, v.currentTime + 5);
|
|
}
|
|
} else {
|
|
nextImg?.();
|
|
}
|
|
};
|
|
|
|
const handleVisibilityChange = (): void => {
|
|
if (document.hidden) resetRightKeyPress();
|
|
};
|
|
|
|
window.addEventListener("keydown", onKeyDown);
|
|
window.addEventListener("keyup", onKeyUp);
|
|
window.addEventListener("blur", resetRightKeyPress);
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
|
return () => {
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
window.removeEventListener("keyup", onKeyUp);
|
|
window.removeEventListener("blur", resetRightKeyPress);
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
};
|
|
}, [isVideo, neighbors, router, videoRef, prevImg, nextImg, togglePlay]);
|
|
|
|
// 组件卸载时确保清理长按定时器并恢复倍速
|
|
useEffect(() => {
|
|
return () => {
|
|
if (longPressTimerRef.current !== null) {
|
|
window.clearTimeout(longPressTimerRef.current);
|
|
longPressTimerRef.current = null;
|
|
}
|
|
if (longPressActiveRef.current) {
|
|
longPressActiveRef.current = false;
|
|
rightPressKeyRef.current = null;
|
|
onFastForwardChangeRef.current?.(false);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// 仅在无历史记录的独立新标签页首次进入时注入一次返回兜底,避免连续刷视频时无限叠加 history 栈
|
|
useEffect(() => {
|
|
const currentState = window.history.state as
|
|
| { interceptBack?: boolean }
|
|
| null
|
|
| undefined;
|
|
if (window.history.length <= 1 && !currentState?.interceptBack) {
|
|
window.history.pushState({ ...currentState, interceptBack: true }, "");
|
|
}
|
|
|
|
const handlePopState = (): void => {
|
|
if (window.history.length <= 1) {
|
|
window.close();
|
|
setTimeout(() => {
|
|
if (!document.hidden) {
|
|
router.push("/");
|
|
}
|
|
}, 100);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("popstate", handlePopState);
|
|
return () => {
|
|
window.removeEventListener("popstate", handlePopState);
|
|
};
|
|
}, [router]);
|
|
}
|