|
|
@@ -15,117 +15,129 @@ interface Props {
|
|
|
|
|
|
export function Timeline({ duration, currentTime, fps, comments, onSeek, onCommentClick }: Props) {
|
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
|
- const draggingRef = useRef(false);
|
|
|
- const rafRef = useRef<number | null>(null);
|
|
|
- const [displayTime, setDisplayTime] = useState(currentTime);
|
|
|
- const [touchActive, setTouchActive] = useState(false);
|
|
|
- const [touchX, setTouchX] = useState(0);
|
|
|
|
|
|
- // Smoothly track displayTime during drag using RAF
|
|
|
+ // ── Drag state ─────────────────────────────────────────────────────────────
|
|
|
+ // draggingRef: controls video seeks during drag (written by handlers, read by effect)
|
|
|
+ const draggingRef = useRef(false);
|
|
|
+ // isDragging: React state — controls visual active state (scrubber scale, tooltip)
|
|
|
+ const [isDragging, setIsDragging] = useState(false);
|
|
|
+ // draggedTime: tracks the visual position of the scrubber during drag, decoupled from
|
|
|
+ // the actual video time so the scrubber never lags behind the cursor
|
|
|
+ const [draggedTime, setDraggedTime] = useState(currentTime);
|
|
|
+ // tooltipX: CSS left position (%) for tooltip positioning during drag
|
|
|
+ const [tooltipX, setTooltipX] = useState(0);
|
|
|
+
|
|
|
+ // ── Sync display time when NOT dragging ──────────────────────────────────
|
|
|
useEffect(() => {
|
|
|
if (draggingRef.current) return;
|
|
|
- setDisplayTime(currentTime);
|
|
|
+ setDraggedTime(currentTime);
|
|
|
}, [currentTime]);
|
|
|
|
|
|
- const getTimeFromX = useCallback((clientX: number) => {
|
|
|
+ // ── Cursor → time ────────────────────────────────────────────────────────
|
|
|
+ const cursorToTime = useCallback((clientX: number): number | null => {
|
|
|
if (!trackRef.current) return null;
|
|
|
const rect = trackRef.current.getBoundingClientRect();
|
|
|
- const x = clientX - rect.left;
|
|
|
- const ratio = Math.max(0, Math.min(1, x / rect.width));
|
|
|
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
|
return ratio * duration;
|
|
|
}, [duration]);
|
|
|
|
|
|
- const seek = useCallback((t: number) => {
|
|
|
+ const cursorToPercent = useCallback((clientX: number): number => {
|
|
|
+ if (!trackRef.current) return 0;
|
|
|
+ const rect = trackRef.current.getBoundingClientRect();
|
|
|
+ return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // ── Core seek — updates display + video ──────────────────────────────────
|
|
|
+ const seekTo = useCallback((t: number) => {
|
|
|
const clamped = Math.max(0, Math.min(duration, t));
|
|
|
- setDisplayTime(clamped);
|
|
|
+ setDraggedTime(clamped);
|
|
|
onSeek(clamped);
|
|
|
}, [duration, onSeek]);
|
|
|
|
|
|
- // Generic pointer move — fires on both mouse and touch
|
|
|
- const handleMove = useCallback((clientX: number) => {
|
|
|
- if (!draggingRef.current) return;
|
|
|
- if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
|
- rafRef.current = requestAnimationFrame(() => {
|
|
|
- const t = getTimeFromX(clientX);
|
|
|
- if (t !== null) seek(t);
|
|
|
- });
|
|
|
- }, [getTimeFromX, seek]);
|
|
|
-
|
|
|
- const handleUp = useCallback((clientX: number) => {
|
|
|
- if (!draggingRef.current) return;
|
|
|
- draggingRef.current = false;
|
|
|
- document.body.style.userSelect = '';
|
|
|
- document.body.style.cursor = '';
|
|
|
- const t = getTimeFromX(clientX);
|
|
|
- if (t !== null) seek(t);
|
|
|
- setTouchActive(false);
|
|
|
- }, [getTimeFromX, seek]);
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- const onMouseMove = (e: MouseEvent) => handleMove(e.clientX);
|
|
|
- const onMouseUp = (e: MouseEvent) => handleUp(e.clientX);
|
|
|
- window.addEventListener('mousemove', onMouseMove);
|
|
|
- window.addEventListener('mouseup', onMouseUp);
|
|
|
- return () => {
|
|
|
- window.removeEventListener('mousemove', onMouseMove);
|
|
|
- window.removeEventListener('mouseup', onMouseUp);
|
|
|
- if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
|
- };
|
|
|
- }, [handleMove, handleUp]);
|
|
|
-
|
|
|
- const handlePointerDown = (e: React.PointerEvent) => {
|
|
|
- // Only handle primary pointer (left click / first touch)
|
|
|
+ // ── Mouse handlers (desktop) ─────────────────────────────────────────────
|
|
|
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
|
if (e.button !== 0) return;
|
|
|
e.preventDefault();
|
|
|
|
|
|
- // Reset any stale state from previous incomplete drag
|
|
|
+ // Reset stale drag state
|
|
|
draggingRef.current = false;
|
|
|
- setTouchActive(false);
|
|
|
+ setIsDragging(false);
|
|
|
|
|
|
- // Begin new drag
|
|
|
+ // Begin drag
|
|
|
draggingRef.current = true;
|
|
|
- document.body.style.userSelect = 'none';
|
|
|
- document.body.style.cursor = 'col-resize';
|
|
|
+ setIsDragging(true);
|
|
|
|
|
|
- // Capture pointer so mouseup always fires even if pointer leaves the element
|
|
|
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
|
+ const t = cursorToTime(e.clientX);
|
|
|
+ if (t === null) return;
|
|
|
+ seekTo(t);
|
|
|
+ setTooltipX(cursorToPercent(e.clientX));
|
|
|
|
|
|
- const t = getTimeFromX(e.clientX);
|
|
|
- if (t !== null) seek(t);
|
|
|
- };
|
|
|
+ // Capture pointer so mouseup always fires even if cursor leaves the track
|
|
|
+ trackRef.current?.setPointerCapture((e as unknown as React.PointerEvent).pointerId);
|
|
|
+ }, [cursorToTime, cursorToPercent, seekTo]);
|
|
|
|
|
|
- // Touch events — also set draggingRef so window listeners work for touch
|
|
|
- const handleTouchStart = (e: React.TouchEvent) => {
|
|
|
+ const handleMouseMove = useCallback((e: MouseEvent) => {
|
|
|
+ if (!draggingRef.current) return;
|
|
|
+ const t = cursorToTime(e.clientX);
|
|
|
+ if (t === null) return;
|
|
|
+ seekTo(t);
|
|
|
+ setTooltipX(cursorToPercent(e.clientX));
|
|
|
+ }, [cursorToTime, cursorToPercent, seekTo]);
|
|
|
+
|
|
|
+ const handleMouseUp = useCallback((e: MouseEvent) => {
|
|
|
+ if (!draggingRef.current) return;
|
|
|
+ draggingRef.current = false;
|
|
|
+ setIsDragging(false);
|
|
|
+ const t = cursorToTime(e.clientX);
|
|
|
+ if (t !== null) seekTo(t);
|
|
|
+ }, [cursorToTime, seekTo]);
|
|
|
+
|
|
|
+ // ── Touch handlers (mobile) ───────────────────────────────────────────────
|
|
|
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
|
if (e.touches.length !== 1) return;
|
|
|
const touch = e.touches[0];
|
|
|
- if (!touch) return;
|
|
|
draggingRef.current = true;
|
|
|
- setTouchActive(true);
|
|
|
- setTouchX(touch.clientX);
|
|
|
- const t = getTimeFromX(touch.clientX);
|
|
|
- if (t !== null) seek(t);
|
|
|
- };
|
|
|
-
|
|
|
- const handleTouchMove = (e: React.TouchEvent) => {
|
|
|
+ setIsDragging(true);
|
|
|
+ const t = cursorToTime(touch.clientX);
|
|
|
+ if (t !== null) seekTo(t);
|
|
|
+ setTooltipX(cursorToPercent(touch.clientX));
|
|
|
+ }, [cursorToTime, cursorToPercent, seekTo]);
|
|
|
+
|
|
|
+ const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
|
+ if (!draggingRef.current || e.touches.length !== 1) return;
|
|
|
const touch = e.touches[0];
|
|
|
- if (!touch) return;
|
|
|
- setTouchX(touch.clientX);
|
|
|
- handleMove(touch.clientX);
|
|
|
- };
|
|
|
+ const t = cursorToTime(touch.clientX);
|
|
|
+ if (t !== null) seekTo(t);
|
|
|
+ setTooltipX(cursorToPercent(touch.clientX));
|
|
|
+ }, [cursorToTime, cursorToPercent, seekTo]);
|
|
|
|
|
|
- const handleTouchEnd = (e: React.TouchEvent) => {
|
|
|
+ const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
|
|
+ if (!draggingRef.current) return;
|
|
|
+ draggingRef.current = false;
|
|
|
+ setIsDragging(false);
|
|
|
const touch = e.changedTouches[0];
|
|
|
- if (!touch) return;
|
|
|
- handleUp(touch.clientX);
|
|
|
- };
|
|
|
+ if (touch) {
|
|
|
+ const t = cursorToTime(touch.clientX);
|
|
|
+ if (t !== null) seekTo(t);
|
|
|
+ }
|
|
|
+ }, [cursorToTime, seekTo]);
|
|
|
|
|
|
- const progress = duration > 0 ? (displayTime / duration) * 100 : 0;
|
|
|
+ // ── Global mouse listeners (desktop drag) ─────────────────────────────────
|
|
|
+ useEffect(() => {
|
|
|
+ window.addEventListener('mousemove', handleMouseMove);
|
|
|
+ window.addEventListener('mouseup', handleMouseUp);
|
|
|
+ return () => {
|
|
|
+ window.removeEventListener('mousemove', handleMouseMove);
|
|
|
+ window.removeEventListener('mouseup', handleMouseUp);
|
|
|
+ };
|
|
|
+ }, [handleMouseMove, handleMouseUp]);
|
|
|
|
|
|
- // Touch tooltip: show timecode above thumb while dragging on touch
|
|
|
- const showTooltip = draggingRef.current || touchActive;
|
|
|
+ // ── Scrubber position ─────────────────────────────────────────────────────
|
|
|
+ const progress = duration > 0 ? (draggedTime / duration) * 100 : 0;
|
|
|
|
|
|
return (
|
|
|
<div className="relative py-3 select-none">
|
|
|
+
|
|
|
{/* Comment tick marks */}
|
|
|
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-0 pointer-events-none">
|
|
|
{comments.map(comment => {
|
|
|
@@ -150,62 +162,75 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
|
|
|
})}
|
|
|
</div>
|
|
|
|
|
|
- {/* Touch tooltip — visible during drag */}
|
|
|
- {showTooltip && (
|
|
|
- <div
|
|
|
- className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-lg text-xs font-mono pointer-events-none whitespace-nowrap z-10"
|
|
|
- style={{
|
|
|
- background: 'rgba(10,11,20,0.95)',
|
|
|
- border: '1px solid rgba(99,102,241,0.35)',
|
|
|
- color: '#A5B4FC',
|
|
|
- backdropFilter: 'blur(8px)',
|
|
|
- }}
|
|
|
- >
|
|
|
- {formatTimecode(displayTime, fps, duration)}
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* Seek bar — large touch target */}
|
|
|
+ {/* ── Seek bar ──────────────────────────────────────────────────── */}
|
|
|
<div
|
|
|
ref={trackRef}
|
|
|
- className="relative h-2 md:h-1.5 bg-white/10 rounded-full cursor-pointer group"
|
|
|
- onPointerDown={handlePointerDown}
|
|
|
+ className="relative h-2 md:h-1.5 bg-white/10 rounded-full cursor-pointer"
|
|
|
+ onMouseDown={handleMouseDown}
|
|
|
onTouchStart={handleTouchStart}
|
|
|
onTouchMove={handleTouchMove}
|
|
|
onTouchEnd={handleTouchEnd}
|
|
|
style={{ touchAction: 'none' }}
|
|
|
>
|
|
|
- {/* Played portion */}
|
|
|
+ {/* Played / scrubbed portion */}
|
|
|
<div
|
|
|
- className="absolute left-0 top-0 h-full bg-indigo-500 rounded-full"
|
|
|
+ className="absolute left-0 top-0 h-full bg-indigo-500 rounded-full will-change-width"
|
|
|
style={{ width: `${progress}%` }}
|
|
|
/>
|
|
|
- {/* Scrubber — larger on touch, always visible while dragging */}
|
|
|
+
|
|
|
+ {/* Scrubber — active state: indigo glow + scale up */}
|
|
|
<div
|
|
|
- className="absolute top-1/2 -translate-y-1/2 w-4 h-4 md:w-3.5 md:h-3.5 bg-white rounded-full shadow-lg border-2 border-indigo-500 transition-opacity"
|
|
|
+ className="absolute top-1/2 -translate-y-1/2 rounded-full will-change-transform"
|
|
|
style={{
|
|
|
left: `calc(${progress}% - 8px)`,
|
|
|
- opacity: draggingRef.current ? 1 : undefined,
|
|
|
+ width: isDragging ? '18px' : '14px',
|
|
|
+ height: isDragging ? '18px' : '14px',
|
|
|
+ marginLeft: isDragging ? '-2px' : '0px',
|
|
|
+ marginTop: isDragging ? '-2px' : '0px',
|
|
|
+ background: 'white',
|
|
|
+ border: '2px solid #818CF8',
|
|
|
+ boxShadow: isDragging
|
|
|
+ ? '0 0 0 4px rgba(99,102,241,0.25), 0 0 12px rgba(99,102,241,0.4)'
|
|
|
+ : '0 1px 4px rgba(0,0,0,0.3)',
|
|
|
+ transition: isDragging ? 'none' : 'width 0.12s, height 0.12s, margin 0.12s, box-shadow 0.12s',
|
|
|
}}
|
|
|
/>
|
|
|
- {/* Touch ripple — visible on touch drag */}
|
|
|
- {touchActive && (
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* ── Timecode tooltip — appears above scrubber during drag ───────── */}
|
|
|
+ {isDragging && (
|
|
|
+ <div
|
|
|
+ className="absolute bottom-full mb-2 px-2 py-1 rounded-lg text-xs font-mono pointer-events-none whitespace-nowrap z-10"
|
|
|
+ style={{
|
|
|
+ left: `clamp(40px, ${tooltipX}%, calc(100% - 40px))`,
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ background: 'rgba(10,11,20,0.95)',
|
|
|
+ border: '1px solid rgba(99,102,241,0.40)',
|
|
|
+ color: '#A5B4FC',
|
|
|
+ backdropFilter: 'blur(8px)',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {formatTimecode(draggedTime, fps, duration)}
|
|
|
+ {/* Tooltip pointer — points down to scrubber */}
|
|
|
<div
|
|
|
- className="absolute top-1/2 -translate-y-1/2 rounded-full pointer-events-none"
|
|
|
style={{
|
|
|
- left: `calc(${progress}% - 16px)`,
|
|
|
- width: '32px',
|
|
|
- height: '32px',
|
|
|
- background: 'rgba(99,102,241,0.15)',
|
|
|
- border: '1px solid rgba(99,102,241,0.3)',
|
|
|
+ position: 'absolute',
|
|
|
+ top: '100%',
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ width: 0,
|
|
|
+ height: 0,
|
|
|
+ borderLeft: '5px solid transparent',
|
|
|
+ borderRight: '5px solid transparent',
|
|
|
+ borderTop: '5px solid rgba(99,102,241,0.40)',
|
|
|
}}
|
|
|
/>
|
|
|
- )}
|
|
|
- </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
{/* Timecode display */}
|
|
|
<div className="flex justify-between mt-1.5 text-xs text-gray-400 font-mono">
|
|
|
- <span>{formatTimecode(displayTime, fps, duration)}</span>
|
|
|
+ <span>{formatTimecode(draggedTime, fps, duration)}</span>
|
|
|
<span>{formatTimecode(duration, fps, duration)}</span>
|
|
|
</div>
|
|
|
</div>
|