Преглед изворни кода

feat: enhanced Timeline scrubbing UX

- Visual active state: scrubber scales up (14→18px) with indigo glow + pulse ring during drag
- No RAF throttle in handleMouseMove: direct seek on every mousemove for sub-16ms latency
- Tooltip follows cursor X position (not track center) clamped to viewport edges
- Decouple draggedTime (visual) from currentTime (video) so scrubber never lags behind cursor
- Removed touchActive/touchX state, consolidated into single isDragging
- Pointer capture ensures mouseup always fires even if cursor leaves track

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev пре 1 месец
родитељ
комит
4215e2ebf3
1 измењених фајлова са 136 додато и 111 уклоњено
  1. 136 111
      src/components/video-player/Timeline.tsx

+ 136 - 111
src/components/video-player/Timeline.tsx

@@ -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>