121 lines
3.9 KiB
TypeScript
121 lines
3.9 KiB
TypeScript
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();
|
|
if (rect.width <= 0) return;
|
|
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 cursor-pointer touch-none select-none"
|
|
onClick={(e) => {
|
|
seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
|
|
}}
|
|
onPointerDown={(e) => {
|
|
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
seekFromClientX(e.clientX, e.currentTarget);
|
|
}}
|
|
onPointerMove={(e) => {
|
|
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
|
|
seekFromClientX(e.clientX, e.currentTarget);
|
|
}}
|
|
onPointerUp={(e) => {
|
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
}
|
|
}}
|
|
onPointerCancel={(e) => {
|
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
}
|
|
}}
|
|
>
|
|
{/* 轨道主体 */}
|
|
<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>
|
|
);
|
|
});
|