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";
import { Play } from "lucide-react";
import { FastForward, Play } from "lucide-react";
import { useRouter } from "next/navigation";
import {
type ReactElement,
@@ -26,6 +26,7 @@ import { useNavigation } from "./hooks/useNavigation";
import { usePlayerState } from "./hooks/usePlayerState";
import { useVideoPlayer } from "./hooks/useVideoPlayer";
import type {
AbLoop,
AwemeData,
ImageData,
Neighbors,
@@ -67,14 +68,22 @@ export default function AwemeDetailClient({
const playerState = usePlayerState();
const commentState = useCommentState();
const [transcriptOpen, setTranscriptOpen] = useState(false);
const [isFastForwarding, setIsFastForwarding] = useState(false);
const [abLoop, setAbLoop] = useState<AbLoop>({ a: null, b: null });
const [contentTags, setContentTags] = useState<string[]>(
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(() => {
setContentTags(isVideo ? (data as VideoData).content_tags : []);
setTranscriptOpen(false);
setIsFastForwarding(false);
setAbLoop({ a: null, b: null });
playerState.setProgress(0);
playerState.setProgressRestored(false);
playerState.setRotation(0);
@@ -120,6 +129,7 @@ export default function AwemeDetailClient({
const imageCarouselState = useImageCarousel({
images,
isPlaying: playerState.isPlaying,
playbackRate: effectiveImageRate,
loopMode: playerState.loopMode,
neighbors,
volume: playerState.volume,
@@ -134,8 +144,9 @@ export default function AwemeDetailClient({
awemeId: data.aweme_id,
videoRef,
volume: playerState.volume,
rate: playerState.rate,
rate: effectiveVideoRate,
loopMode: playerState.loopMode,
abLoop,
neighbors,
progressRestored: playerState.progressRestored,
setIsPlaying: playerState.setIsPlaying,
@@ -246,6 +257,47 @@ export default function AwemeDetailClient({
imageCarouselState.goToIndex(next);
}, [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 => {
if (isVideo) {
const videoUrl = (data as VideoData).video_url;
@@ -305,6 +357,10 @@ export default function AwemeDetailClient({
prevImg,
nextImg,
togglePlay,
onFastForwardChange: setIsFastForwarding,
onSetAbStart: handleSetAbStart,
onSetAbEnd: handleSetAbEnd,
onClearAbLoop: handleClearAbLoop,
});
// 背景画布(仅视频且播放时采样绘制)
@@ -383,7 +439,7 @@ export default function AwemeDetailClient({
ms.setPositionState({
duration: Math.max(0, v.duration),
position: Math.max(0, v.currentTime),
playbackRate: playerState.rate ?? 1,
playbackRate: effectiveVideoRate ?? 1,
});
} else if (images?.length) {
const totalMs = imageCarouselState.totalDurationMs;
@@ -392,7 +448,11 @@ export default function AwemeDetailClient({
0,
Math.min(duration, (progressRef.current * totalMs) / 1000),
);
ms.setPositionState({ duration, position, playbackRate: 1 });
ms.setPositionState({
duration,
position,
playbackRate: effectiveImageRate,
});
}
} catch {}
};
@@ -433,11 +493,12 @@ export default function AwemeDetailClient({
return () => window.clearInterval(id);
}
}, [
effectiveImageRate,
effectiveVideoRate,
imageCarouselState.totalDurationMs,
images?.length,
isVideo,
playerState.isPlaying,
playerState.rate,
]);
// 绑定系统媒体控件的操作处理程序(不依赖 progress state,仅在作品切换时注册一次)
@@ -581,7 +642,7 @@ export default function AwemeDetailClient({
videoUrl={(data as VideoData).video_url}
rotation={playerState.rotation}
objectFit={playerState.objectFit}
loop={playerState.loopMode !== "sequential"}
loop={playerState.loopMode !== "sequential" && !hasAbLoop}
onTogglePlay={togglePlay}
/>
) : (
@@ -591,6 +652,7 @@ export default function AwemeDetailClient({
images={images}
currentIndex={imageCarouselState.idx}
isPlaying={playerState.isPlaying}
playbackRate={effectiveImageRate}
segmentProgressRef={imageCarouselState.segProgressRef}
mediaSyncToken={imageCarouselState.mediaSyncToken}
loopMode={playerState.loopMode}
@@ -608,6 +670,16 @@ export default function AwemeDetailClient({
)}
</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 && (
<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}
progress={playerState.progress}
volume={playerState.volume}
rate={playerState.rate}
rate={effectiveVideoRate}
rotation={playerState.rotation}
objectFit={playerState.objectFit}
loopMode={playerState.loopMode}
@@ -650,6 +722,8 @@ export default function AwemeDetailClient({
createdAt={data.created_at}
desc={data.desc}
videoRef={videoRef}
abLoop={isVideo ? abLoop : undefined}
onClearAbLoop={handleClearAbLoop}
currentIndex={imageCarouselState.idx}
totalSegments={images.length}
segmentProgress={imageCarouselState.segProgress}