feat: 支持长按右键/D键二倍速播放与视频AB循环
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
{/* 中间:音量控制 - 中等屏幕以上显示 */}
|
{/* 中间:音量控制 - 中等屏幕以上显示 */}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user