122 lines
3.7 KiB
TypeScript
122 lines
3.7 KiB
TypeScript
import { useRef, useCallback, useState, useEffect } from "react";
|
|
|
|
interface TimelineProps {
|
|
duration: number;
|
|
currentTime: number;
|
|
trimStart: number;
|
|
trimEnd: number;
|
|
onTrimChange: (start: number, end: number) => void;
|
|
onSeek: (time: number) => void;
|
|
}
|
|
|
|
function formatTime(seconds: number): string {
|
|
const m = Math.floor(seconds / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
const ms = Math.floor((seconds % 1) * 10);
|
|
return `${m}:${s.toString().padStart(2, "0")}.${ms}`;
|
|
}
|
|
|
|
export default function Timeline({
|
|
duration,
|
|
currentTime,
|
|
trimStart,
|
|
trimEnd,
|
|
onTrimChange,
|
|
onSeek,
|
|
}: TimelineProps) {
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
const [dragging, setDragging] = useState<"in" | "out" | null>(null);
|
|
|
|
const timeToPercent = (t: number) => (duration > 0 ? (t / duration) * 100 : 0);
|
|
|
|
const positionToTime = useCallback(
|
|
(clientX: number) => {
|
|
const track = trackRef.current;
|
|
if (!track || duration <= 0) return 0;
|
|
const rect = track.getBoundingClientRect();
|
|
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
return ratio * duration;
|
|
},
|
|
[duration],
|
|
);
|
|
|
|
const handleTrackClick = (e: React.MouseEvent) => {
|
|
if (dragging) return;
|
|
onSeek(positionToTime(e.clientX));
|
|
};
|
|
|
|
const handleMouseDown = (handle: "in" | "out") => (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setDragging(handle);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!dragging) return;
|
|
|
|
const minGap = 0.1;
|
|
|
|
const handleMove = (e: MouseEvent) => {
|
|
const time = positionToTime(e.clientX);
|
|
if (dragging === "in") {
|
|
onTrimChange(Math.min(time, trimEnd - minGap), trimEnd);
|
|
} else {
|
|
onTrimChange(trimStart, Math.max(time, trimStart + minGap));
|
|
}
|
|
};
|
|
|
|
const handleUp = () => setDragging(null);
|
|
|
|
document.addEventListener("mousemove", handleMove);
|
|
document.addEventListener("mouseup", handleUp);
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMove);
|
|
document.removeEventListener("mouseup", handleUp);
|
|
};
|
|
}, [dragging, trimStart, trimEnd, positionToTime, onTrimChange]);
|
|
|
|
const inPct = timeToPercent(trimStart);
|
|
const outPct = timeToPercent(trimEnd);
|
|
const playheadPct = timeToPercent(currentTime);
|
|
const selectionDuration = trimEnd - trimStart;
|
|
|
|
return (
|
|
<div className="timeline">
|
|
<div className="timeline-times">
|
|
<span>In: {formatTime(trimStart)}</span>
|
|
<span>Selection: {formatTime(selectionDuration)}</span>
|
|
<span>Out: {formatTime(trimEnd)}</span>
|
|
</div>
|
|
<div className="timeline-track" ref={trackRef} onClick={handleTrackClick}>
|
|
{/* Dimmed regions */}
|
|
<div className="timeline-dim" style={{ left: 0, width: `${inPct}%` }} />
|
|
<div className="timeline-dim" style={{ left: `${outPct}%`, width: `${100 - outPct}%` }} />
|
|
|
|
{/* Selection highlight */}
|
|
<div
|
|
className="timeline-selection"
|
|
style={{ left: `${inPct}%`, width: `${outPct - inPct}%` }}
|
|
/>
|
|
|
|
{/* Playhead */}
|
|
<div className="timeline-playhead" style={{ left: `${playheadPct}%` }} />
|
|
|
|
{/* Handles */}
|
|
<div
|
|
className={`timeline-handle timeline-handle-in ${dragging === "in" ? "dragging" : ""}`}
|
|
style={{ left: `${inPct}%` }}
|
|
onMouseDown={handleMouseDown("in")}
|
|
/>
|
|
<div
|
|
className={`timeline-handle timeline-handle-out ${dragging === "out" ? "dragging" : ""}`}
|
|
style={{ left: `${outPct}%` }}
|
|
onMouseDown={handleMouseDown("out")}
|
|
/>
|
|
</div>
|
|
<div className="timeline-duration">
|
|
<span>0:00</span>
|
|
<span>{formatTime(duration)}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|