feat: 支持长按右键/D键二倍速播放与视频AB循环

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-04 02:00:05 +08:00
co-authored by Cursor
parent bcf1e6e349
commit 11d9d35002
8 changed files with 431 additions and 37 deletions
+145 -14
View File
@@ -3,6 +3,8 @@ 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;
@@ -11,6 +13,10 @@ interface UseNavigationProps {
prevImg?: () => void;
nextImg?: () => void;
togglePlay: () => void;
onFastForwardChange?: (active: boolean) => void;
onSetAbStart?: () => void;
onSetAbEnd?: () => void;
onClearAbLoop?: () => void;
}
export function useNavigation({
@@ -21,9 +27,25 @@ export function useNavigation({
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(() => {
@@ -61,9 +83,28 @@ export function useNavigation({
return () => el.removeEventListener("wheel", onWheel);
}, [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 &&
@@ -75,8 +116,9 @@ export function useNavigation({
}
const key = e.key.toLowerCase();
const code = e.code;
if (key === "arrowup" || key === "w") {
if (key === "arrowup" || key === "w" || code === "KeyW") {
e.preventDefault();
const now = performance.now();
if (now - wheelCooldownRef.current < 700) return;
@@ -84,7 +126,7 @@ export function useNavigation({
wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.prev.aweme_id}`);
}
} else if (key === "arrowdown" || key === "s") {
} else if (key === "arrowdown" || key === "s" || code === "KeyS") {
e.preventDefault();
const now = performance.now();
if (now - wheelCooldownRef.current < 700) return;
@@ -92,7 +134,7 @@ export function useNavigation({
wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.next.aweme_id}`);
}
} else if (key === "arrowleft" || key === "a") {
} else if (key === "arrowleft" || key === "a" || code === "KeyA") {
e.preventDefault();
if (isVideo) {
const v = videoRef?.current;
@@ -102,26 +144,115 @@ export function useNavigation({
} else {
prevImg?.();
}
} else if (key === "arrowright" || key === "d") {
} else if (key === "arrowright" || key === "d" || code === "KeyD") {
e.preventDefault();
if (isVideo) {
const v = videoRef?.current;
if (v && v.duration) {
v.currentTime = Math.min(v.duration, v.currentTime + 5);
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);
}
} else {
nextImg?.();
}
} else if (key === " ") {
}, 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);
return () => window.removeEventListener("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