feat: 支持长按右键/D键二倍速播放与视频AB循环
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user