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
+81 -7
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Play } from "lucide-react"; import { FastForward, Play } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
type ReactElement, type ReactElement,
@@ -26,6 +26,7 @@ import { useNavigation } from "./hooks/useNavigation";
import { usePlayerState } from "./hooks/usePlayerState"; import { usePlayerState } from "./hooks/usePlayerState";
import { useVideoPlayer } from "./hooks/useVideoPlayer"; import { useVideoPlayer } from "./hooks/useVideoPlayer";
import type { import type {
AbLoop,
AwemeData, AwemeData,
ImageData, ImageData,
Neighbors, Neighbors,
@@ -67,14 +68,22 @@ export default function AwemeDetailClient({
const playerState = usePlayerState(); const playerState = usePlayerState();
const commentState = useCommentState(); const commentState = useCommentState();
const [transcriptOpen, setTranscriptOpen] = useState(false); const [transcriptOpen, setTranscriptOpen] = useState(false);
const [isFastForwarding, setIsFastForwarding] = useState(false);
const [abLoop, setAbLoop] = useState<AbLoop>({ a: null, b: null });
const [contentTags, setContentTags] = useState<string[]>( const [contentTags, setContentTags] = useState<string[]>(
isVideo ? (data as VideoData).content_tags : [], isVideo ? (data as VideoData).content_tags : [],
); );
const effectiveVideoRate = isFastForwarding ? 2 : playerState.rate;
const effectiveImageRate = isFastForwarding ? 2 : 1;
const hasAbLoop = isVideo && (abLoop.a !== null || abLoop.b !== null);
// 同步跨作品状态重置 // 同步跨作品状态重置
useEffect(() => { useEffect(() => {
setContentTags(isVideo ? (data as VideoData).content_tags : []); setContentTags(isVideo ? (data as VideoData).content_tags : []);
setTranscriptOpen(false); setTranscriptOpen(false);
setIsFastForwarding(false);
setAbLoop({ a: null, b: null });
playerState.setProgress(0); playerState.setProgress(0);
playerState.setProgressRestored(false); playerState.setProgressRestored(false);
playerState.setRotation(0); playerState.setRotation(0);
@@ -120,6 +129,7 @@ export default function AwemeDetailClient({
const imageCarouselState = useImageCarousel({ const imageCarouselState = useImageCarousel({
images, images,
isPlaying: playerState.isPlaying, isPlaying: playerState.isPlaying,
playbackRate: effectiveImageRate,
loopMode: playerState.loopMode, loopMode: playerState.loopMode,
neighbors, neighbors,
volume: playerState.volume, volume: playerState.volume,
@@ -134,8 +144,9 @@ export default function AwemeDetailClient({
awemeId: data.aweme_id, awemeId: data.aweme_id,
videoRef, videoRef,
volume: playerState.volume, volume: playerState.volume,
rate: playerState.rate, rate: effectiveVideoRate,
loopMode: playerState.loopMode, loopMode: playerState.loopMode,
abLoop,
neighbors, neighbors,
progressRestored: playerState.progressRestored, progressRestored: playerState.progressRestored,
setIsPlaying: playerState.setIsPlaying, setIsPlaying: playerState.setIsPlaying,
@@ -246,6 +257,47 @@ export default function AwemeDetailClient({
imageCarouselState.goToIndex(next); imageCarouselState.goToIndex(next);
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]); }, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
const handleSetAbStart = useCallback((): void => {
if (!isVideo) return;
const v = videoRef.current;
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
const currentTime = Math.max(0, Math.min(v.duration, v.currentTime));
setAbLoop((prev) => {
if (prev.a !== null && Math.abs(prev.a - currentTime) < 0.2) {
return { a: null, b: prev.b };
}
const nextB =
prev.b !== null && currentTime >= prev.b - 0.1 ? null : prev.b;
return { a: currentTime, b: nextB };
});
}, [isVideo]);
const handleSetAbEnd = useCallback((): void => {
if (!isVideo) return;
const v = videoRef.current;
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
const currentTime = Math.max(0, Math.min(v.duration, v.currentTime));
setAbLoop((prev) => {
if (prev.b !== null && Math.abs(prev.b - currentTime) < 0.2) {
return { a: prev.a, b: null };
}
const nextA =
prev.a !== null && currentTime <= prev.a + 0.1 ? null : prev.a;
const startTime = Math.max(0, nextA ?? 0);
v.currentTime = startTime;
if (v.paused) {
void v.play().catch(() => {});
}
return { a: nextA, b: currentTime };
});
}, [isVideo]);
const handleClearAbLoop = useCallback((): void => {
setAbLoop({ a: null, b: null });
}, []);
const handleDownload = useCallback((): void => { const handleDownload = useCallback((): void => {
if (isVideo) { if (isVideo) {
const videoUrl = (data as VideoData).video_url; const videoUrl = (data as VideoData).video_url;
@@ -305,6 +357,10 @@ export default function AwemeDetailClient({
prevImg, prevImg,
nextImg, nextImg,
togglePlay, togglePlay,
onFastForwardChange: setIsFastForwarding,
onSetAbStart: handleSetAbStart,
onSetAbEnd: handleSetAbEnd,
onClearAbLoop: handleClearAbLoop,
}); });
// 背景画布(仅视频且播放时采样绘制) // 背景画布(仅视频且播放时采样绘制)
@@ -383,7 +439,7 @@ export default function AwemeDetailClient({
ms.setPositionState({ ms.setPositionState({
duration: Math.max(0, v.duration), duration: Math.max(0, v.duration),
position: Math.max(0, v.currentTime), position: Math.max(0, v.currentTime),
playbackRate: playerState.rate ?? 1, playbackRate: effectiveVideoRate ?? 1,
}); });
} else if (images?.length) { } else if (images?.length) {
const totalMs = imageCarouselState.totalDurationMs; const totalMs = imageCarouselState.totalDurationMs;
@@ -392,7 +448,11 @@ export default function AwemeDetailClient({
0, 0,
Math.min(duration, (progressRef.current * totalMs) / 1000), Math.min(duration, (progressRef.current * totalMs) / 1000),
); );
ms.setPositionState({ duration, position, playbackRate: 1 }); ms.setPositionState({
duration,
position,
playbackRate: effectiveImageRate,
});
} }
} catch {} } catch {}
}; };
@@ -433,11 +493,12 @@ export default function AwemeDetailClient({
return () => window.clearInterval(id); return () => window.clearInterval(id);
} }
}, [ }, [
effectiveImageRate,
effectiveVideoRate,
imageCarouselState.totalDurationMs, imageCarouselState.totalDurationMs,
images?.length, images?.length,
isVideo, isVideo,
playerState.isPlaying, playerState.isPlaying,
playerState.rate,
]); ]);
// 绑定系统媒体控件的操作处理程序(不依赖 progress state,仅在作品切换时注册一次) // 绑定系统媒体控件的操作处理程序(不依赖 progress state,仅在作品切换时注册一次)
@@ -581,7 +642,7 @@ export default function AwemeDetailClient({
videoUrl={(data as VideoData).video_url} videoUrl={(data as VideoData).video_url}
rotation={playerState.rotation} rotation={playerState.rotation}
objectFit={playerState.objectFit} objectFit={playerState.objectFit}
loop={playerState.loopMode !== "sequential"} loop={playerState.loopMode !== "sequential" && !hasAbLoop}
onTogglePlay={togglePlay} onTogglePlay={togglePlay}
/> />
) : ( ) : (
@@ -591,6 +652,7 @@ export default function AwemeDetailClient({
images={images} images={images}
currentIndex={imageCarouselState.idx} currentIndex={imageCarouselState.idx}
isPlaying={playerState.isPlaying} isPlaying={playerState.isPlaying}
playbackRate={effectiveImageRate}
segmentProgressRef={imageCarouselState.segProgressRef} segmentProgressRef={imageCarouselState.segProgressRef}
mediaSyncToken={imageCarouselState.mediaSyncToken} mediaSyncToken={imageCarouselState.mediaSyncToken}
loopMode={playerState.loopMode} loopMode={playerState.loopMode}
@@ -608,6 +670,16 @@ export default function AwemeDetailClient({
)} )}
</div> </div>
{/* 长按 2x 快进状态指示 */}
{isFastForwarding && (
<div className="pointer-events-none absolute top-4 left-1/2 -translate-x-1/2 z-40">
<div className="inline-flex items-center gap-1.5 rounded-full bg-black/65 border border-white/20 px-3.5 py-1.5 text-xs font-medium text-white shadow-lg">
<FastForward size={14} className="text-amber-300" />
<span>2.0x 快进中</span>
</div>
</div>
)}
{/* 暂停图标 */} {/* 暂停图标 */}
{!playerState.isPlaying && ( {!playerState.isPlaying && (
<div className="pointer-events-none absolute inset-0 z-30 grid place-items-center"> <div className="pointer-events-none absolute inset-0 z-30 grid place-items-center">
@@ -641,7 +713,7 @@ export default function AwemeDetailClient({
isPlaying={playerState.isPlaying} isPlaying={playerState.isPlaying}
progress={playerState.progress} progress={playerState.progress}
volume={playerState.volume} volume={playerState.volume}
rate={playerState.rate} rate={effectiveVideoRate}
rotation={playerState.rotation} rotation={playerState.rotation}
objectFit={playerState.objectFit} objectFit={playerState.objectFit}
loopMode={playerState.loopMode} loopMode={playerState.loopMode}
@@ -650,6 +722,8 @@ export default function AwemeDetailClient({
createdAt={data.created_at} createdAt={data.created_at}
desc={data.desc} desc={data.desc}
videoRef={videoRef} videoRef={videoRef}
abLoop={isVideo ? abLoop : undefined}
onClearAbLoop={handleClearAbLoop}
currentIndex={imageCarouselState.idx} currentIndex={imageCarouselState.idx}
totalSegments={images.length} totalSegments={images.length}
segmentProgress={imageCarouselState.segProgress} segmentProgress={imageCarouselState.segProgress}
@@ -14,6 +14,7 @@ interface ImageCarouselProps {
images: ImageData["images"]; images: ImageData["images"];
currentIndex: number; currentIndex: number;
isPlaying: boolean; isPlaying: boolean;
playbackRate?: number;
segmentProgressRef: RefObject<number>; segmentProgressRef: RefObject<number>;
mediaSyncToken: number; mediaSyncToken: number;
loopMode: LoopMode; loopMode: LoopMode;
@@ -36,6 +37,7 @@ export const ImageCarousel = memo(
images, images,
currentIndex, currentIndex,
isPlaying, isPlaying,
playbackRate = 1,
segmentProgressRef, segmentProgressRef,
mediaSyncToken, mediaSyncToken,
loopMode, loopMode,
@@ -58,6 +60,8 @@ export const ImageCarousel = memo(
>(new Map()); >(new Map());
const activeVideoIdRef = useRef<string | null>(null); const activeVideoIdRef = useRef<string | null>(null);
const lastMediaSyncTokenRef = useRef(mediaSyncToken); const lastMediaSyncTokenRef = useRef(mediaSyncToken);
const playbackRateRef = useRef(playbackRate);
playbackRateRef.current = playbackRate;
const pendingSeekRef = useRef<{ const pendingSeekRef = useRef<{
imageId: string; imageId: string;
progress: number; progress: number;
@@ -154,6 +158,7 @@ export const ImageCarousel = memo(
if (!videoEl) return; if (!videoEl) return;
videoEl.loop = shouldLoopCurrentVideo; videoEl.loop = shouldLoopCurrentVideo;
videoEl.playbackRate = playbackRate;
if (activeVideoIdRef.current !== currentImage.id) { if (activeVideoIdRef.current !== currentImage.id) {
activeVideoIdRef.current = currentImage.id; activeVideoIdRef.current = currentImage.id;
@@ -196,6 +201,7 @@ export const ImageCarousel = memo(
failedAnimatedMap, failedAnimatedMap,
images, images,
isPlaying, isPlaying,
playbackRate,
seekVideoToProgress, seekVideoToProgress,
segmentProgressRef, segmentProgressRef,
shouldLoopCurrentVideo, shouldLoopCurrentVideo,
@@ -293,6 +299,7 @@ export const ImageCarousel = memo(
const callback = (el: HTMLVideoElement | null): void => { const callback = (el: HTMLVideoElement | null): void => {
if (el) { if (el) {
el.playbackRate = playbackRateRef.current;
videoRefs.current.set(imageId, el); videoRefs.current.set(imageId, el);
if (Number.isFinite(el.duration) && el.duration > 0) { if (Number.isFinite(el.duration) && el.duration > 0) {
onAnimatedDuration(imageId, Math.round(el.duration * 1000)); onAnimatedDuration(imageId, Math.round(el.duration * 1000));
@@ -377,6 +384,7 @@ export const ImageCarousel = memo(
...mediaStyle, ...mediaStyle,
}} }}
onLoadedMetadata={(e) => { onLoadedMetadata={(e) => {
e.currentTarget.playbackRate = playbackRateRef.current;
const duration = e.currentTarget.duration; const duration = e.currentTarget.duration;
if (Number.isFinite(duration) && duration > 0) { if (Number.isFinite(duration) && duration > 0) {
onAnimatedDuration( onAnimatedDuration(
@@ -17,7 +17,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { memo, type ReactNode, type RefObject } from "react"; import { memo, type ReactNode, type RefObject } from "react";
import type { LoopMode, ObjectFit, User } from "../types.ts"; import type { AbLoop, LoopMode, ObjectFit, User } from "../types.ts";
import { formatAbsoluteUTC, formatRelativeTime, formatTime } from "../utils"; import { formatAbsoluteUTC, formatRelativeTime, formatTime } from "../utils";
import { MoreMenu, MoreMenuItem } from "./MoreMenu"; import { MoreMenu, MoreMenuItem } from "./MoreMenu";
import { ProgressBar } from "./ProgressBar"; import { ProgressBar } from "./ProgressBar";
@@ -38,6 +38,8 @@ interface MediaControlsProps {
desc: string; desc: string;
// 视频专用 // 视频专用
videoRef?: RefObject<HTMLVideoElement | null>; videoRef?: RefObject<HTMLVideoElement | null>;
abLoop?: AbLoop;
onClearAbLoop?: () => void;
// 图文专用 // 图文专用
currentIndex?: number; currentIndex?: number;
totalSegments?: number; totalSegments?: number;
@@ -76,6 +78,8 @@ export const MediaControls = memo(function MediaControls({
createdAt, createdAt,
desc, desc,
videoRef, videoRef,
abLoop,
onClearAbLoop,
currentIndex = 0, currentIndex = 0,
totalSegments = 0, totalSegments = 0,
segmentProgress = 0, segmentProgress = 0,
@@ -182,7 +186,12 @@ export const MediaControls = memo(function MediaControls({
onSeek={onSeek} onSeek={onSeek}
/> />
) : ( ) : (
<ProgressBar progress={progress} onSeek={onSeek} /> <ProgressBar
progress={progress}
onSeek={onSeek}
abLoop={isVideo ? abLoop : undefined}
duration={isVideo ? (videoRef?.current?.duration ?? 0) : 0}
/>
)} )}
{/* 控制按钮行 - 响应式布局 */} {/* 控制按钮行 - 响应式布局 */}
@@ -228,6 +237,27 @@ export const MediaControls = memo(function MediaControls({
{rate}x {rate}x
</button> </button>
)} )}
{/* AB 循环状态提示与快捷清除 */}
{isVideo && abLoop && (abLoop.a !== null || abLoop.b !== null) && (
<button
type="button"
onClick={onClearAbLoop}
className="h-[30px] px-2.5 rounded-full text-[11px] font-mono bg-amber-400/20 text-amber-300 border border-amber-400/50 cursor-pointer shrink-0 hover:bg-amber-400/30 transition-colors inline-flex items-center gap-1"
title="点击清除 AB 循环 (快捷键 \)"
aria-label="清除 AB 循环"
>
<span>
AB {formatTime(abLoop.a ?? 0)}-
{abLoop.b !== null
? formatTime(abLoop.b)
: videoRef?.current?.duration
? formatTime(videoRef.current.duration)
: "末"}
</span>
<span className="text-amber-200/80 text-xs leading-none">×</span>
</button>
)}
</div> </div>
{/* 中间:音量控制 - 中等屏幕以上显示 */} {/* 中间:音量控制 - 中等屏幕以上显示 */}
+75 -5
View File
@@ -1,13 +1,18 @@
import { memo } from "react"; import { memo } from "react";
import type { AbLoop } from "../types.ts";
interface ProgressBarProps { interface ProgressBarProps {
progress: number; progress: number;
onSeek: (ratio: number) => void; onSeek: (ratio: number) => void;
abLoop?: AbLoop;
duration?: number;
} }
export const ProgressBar = memo(function ProgressBar({ export const ProgressBar = memo(function ProgressBar({
progress, progress,
onSeek, onSeek,
abLoop,
duration = 0,
}: ProgressBarProps) { }: ProgressBarProps) {
const seekFromClientX = (clientX: number, element: HTMLElement): void => { const seekFromClientX = (clientX: number, element: HTMLElement): void => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -15,9 +20,21 @@ export const ProgressBar = memo(function ProgressBar({
onSeek((clientX - rect.left) / rect.width); onSeek((clientX - rect.left) / rect.width);
}; };
const hasAbLoop = Boolean(
abLoop && (abLoop.a !== null || abLoop.b !== null) && duration > 0,
);
const startRatio =
hasAbLoop && abLoop
? Math.max(0, Math.min(1, (abLoop.a ?? 0) / duration))
: 0;
const endRatio =
hasAbLoop && abLoop
? Math.max(startRatio, Math.min(1, (abLoop.b ?? duration) / duration))
: 1;
return ( return (
<div <div
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer touch-none" className="relative h-1.5 cursor-pointer touch-none select-none"
onClick={(e) => { onClick={(e) => {
seekFromClientX(e.clientX, e.currentTarget as HTMLElement); seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
}} }}
@@ -41,10 +58,63 @@ export const ProgressBar = memo(function ProgressBar({
} }
}} }}
> >
<div {/* 轨道主体 */}
className="origin-left h-full bg-white will-change-transform" <div className="relative h-full w-full rounded-full bg-white/25 overflow-hidden">
style={{ transform: `scaleX(${progress || 0})` }} {/* AB 循环区间底色高亮 */}
/> {hasAbLoop && (
<div
className="absolute top-0 bottom-0 bg-amber-400/40"
style={{
left: `${startRatio * 100}%`,
width: `${Math.max(0, endRatio - startRatio) * 100}%`,
}}
/>
)}
{/* 播放进度填充 */}
<div
className={`origin-left h-full will-change-transform ${
hasAbLoop ? "bg-amber-300" : "bg-white"
}`}
style={{ transform: `scaleX(${progress || 0})` }}
/>
{/* AB 循环区间顶部与底部亮边 */}
{hasAbLoop && (
<div
className="pointer-events-none absolute top-0 bottom-0 border-y border-amber-300/90 bg-amber-300/20"
style={{
left: `${startRatio * 100}%`,
width: `${Math.max(0, endRatio - startRatio) * 100}%`,
}}
/>
)}
</div>
{/* A / B 端点指示标记 */}
{hasAbLoop && (
<>
<div
className="pointer-events-none absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex flex-col items-center"
style={{ left: `${startRatio * 100}%` }}
>
<span className="mb-2.5 rounded bg-amber-400 px-1 py-[1px] text-[9px] font-bold leading-none text-black shadow">
A
</span>
<div className="h-3 w-[3px] rounded-full bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.9)]" />
</div>
<div
className="pointer-events-none absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex flex-col items-center"
style={{ left: `${endRatio * 100}%` }}
>
<span className="mb-2.5 rounded bg-amber-400 px-1 py-[1px] text-[9px] font-bold leading-none text-black shadow">
B
</span>
<div className="h-3 w-[3px] rounded-full bg-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.9)]" />
</div>
</>
)}
</div> </div>
); );
}); });
@@ -31,6 +31,7 @@ function sumDurations(durations: number[], end: number): number {
interface UseImageCarouselProps { interface UseImageCarouselProps {
images: ImageData["images"]; images: ImageData["images"];
isPlaying: boolean; isPlaying: boolean;
playbackRate?: number;
loopMode: LoopMode; loopMode: LoopMode;
neighbors: Neighbors; neighbors: Neighbors;
volume: number; volume: number;
@@ -63,6 +64,7 @@ export interface UseImageCarouselReturn {
export function useImageCarousel({ export function useImageCarousel({
images, images,
isPlaying, isPlaying,
playbackRate = 1,
loopMode, loopMode,
neighbors, neighbors,
volume, volume,
@@ -375,8 +377,10 @@ export function useImageCarousel({
return; return;
} }
const safeRate = playbackRate > 0 ? playbackRate : 1;
const now = performance.now(); const now = performance.now();
const currentSegmentDuration = segmentDurations[idx] ?? fallbackDuration; const currentSegmentDuration =
(segmentDurations[idx] ?? fallbackDuration) / safeRate;
segStartRef.current = now - segProgressRef.current * currentSegmentDuration; segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
const tick = (): void => { const tick = (): void => {
@@ -386,7 +390,8 @@ export function useImageCarousel({
const ts = performance.now(); const ts = performance.now();
const start = segStartRef.current ?? ts; const start = segStartRef.current ?? ts;
const activeIdx = idxRef.current; const activeIdx = idxRef.current;
const duration = segmentDurations[activeIdx] ?? fallbackDuration; const duration =
(segmentDurations[activeIdx] ?? fallbackDuration) / safeRate;
const elapsed = ts - start; const elapsed = ts - start;
if (elapsed >= duration) { if (elapsed >= duration) {
@@ -416,6 +421,7 @@ export function useImageCarousel({
isCurrentAnimated, isCurrentAnimated,
isCurrentStaticReady, isCurrentStaticReady,
isPlaying, isPlaying,
playbackRate,
segmentDurations, segmentDurations,
syncProgress, syncProgress,
totalDurationMs, totalDurationMs,
+145 -14
View File
@@ -3,6 +3,8 @@ import type { RefObject } from "react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import type { Neighbors } from "../types.ts"; import type { Neighbors } from "../types.ts";
const LONG_PRESS_DELAY_MS = 220;
interface UseNavigationProps { interface UseNavigationProps {
neighbors: Neighbors; neighbors: Neighbors;
isVideo: boolean; isVideo: boolean;
@@ -11,6 +13,10 @@ interface UseNavigationProps {
prevImg?: () => void; prevImg?: () => void;
nextImg?: () => void; nextImg?: () => void;
togglePlay: () => void; togglePlay: () => void;
onFastForwardChange?: (active: boolean) => void;
onSetAbStart?: () => void;
onSetAbEnd?: () => void;
onClearAbLoop?: () => void;
} }
export function useNavigation({ export function useNavigation({
@@ -21,9 +27,25 @@ export function useNavigation({
prevImg, prevImg,
nextImg, nextImg,
togglePlay, togglePlay,
onFastForwardChange,
onSetAbStart,
onSetAbEnd,
onClearAbLoop,
}: UseNavigationProps): void { }: UseNavigationProps): void {
const router = useRouter(); const router = useRouter();
const wheelCooldownRef = useRef<number>(0); 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(() => { useEffect(() => {
@@ -61,9 +83,28 @@ export function useNavigation({
return () => el.removeEventListener("wheel", onWheel); return () => el.removeEventListener("wheel", onWheel);
}, [neighbors, mediaContainerRef, router]); }, [neighbors, mediaContainerRef, router]);
// 键盘快捷键 // 键盘快捷键(支持向右箭头 / D 键区分短按与长按 2 倍速,以及视频 [ ] 设置 AB 循环)
useEffect(() => { 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 => { const onKeyDown = (e: KeyboardEvent): void => {
if (e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if ( if (
target && target &&
@@ -75,8 +116,9 @@ export function useNavigation({
} }
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
const code = e.code;
if (key === "arrowup" || key === "w") { if (key === "arrowup" || key === "w" || code === "KeyW") {
e.preventDefault(); e.preventDefault();
const now = performance.now(); const now = performance.now();
if (now - wheelCooldownRef.current < 700) return; if (now - wheelCooldownRef.current < 700) return;
@@ -84,7 +126,7 @@ export function useNavigation({
wheelCooldownRef.current = now; wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.prev.aweme_id}`); router.push(`/aweme/${neighbors.prev.aweme_id}`);
} }
} else if (key === "arrowdown" || key === "s") { } else if (key === "arrowdown" || key === "s" || code === "KeyS") {
e.preventDefault(); e.preventDefault();
const now = performance.now(); const now = performance.now();
if (now - wheelCooldownRef.current < 700) return; if (now - wheelCooldownRef.current < 700) return;
@@ -92,7 +134,7 @@ export function useNavigation({
wheelCooldownRef.current = now; wheelCooldownRef.current = now;
router.push(`/aweme/${neighbors.next.aweme_id}`); router.push(`/aweme/${neighbors.next.aweme_id}`);
} }
} else if (key === "arrowleft" || key === "a") { } else if (key === "arrowleft" || key === "a" || code === "KeyA") {
e.preventDefault(); e.preventDefault();
if (isVideo) { if (isVideo) {
const v = videoRef?.current; const v = videoRef?.current;
@@ -102,26 +144,115 @@ export function useNavigation({
} else { } else {
prevImg?.(); prevImg?.();
} }
} else if (key === "arrowright" || key === "d") { } else if (key === "arrowright" || key === "d" || code === "KeyD") {
e.preventDefault(); e.preventDefault();
if (isVideo) { const normalizedKey = code === "KeyD" ? "d" : key;
const v = videoRef?.current; // 忽略系统长按重复触发的 keydown,交由定时器统一判定长按阈值
if (v && v.duration) { if (e.repeat || rightPressKeyRef.current !== null) return;
v.currentTime = Math.min(v.duration, v.currentTime + 5);
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 { }, LONG_PRESS_DELAY_MS);
nextImg?.(); } else if (
} isVideo &&
} else if (key === " ") { (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(); e.preventDefault();
togglePlay(); 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("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]); }, [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 栈 // 仅在无历史记录的独立新标签页首次进入时注入一次返回兜底,避免连续刷视频时无限叠加 history 栈
useEffect(() => { useEffect(() => {
const currentState = window.history.state as const currentState = window.history.state as
+77 -7
View File
@@ -1,7 +1,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { useEffect, useRef } 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"; import { cleanupExpiredProgressStorage, releaseMediaElement } from "../utils";
interface UseVideoPlayerProps { interface UseVideoPlayerProps {
@@ -11,6 +11,7 @@ interface UseVideoPlayerProps {
volume: number; volume: number;
rate: number; rate: number;
loopMode: LoopMode; loopMode: LoopMode;
abLoop?: AbLoop;
neighbors: Neighbors; neighbors: Neighbors;
progressRestored: boolean; progressRestored: boolean;
setIsPlaying: (playing: boolean) => void; setIsPlaying: (playing: boolean) => void;
@@ -25,6 +26,7 @@ export function useVideoPlayer({
volume, volume,
rate, rate,
loopMode, loopMode,
abLoop,
neighbors, neighbors,
progressRestored, progressRestored,
setIsPlaying, setIsPlaying,
@@ -34,6 +36,8 @@ export function useVideoPlayer({
const router = useRouter(); const router = useRouter();
const restoredAwemeIdRef = useRef<string | null>(null); const restoredAwemeIdRef = useRef<string | null>(null);
const lastProgressRef = useRef(0); const lastProgressRef = useRef(0);
const abLoopRef = useRef<AbLoop | undefined>(abLoop);
abLoopRef.current = abLoop;
// 挂载时清理过期的播放进度缓存 // 挂载时清理过期的播放进度缓存
useEffect(() => { useEffect(() => {
@@ -128,37 +132,68 @@ export function useVideoPlayer({
}; };
}, [awemeId, enabled, videoRef]); }, [awemeId, enabled, videoRef]);
// 监听播放状态和进度 // 监听播放状态和进度(含 AB 循环区间控制)
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
const onTime = (): void => { const syncVideoProgress = (): void => {
if (!v.duration || Number.isNaN(v.duration)) return; 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)); const nextProgress = Math.max(0, Math.min(1, v.currentTime / v.duration));
if (Math.abs(nextProgress - lastProgressRef.current) >= 0.001) { if (Math.abs(nextProgress - lastProgressRef.current) >= 0.001) {
lastProgressRef.current = nextProgress; lastProgressRef.current = nextProgress;
setProgress(nextProgress); setProgress(nextProgress);
} }
}; };
const onPlay = (): void => setIsPlaying(true); const onPlay = (): void => setIsPlaying(true);
const onPause = (): void => setIsPlaying(false); const onPause = (): void => setIsPlaying(false);
const onEnded = (): void => { 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) { if (loopMode === "sequential" && neighbors?.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`); router.push(`/aweme/${neighbors.next.aweme_id}`);
} }
}; };
v.addEventListener("timeupdate", onTime); v.addEventListener("timeupdate", syncVideoProgress);
v.addEventListener("loadedmetadata", onTime); v.addEventListener("loadedmetadata", syncVideoProgress);
v.addEventListener("play", onPlay); v.addEventListener("play", onPlay);
v.addEventListener("pause", onPause); v.addEventListener("pause", onPause);
v.addEventListener("ended", onEnded); v.addEventListener("ended", onEnded);
return () => { return () => {
v.removeEventListener("timeupdate", onTime); v.removeEventListener("timeupdate", syncVideoProgress);
v.removeEventListener("loadedmetadata", onTime); v.removeEventListener("loadedmetadata", syncVideoProgress);
v.removeEventListener("play", onPlay); v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause); v.removeEventListener("pause", onPause);
v.removeEventListener("ended", onEnded); v.removeEventListener("ended", onEnded);
@@ -173,6 +208,41 @@ export function useVideoPlayer({
setProgress, 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(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
+5
View File
@@ -65,3 +65,8 @@ export type Neighbors = {
export type LoopMode = "loop" | "single" | "sequential"; export type LoopMode = "loop" | "single" | "sequential";
export type ObjectFit = "contain" | "cover"; export type ObjectFit = "contain" | "cover";
export type AbLoop = {
a: number | null;
b: number | null;
};