Files
douyin-archive/app/aweme/[awemeId]/hooks/useNavigation.ts
T
feie9454andClaude Opus 5.5 b84fcec77b feat: 播放页按来源列表切换,新增作者/搜索/hashtag 侧栏
- 播放上下文:作品链接带上来源(搜索、标签、作者、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>
2026-11-09 00:50:09 +08:00

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]);
}