feat: 支持长按右键/D键二倍速播放与视频AB循环
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -31,6 +31,7 @@ function sumDurations(durations: number[], end: number): number {
|
||||
interface UseImageCarouselProps {
|
||||
images: ImageData["images"];
|
||||
isPlaying: boolean;
|
||||
playbackRate?: number;
|
||||
loopMode: LoopMode;
|
||||
neighbors: Neighbors;
|
||||
volume: number;
|
||||
@@ -63,6 +64,7 @@ export interface UseImageCarouselReturn {
|
||||
export function useImageCarousel({
|
||||
images,
|
||||
isPlaying,
|
||||
playbackRate = 1,
|
||||
loopMode,
|
||||
neighbors,
|
||||
volume,
|
||||
@@ -375,8 +377,10 @@ export function useImageCarousel({
|
||||
return;
|
||||
}
|
||||
|
||||
const safeRate = playbackRate > 0 ? playbackRate : 1;
|
||||
const now = performance.now();
|
||||
const currentSegmentDuration = segmentDurations[idx] ?? fallbackDuration;
|
||||
const currentSegmentDuration =
|
||||
(segmentDurations[idx] ?? fallbackDuration) / safeRate;
|
||||
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
|
||||
|
||||
const tick = (): void => {
|
||||
@@ -386,7 +390,8 @@ export function useImageCarousel({
|
||||
const ts = performance.now();
|
||||
const start = segStartRef.current ?? ts;
|
||||
const activeIdx = idxRef.current;
|
||||
const duration = segmentDurations[activeIdx] ?? fallbackDuration;
|
||||
const duration =
|
||||
(segmentDurations[activeIdx] ?? fallbackDuration) / safeRate;
|
||||
const elapsed = ts - start;
|
||||
|
||||
if (elapsed >= duration) {
|
||||
@@ -416,6 +421,7 @@ export function useImageCarousel({
|
||||
isCurrentAnimated,
|
||||
isCurrentStaticReady,
|
||||
isPlaying,
|
||||
playbackRate,
|
||||
segmentDurations,
|
||||
syncProgress,
|
||||
totalDurationMs,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { LoopMode, Neighbors } from "../types.ts";
|
||||
import type { AbLoop, LoopMode, Neighbors } from "../types.ts";
|
||||
import { cleanupExpiredProgressStorage, releaseMediaElement } from "../utils";
|
||||
|
||||
interface UseVideoPlayerProps {
|
||||
@@ -11,6 +11,7 @@ interface UseVideoPlayerProps {
|
||||
volume: number;
|
||||
rate: number;
|
||||
loopMode: LoopMode;
|
||||
abLoop?: AbLoop;
|
||||
neighbors: Neighbors;
|
||||
progressRestored: boolean;
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
@@ -25,6 +26,7 @@ export function useVideoPlayer({
|
||||
volume,
|
||||
rate,
|
||||
loopMode,
|
||||
abLoop,
|
||||
neighbors,
|
||||
progressRestored,
|
||||
setIsPlaying,
|
||||
@@ -34,6 +36,8 @@ export function useVideoPlayer({
|
||||
const router = useRouter();
|
||||
const restoredAwemeIdRef = useRef<string | null>(null);
|
||||
const lastProgressRef = useRef(0);
|
||||
const abLoopRef = useRef<AbLoop | undefined>(abLoop);
|
||||
abLoopRef.current = abLoop;
|
||||
|
||||
// 挂载时清理过期的播放进度缓存
|
||||
useEffect(() => {
|
||||
@@ -128,37 +132,68 @@ export function useVideoPlayer({
|
||||
};
|
||||
}, [awemeId, enabled, videoRef]);
|
||||
|
||||
// 监听播放状态和进度
|
||||
// 监听播放状态和进度(含 AB 循环区间控制)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const onTime = (): void => {
|
||||
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(`/aweme/${neighbors.next.aweme_id}`);
|
||||
}
|
||||
};
|
||||
|
||||
v.addEventListener("timeupdate", onTime);
|
||||
v.addEventListener("loadedmetadata", onTime);
|
||||
v.addEventListener("timeupdate", syncVideoProgress);
|
||||
v.addEventListener("loadedmetadata", syncVideoProgress);
|
||||
v.addEventListener("play", onPlay);
|
||||
v.addEventListener("pause", onPause);
|
||||
v.addEventListener("ended", onEnded);
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("timeupdate", onTime);
|
||||
v.removeEventListener("loadedmetadata", onTime);
|
||||
v.removeEventListener("timeupdate", syncVideoProgress);
|
||||
v.removeEventListener("loadedmetadata", syncVideoProgress);
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
@@ -173,6 +208,41 @@ export function useVideoPlayer({
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user