diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index 553c790..f46afd3 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -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({ a: null, b: null }); const [contentTags, setContentTags] = useState( 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({ )} + {/* 长按 2x 快进状态指示 */} + {isFastForwarding && ( +
+
+ + 2.0x 快进中 +
+
+ )} + {/* 暂停图标 */} {!playerState.isPlaying && (
@@ -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} diff --git a/app/aweme/[awemeId]/components/ImageCarousel.tsx b/app/aweme/[awemeId]/components/ImageCarousel.tsx index 8348c9c..3ad4b65 100644 --- a/app/aweme/[awemeId]/components/ImageCarousel.tsx +++ b/app/aweme/[awemeId]/components/ImageCarousel.tsx @@ -14,6 +14,7 @@ interface ImageCarouselProps { images: ImageData["images"]; currentIndex: number; isPlaying: boolean; + playbackRate?: number; segmentProgressRef: RefObject; mediaSyncToken: number; loopMode: LoopMode; @@ -36,6 +37,7 @@ export const ImageCarousel = memo( images, currentIndex, isPlaying, + playbackRate = 1, segmentProgressRef, mediaSyncToken, loopMode, @@ -58,6 +60,8 @@ export const ImageCarousel = memo( >(new Map()); const activeVideoIdRef = useRef(null); const lastMediaSyncTokenRef = useRef(mediaSyncToken); + const playbackRateRef = useRef(playbackRate); + playbackRateRef.current = playbackRate; const pendingSeekRef = useRef<{ imageId: string; progress: number; @@ -154,6 +158,7 @@ export const ImageCarousel = memo( if (!videoEl) return; videoEl.loop = shouldLoopCurrentVideo; + videoEl.playbackRate = playbackRate; if (activeVideoIdRef.current !== currentImage.id) { activeVideoIdRef.current = currentImage.id; @@ -196,6 +201,7 @@ export const ImageCarousel = memo( failedAnimatedMap, images, isPlaying, + playbackRate, seekVideoToProgress, segmentProgressRef, shouldLoopCurrentVideo, @@ -293,6 +299,7 @@ export const ImageCarousel = memo( const callback = (el: HTMLVideoElement | null): void => { if (el) { + el.playbackRate = playbackRateRef.current; videoRefs.current.set(imageId, el); if (Number.isFinite(el.duration) && el.duration > 0) { onAnimatedDuration(imageId, Math.round(el.duration * 1000)); @@ -377,6 +384,7 @@ export const ImageCarousel = memo( ...mediaStyle, }} onLoadedMetadata={(e) => { + e.currentTarget.playbackRate = playbackRateRef.current; const duration = e.currentTarget.duration; if (Number.isFinite(duration) && duration > 0) { onAnimatedDuration( diff --git a/app/aweme/[awemeId]/components/MediaControls.tsx b/app/aweme/[awemeId]/components/MediaControls.tsx index 339d683..05f8c0a 100644 --- a/app/aweme/[awemeId]/components/MediaControls.tsx +++ b/app/aweme/[awemeId]/components/MediaControls.tsx @@ -17,7 +17,7 @@ import { } from "lucide-react"; import Link from "next/link"; 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 { MoreMenu, MoreMenuItem } from "./MoreMenu"; import { ProgressBar } from "./ProgressBar"; @@ -38,6 +38,8 @@ interface MediaControlsProps { desc: string; // 视频专用 videoRef?: RefObject; + abLoop?: AbLoop; + onClearAbLoop?: () => void; // 图文专用 currentIndex?: number; totalSegments?: number; @@ -76,6 +78,8 @@ export const MediaControls = memo(function MediaControls({ createdAt, desc, videoRef, + abLoop, + onClearAbLoop, currentIndex = 0, totalSegments = 0, segmentProgress = 0, @@ -182,7 +186,12 @@ export const MediaControls = memo(function MediaControls({ onSeek={onSeek} /> ) : ( - + )} {/* 控制按钮行 - 响应式布局 */} @@ -228,6 +237,27 @@ export const MediaControls = memo(function MediaControls({ {rate}x )} + + {/* AB 循环状态提示与快捷清除 */} + {isVideo && abLoop && (abLoop.a !== null || abLoop.b !== null) && ( + + )}
{/* 中间:音量控制 - 中等屏幕以上显示 */} diff --git a/app/aweme/[awemeId]/components/ProgressBar.tsx b/app/aweme/[awemeId]/components/ProgressBar.tsx index 2dc38e4..96db89e 100644 --- a/app/aweme/[awemeId]/components/ProgressBar.tsx +++ b/app/aweme/[awemeId]/components/ProgressBar.tsx @@ -1,13 +1,18 @@ import { memo } from "react"; +import type { AbLoop } from "../types.ts"; interface ProgressBarProps { progress: number; onSeek: (ratio: number) => void; + abLoop?: AbLoop; + duration?: number; } export const ProgressBar = memo(function ProgressBar({ progress, onSeek, + abLoop, + duration = 0, }: ProgressBarProps) { const seekFromClientX = (clientX: number, element: HTMLElement): void => { const rect = element.getBoundingClientRect(); @@ -15,9 +20,21 @@ export const ProgressBar = memo(function ProgressBar({ 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 (
{ seekFromClientX(e.clientX, e.currentTarget as HTMLElement); }} @@ -41,10 +58,63 @@ export const ProgressBar = memo(function ProgressBar({ } }} > -
+ {/* 轨道主体 */} +
+ {/* AB 循环区间底色高亮 */} + {hasAbLoop && ( +
+ )} + + {/* 播放进度填充 */} +
+ + {/* AB 循环区间顶部与底部亮边 */} + {hasAbLoop && ( +
+ )} +
+ + {/* A / B 端点指示标记 */} + {hasAbLoop && ( + <> +
+ + A + +
+
+ +
+ + B + +
+
+ + )}
); }); diff --git a/app/aweme/[awemeId]/hooks/useImageCarousel.ts b/app/aweme/[awemeId]/hooks/useImageCarousel.ts index c94deb5..080c648 100644 --- a/app/aweme/[awemeId]/hooks/useImageCarousel.ts +++ b/app/aweme/[awemeId]/hooks/useImageCarousel.ts @@ -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, diff --git a/app/aweme/[awemeId]/hooks/useNavigation.ts b/app/aweme/[awemeId]/hooks/useNavigation.ts index 4552367..3760e1a 100644 --- a/app/aweme/[awemeId]/hooks/useNavigation.ts +++ b/app/aweme/[awemeId]/hooks/useNavigation.ts @@ -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(0); + const rightPressKeyRef = useRef(null); + const longPressActiveRef = useRef(false); + const longPressTimerRef = useRef(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 diff --git a/app/aweme/[awemeId]/hooks/useVideoPlayer.ts b/app/aweme/[awemeId]/hooks/useVideoPlayer.ts index 3aa9544..631ae7d 100644 --- a/app/aweme/[awemeId]/hooks/useVideoPlayer.ts +++ b/app/aweme/[awemeId]/hooks/useVideoPlayer.ts @@ -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(null); const lastProgressRef = useRef(0); + const abLoopRef = useRef(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; diff --git a/app/aweme/[awemeId]/types.ts b/app/aweme/[awemeId]/types.ts index 29c1a4f..5bf4d7a 100644 --- a/app/aweme/[awemeId]/types.ts +++ b/app/aweme/[awemeId]/types.ts @@ -65,3 +65,8 @@ export type Neighbors = { export type LoopMode = "loop" | "single" | "sequential"; export type ObjectFit = "contain" | "cover"; + +export type AbLoop = { + a: number | null; + b: number | null; +};