feat: 支持长按右键/D键二倍速播放与视频AB循环
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,6 +14,7 @@ interface ImageCarouselProps {
|
||||
images: ImageData["images"];
|
||||
currentIndex: number;
|
||||
isPlaying: boolean;
|
||||
playbackRate?: number;
|
||||
segmentProgressRef: RefObject<number>;
|
||||
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<string | null>(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(
|
||||
|
||||
@@ -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<HTMLVideoElement | null>;
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<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
|
||||
</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>
|
||||
|
||||
{/* 中间:音量控制 - 中等屏幕以上显示 */}
|
||||
|
||||
@@ -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 (
|
||||
<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) => {
|
||||
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"
|
||||
style={{ transform: `scaleX(${progress || 0})` }}
|
||||
/>
|
||||
{/* 轨道主体 */}
|
||||
<div className="relative h-full w-full rounded-full bg-white/25 overflow-hidden">
|
||||
{/* 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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user