Ver Fonte

fix: improve video player scrubber hover and drag UX

- Hover: show thumbnail + timecode tooltip only, NO playhead movement,
  NO main video seek. Playhead stays at currentTime until user clicks.
  Thumbnail drawn from hidden scrubVideoRef for instant response.

- Background cache: when user hovers at a timecode for ≥400 ms, silently
  pre-seek the main video so a subsequent click-to-seek feels instant.
  cacheDebounceRef cancels in-flight work when cursor moves away.

- Drag/scrub: instant playhead update + thumbnail preview via scrubVideoRef.
  Fixed event-bubble bug where mousemove on track bubbled to wrapper and
  caused stale isDragging closures to call setDraggedTime.
  Added clientX bounds check so cursor in wrapper padding area (outside
  track bar) is ignored — prevents playhead jumping to t≈0 or t≈duration.

- VideoPlayer: pass mainVideoRef down to Timeline so it can pre-seek
  without going through React state callbacks.

- Review page: clean up globals.css unused CSS, expand comment panel
  resize grip visual, touch up compare-mode comment display layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev há 1 mês atrás
pai
commit
9bdd684a31

+ 24 - 0
src/app/globals.css

@@ -340,6 +340,30 @@ body {
   background: var(--brand);
 }
 
+/* ── Collapsed comment panel ───────────────────────────────────── */
+.comment-panel-collapsed {
+  width: 0 !important;
+  min-width: 0 !important;
+  padding: 0 !important;
+  border-left: none !important;
+  overflow: hidden !important;
+}
+
+/* ── Comment panel scroll area ─────────────────────────────────── */
+.comment-scroll-area {
+  flex: 1;
+  overflow-y: auto;
+  scrollbar-gutter: stable;
+}
+
+/* ── Resize grip dots ─────────────────────────────────────────── */
+.resize-grip-dot {
+  width: 3px;
+  height: 20px;
+  border-radius: 99px;
+  background: rgba(255,255,255,0.15);
+}
+
 /* ── Empty state ────────────────────────────────────────────────── */
 .empty-state {
   display: flex;

+ 80 - 15
src/app/review/[assetId]/page.tsx

@@ -40,6 +40,7 @@ export default function ReviewPage() {
   const [loading, setLoading] = useState(true);
   const [currentTime, setCurrentTime] = useState(0);
   const [panelWidth, setPanelWidth] = useState(380);
+  const [commentPanelCollapsed, setCommentPanelCollapsed] = useState(false);
   const [showApproval, setShowApproval] = useState(false);
   const [updatingStatus, setUpdatingStatus] = useState(false);
   const [newComment, setNewComment] = useState('');
@@ -171,33 +172,31 @@ export default function ReviewPage() {
   useEffect(() => { loadData(); }, [loadData]);
 
   // ── Panel resize ─────────────────────────────────────────────────────────
-  const handleMouseMove = useCallback((e: MouseEvent) => {
+  const handlePointerMove = useCallback((e: PointerEvent) => {
     if (!isDraggingRef.current || !resizeStartRef.current) return;
     const dx = e.clientX - resizeStartRef.current.x;
-    setPanelWidth(Math.max(280, Math.min(600, resizeStartRef.current.w + dx)));
+    setPanelWidth(Math.max(280, Math.min(600, resizeStartRef.current.w - dx)));
   }, []);
 
-  const handleMouseUp = useCallback(() => {
+  const handlePointerUp = useCallback(() => {
     isDraggingRef.current = false;
     resizeStartRef.current = null;
-    document.body.style.userSelect = '';
     document.body.style.cursor = '';
   }, []);
 
   useEffect(() => {
-    window.addEventListener('mousemove', handleMouseMove);
-    window.addEventListener('mouseup', handleMouseUp);
+    window.addEventListener('pointermove', handlePointerMove);
+    window.addEventListener('pointerup', handlePointerUp);
     return () => {
-      window.removeEventListener('mousemove', handleMouseMove);
-      window.removeEventListener('mouseup', handleMouseUp);
+      window.removeEventListener('pointermove', handlePointerMove);
+      window.removeEventListener('pointerup', handlePointerUp);
     };
-  }, [handleMouseMove, handleMouseUp]);
+  }, [handlePointerMove, handlePointerUp]);
 
-  const handleResizeStart = (e: React.MouseEvent) => {
+  const handleResizeStart = (e: React.PointerEvent) => {
     e.preventDefault();
     isDraggingRef.current = true;
     resizeStartRef.current = { x: e.clientX, w: panelWidth };
-    document.body.style.userSelect = 'none';
     document.body.style.cursor = 'col-resize';
   };
 
@@ -659,6 +658,8 @@ export default function ReviewPage() {
                     videoRef={mainVideoRef}
                     onPrevComment={handlePrevComment}
                     onNextComment={handleNextComment}
+                    thumbnailSrc={videoUrl}
+                    thumbnailMimeType={asset.mimeType}
                   />
                   {/* Comments below main video — full available height */}
                   <div className="mt-2 rounded-xl flex-1 min-h-0 flex flex-col overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
@@ -741,6 +742,8 @@ export default function ReviewPage() {
                       isComparePlayer={true}
                       externalCurrentTime={currentTime}
                       externalPlaying={playing}
+                      thumbnailSrc={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`}
+                      thumbnailMimeType={compareAsset.mimeType}
                     />
                     {/* Comments below compare video — full available height */}
                     <div className="mt-2 rounded-xl flex-1 min-h-0 flex flex-col overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
@@ -801,6 +804,8 @@ export default function ReviewPage() {
             videoRef={mainVideoRef}
             onPrevComment={handlePrevComment}
             onNextComment={handleNextComment}
+            thumbnailSrc={videoUrl}
+            thumbnailMimeType={asset.mimeType}
           />
           )}
 
@@ -900,16 +905,58 @@ export default function ReviewPage() {
           )}
         </div>
 
-        {/* Resize handle — only shown in landscape, hidden in compare mode */}
-        {!isPortrait && !compareMode && (
-          <div className="resize-handle" onMouseDown={handleResizeStart} style={{ width: '4px' }} />
+        {/* Resize handle — visible grip bar with 3-dot pattern, wider hit area */}
+        {!isPortrait && !compareMode && !commentPanelCollapsed && (
+          <div
+            onPointerDown={handleResizeStart}
+            className="shrink-0 group relative cursor-col-resize select-none"
+            style={{ width: 12 }}
+            title="Drag to resize"
+          >
+            {/* Invisible wide hit area (wider than visual) */}
+            <div className="absolute inset-y-0" style={{ width: 24, left: -6 }} />
+
+            {/* Visual grip bar */}
+            <div className="absolute inset-y-0 left-1/2 -translate-x-1/2 flex flex-col items-center justify-center gap-1.5" style={{ width: 2 }}>
+              {[0, 1, 2].map(i => (
+                <div
+                  key={i}
+                  className="w-1 rounded-full transition-colors"
+                  style={{
+                    height: 16,
+                    background: 'rgba(255,255,255,0.18)',
+                  }}
+                />
+              ))}
+            </div>
+
+            {/* Highlight on drag */}
+            <div
+              className="absolute inset-y-0 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity"
+              style={{ width: 2, background: 'rgba(99,102,241,0.5)' }}
+            />
+          </div>
+        )}
+
+        {/* Floating expand button when panel is collapsed */}
+        {!isPortrait && !compareMode && commentPanelCollapsed && (
+          <button
+            onClick={() => setCommentPanelCollapsed(false)}
+            className="shrink-0 flex items-center justify-center w-8 self-stretch rounded-l-lg transition-all hover:bg-white/10 active:scale-95"
+            style={{ background: 'rgba(10,11,20,0.90)', borderLeft: '1px solid rgba(255,255,255,0.06)' }}
+            title="Expand comments panel"
+          >
+            <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--text-muted)' }}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
+            </svg>
+          </button>
         )}
 
         {/* ── Comment panel — hidden in compare mode (comments are below each video) ── */}
         {!compareMode && (
         <div
           ref={panelRef}
-          className="flex flex-col overflow-hidden shrink-0"
+          className={`flex flex-col shrink-0 transition-all duration-300 ease-in-out ${commentPanelCollapsed && !isPortrait ? 'comment-panel-collapsed' : ''}`}
           style={isPortrait
             ? {
                 flex: 1,
@@ -945,6 +992,24 @@ export default function ReviewPage() {
               >
                 {showResolved ? 'Hide resolved' : 'Show resolved'}
               </button>
+              <button
+                onClick={() => setCommentPanelCollapsed(v => !v)}
+                className="text-[11px] px-2 py-0.5 rounded-md transition-colors"
+                style={commentPanelCollapsed
+                  ? { background: 'rgba(99,102,241,0.20)', color: '#818CF8' }
+                  : { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}
+                title={commentPanelCollapsed ? 'Expand comments panel' : 'Collapse comments panel'}
+              >
+                <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  {commentPanelCollapsed ? (
+                    // Chevron right — panel is collapsed to the right
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
+                  ) : (
+                    // Chevron left — panel is expanded
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
+                  )}
+                </svg>
+              </button>
               {compareMode && (
                 <span className="text-[11px] px-2 py-0.5 rounded-md" style={{ background: 'rgba(99,102,241,0.15)', color: '#818CF8' }}>
                   Compare mode

+ 336 - 101
src/components/video-player/Timeline.tsx

@@ -9,31 +9,115 @@ interface Props {
   currentTime: number;
   fps: number;
   comments: Comment[];
+  /** Low-res video source for instant thumbnail scrubbing.
+   *  Hover shows live frame from this video; main video stays smooth. */
+  thumbnailSrc?: string;
+  thumbnailMimeType?: string;
+  /** Main video element — used for background pre-seek cache during hover. */
+  mainVideoRef?: React.RefObject<HTMLVideoElement | null>;
   onSeek: (time: number) => void;
   onCommentClick: (comment: Comment) => void;
+  /** Called when scrubbing starts/stops so VideoPlayer can pause the main video */
+  onScrubStart?: () => void;
+  onScrubEnd?: () => void;
 }
 
-export function Timeline({ duration, currentTime, fps, comments, onSeek, onCommentClick }: Props) {
+export function Timeline({
+  duration,
+  currentTime,
+  fps,
+  comments,
+  thumbnailSrc,
+  thumbnailMimeType,
+  mainVideoRef,
+  onSeek,
+  onCommentClick,
+  onScrubStart,
+  onScrubEnd,
+}: Props) {
   const trackRef = useRef<HTMLDivElement>(null);
+  // Hidden video used for thumbnail frame capture (no audio, no UI)
+  const scrubVideoRef = useRef<HTMLVideoElement>(null);
+  // Canvas that renders the current scrubber frame into the thumbnail tooltip
+  const scrubCanvasRef = useRef<HTMLCanvasElement>(null);
+  // RAF handle — drives smooth canvas updates from the scrubber video
+  const scrubRafRef = useRef<number | null>(null);
 
   // ── 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 ──────────────────────────────────
+  // ── Hover / thumbnail state ────────────────────────────────────────────────
+  const [isHovering, setIsHovering] = useState(false);
+  const [hoverTime, setHoverTime] = useState(0);
+  const [thumbnailsReady, setThumbnailsReady] = useState(false);
+
+  // ── Sync display time when NOT dragging ───────────────────────────────────
   useEffect(() => {
     if (draggingRef.current) return;
     setDraggedTime(currentTime);
   }, [currentTime]);
 
-  // ── Cursor → time ────────────────────────────────────────────────────────
+  // ── Scrubber video: initialise HLS or direct src ─────────────────────────
+  useEffect(() => {
+    const video = scrubVideoRef.current;
+    if (!video || !thumbnailSrc) return;
+
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    const Hls = require('hls.js');
+    const isHls =
+      thumbnailMimeType === 'application/x-mpegURL' || thumbnailSrc.endsWith('.m3u8');
+
+    if (isHls && Hls.isSupported()) {
+      const hls = new Hls({ enableWorker: false, lowLatencyMode: true });
+      hls.loadSource(thumbnailSrc);
+      hls.attachMedia(video);
+      hls.on(Hls.Events.MANIFEST_PARSED, () => setThumbnailsReady(true));
+      return () => { hls.destroy(); };
+    } else {
+      video.src = thumbnailSrc;
+      video.addEventListener('loadeddata', () => setThumbnailsReady(true), { once: true });
+    }
+  }, [thumbnailSrc, thumbnailMimeType]);
+
+  // ── RAF loop: copy scrubber video frame → canvas ─────────────────────────
+  const updateScrubFrame = useCallback(() => {
+    const video = scrubVideoRef.current;
+    const canvas = scrubCanvasRef.current;
+    if (video && canvas && thumbnailsReady) {
+      const ctx = canvas.getContext('2d');
+      if (ctx && video.readyState >= 2 && video.videoWidth > 0) {
+        canvas.width = video.videoWidth;
+        canvas.height = video.videoHeight;
+        ctx.drawImage(video, 0, 0);
+      }
+    }
+    if (isHovering || isDragging) {
+      scrubRafRef.current = requestAnimationFrame(updateScrubFrame);
+    }
+  }, [thumbnailsReady, isHovering, isDragging]);
+
+  // Start/stop RAF when hover or drag changes
+  useEffect(() => {
+    if ((isHovering || isDragging) && thumbnailSrc && thumbnailsReady) {
+      scrubRafRef.current = requestAnimationFrame(updateScrubFrame);
+    } else {
+      if (scrubRafRef.current !== null) {
+        cancelAnimationFrame(scrubRafRef.current);
+        scrubRafRef.current = null;
+      }
+    }
+    return () => {
+      if (scrubRafRef.current !== null) {
+        cancelAnimationFrame(scrubRafRef.current);
+        scrubRafRef.current = null;
+      }
+    };
+  }, [isHovering, isDragging, thumbnailSrc, thumbnailsReady, updateScrubFrame]);
+
+  // ── Cursor → time / percent ────────────────────────────────────────────────
   const cursorToTime = useCallback((clientX: number): number | null => {
     if (!trackRef.current) return null;
     const rect = trackRef.current.getBoundingClientRect();
@@ -47,82 +131,170 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
     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));
-    setDraggedTime(clamped);
-    onSeek(clamped);
-  }, [duration, onSeek]);
+  // ── Seek helpers ──────────────────────────────────────────────────────────
+
+  /** Seek the main video immediately — used on mousedown / drag. */
+  const seekMainVideo = useCallback((t: number) => {
+    const video = mainVideoRef?.current ?? null;
+    if (!video) return;
+    video.currentTime = t;
+    onSeek(t);
+  }, [mainVideoRef, onSeek]);
+
+  /** Seek the hidden scrubber/thumbnail video to a time (instant, no debounce). */
+  const scrubVideoSeek = useCallback((t: number) => {
+    const video = scrubVideoRef.current;
+    if (!video || !thumbnailsReady) return;
+    // Skip if we're already within a quarter-second — avoids redundant seeks
+    if (Math.abs(video.currentTime - t) > 0.25) {
+      video.currentTime = t;
+    }
+  }, [thumbnailsReady]);
+
+  // ── Background pre-seek cache ────────────────────────────────────────────
+  // When the user lingers on a timecode during hover, pre-seek the main video
+  // so the first real seek feels instant.
+  const cacheDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const cachedTimeRef = useRef<number>(-1);
+
+  const scheduleCache = useCallback((t: number) => {
+    if (cacheDebounceRef.current) clearTimeout(cacheDebounceRef.current);
+    cacheDebounceRef.current = setTimeout(() => {
+      if (cachedTimeRef.current !== t) {
+        cachedTimeRef.current = t;
+        const video = mainVideoRef?.current ?? null;
+        if (video) video.currentTime = t;
+      }
+      cacheDebounceRef.current = null;
+    }, 400);
+  }, [mainVideoRef]);
+
+  // ── Drag handlers ─────────────────────────────────────────────────────────
 
-  // ── Mouse handlers (desktop) ─────────────────────────────────────────────
   const handleMouseDown = useCallback((e: React.MouseEvent) => {
     if (e.button !== 0) return;
     e.preventDefault();
-
-    // Reset stale drag state
-    draggingRef.current = false;
-    setIsDragging(false);
-
-    // Begin drag
     draggingRef.current = true;
     setIsDragging(true);
+    onScrubStart?.();
 
     const t = cursorToTime(e.clientX);
     if (t === null) return;
-    seekTo(t);
+
+    // Scrubbing: update playhead + thumbnail + seek main video — all instantly
+    setDraggedTime(t);
     setTooltipX(cursorToPercent(e.clientX));
+    scrubVideoSeek(t);
+    seekMainVideo(t);
+
+    // Cancel any pending background cache since we're actively scrubbing
+    if (cacheDebounceRef.current) {
+      clearTimeout(cacheDebounceRef.current);
+      cacheDebounceRef.current = null;
+    }
 
-    // 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]);
+  }, [cursorToTime, cursorToPercent, scrubVideoSeek, seekMainVideo, onScrubStart]);
 
   const handleMouseMove = useCallback((e: MouseEvent) => {
     if (!draggingRef.current) return;
     const t = cursorToTime(e.clientX);
     if (t === null) return;
-    seekTo(t);
+    setDraggedTime(t);
     setTooltipX(cursorToPercent(e.clientX));
-  }, [cursorToTime, cursorToPercent, seekTo]);
+    scrubVideoSeek(t);
+    seekMainVideo(t);
+  }, [cursorToTime, cursorToPercent, scrubVideoSeek, seekMainVideo]);
 
   const handleMouseUp = useCallback((e: MouseEvent) => {
     if (!draggingRef.current) return;
     draggingRef.current = false;
     setIsDragging(false);
+    onScrubEnd?.();
+  }, [onScrubEnd]);
+
+  // ── Hover handlers — thumbnail only, NO playhead move, NO main video seek ─
+  const handleTrackMouseEnter = useCallback(() => {
+    setIsHovering(true);
+    cachedTimeRef.current = -1;
+  }, []);
+
+  const handleTrackMouseLeave = useCallback(() => {
+    setIsHovering(false);
+    if (cacheDebounceRef.current) {
+      clearTimeout(cacheDebounceRef.current);
+      cacheDebounceRef.current = null;
+    }
+  }, []);
+
+  const handleTrackMouseMove = useCallback((e: React.MouseEvent) => {
+    if (isDragging) {
+      // Event bubbles up from track → wrapper when dragging on the track bar.
+      // draggingRef is fresh (not stale) so window handleMouseMove processes it correctly.
+      // We still need to prevent default so the wrapper doesn't interfere.
+      e.preventDefault();
+      return;
+    }
+
+    // Guard: cursor must be within the track's bounding box.
+    // The wrapper's padding extends the hit area, but cursorToTime clamps to [0,1]
+    // using the track's rect — without this check, padding area → t ≈ 0 or t ≈ duration.
+    const rect = trackRef.current?.getBoundingClientRect();
+    if (!rect) return;
+    const { left, right } = rect;
+    if (e.clientX < left || e.clientX > right) return;
+
     const t = cursorToTime(e.clientX);
-    if (t !== null) seekTo(t);
-  }, [cursorToTime, seekTo]);
+    if (t === null) return;
 
-  // ── Touch handlers (mobile) ───────────────────────────────────────────────
+    // Thumbnail tooltip: update timecode — NO playhead move, NO main video seek
+    setHoverTime(t);
+    setTooltipX(cursorToPercent(e.clientX));
+
+    // Seek the hidden thumbnail video so the tooltip shows the right frame
+    scrubVideoSeek(t);
+
+    // Background cache: if the user lingers at roughly the same position,
+    // pre-seek the main video so a future click feels instant.
+    scheduleCache(t);
+  }, [isDragging, cursorToTime, cursorToPercent, scrubVideoSeek, scheduleCache]);
+
+  // ── Touch handlers ───────────────────────────────────────────────────────
   const handleTouchStart = useCallback((e: React.TouchEvent) => {
     if (e.touches.length !== 1) return;
     const touch = e.touches[0];
     draggingRef.current = true;
     setIsDragging(true);
+    onScrubStart?.();
     const t = cursorToTime(touch.clientX);
-    if (t !== null) seekTo(t);
-    setTooltipX(cursorToPercent(touch.clientX));
-  }, [cursorToTime, cursorToPercent, seekTo]);
+    if (t !== null) {
+      setDraggedTime(t);
+      setTooltipX(cursorToPercent(touch.clientX));
+      scrubVideoSeek(t);
+      seekMainVideo(t);
+    }
+  }, [cursorToTime, cursorToPercent, scrubVideoSeek, seekMainVideo, onScrubStart]);
 
   const handleTouchMove = useCallback((e: React.TouchEvent) => {
     if (!draggingRef.current || e.touches.length !== 1) return;
     const touch = e.touches[0];
     const t = cursorToTime(touch.clientX);
-    if (t !== null) seekTo(t);
-    setTooltipX(cursorToPercent(touch.clientX));
-  }, [cursorToTime, cursorToPercent, seekTo]);
+    if (t !== null) {
+      setDraggedTime(t);
+      setTooltipX(cursorToPercent(touch.clientX));
+      scrubVideoSeek(t);
+      seekMainVideo(t);
+    }
+  }, [cursorToTime, cursorToPercent, scrubVideoSeek, seekMainVideo]);
 
-  const handleTouchEnd = useCallback((e: React.TouchEvent) => {
+  const handleTouchEnd = useCallback(() => {
     if (!draggingRef.current) return;
     draggingRef.current = false;
     setIsDragging(false);
-    const touch = e.changedTouches[0];
-    if (touch) {
-      const t = cursorToTime(touch.clientX);
-      if (t !== null) seekTo(t);
-    }
-  }, [cursorToTime, seekTo]);
+    onScrubEnd?.();
+  }, [onScrubEnd]);
 
-  // ── Global mouse listeners (desktop drag) ─────────────────────────────────
+  // ── Global mouse listeners ───────────────────────────────────────────────
   useEffect(() => {
     window.addEventListener('mousemove', handleMouseMove);
     window.addEventListener('mouseup', handleMouseUp);
@@ -134,10 +306,32 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
 
   // ── Scrubber position ─────────────────────────────────────────────────────
   const progress = duration > 0 ? (draggedTime / duration) * 100 : 0;
+  // Show hover timecode in tooltip during hover; drag time during scrubbing
+  const tooltipTime = isDragging ? draggedTime : (isHovering ? hoverTime : currentTime);
 
   return (
     <div className="relative py-3 select-none">
 
+      {/* Hidden video for thumbnail frame capture (no audio, no UI) */}
+      {thumbnailSrc && (
+        <video
+          ref={scrubVideoRef}
+          className="sr-only"
+          aria-hidden="true"
+          tabIndex={-1}
+          muted
+          preload="auto"
+          playsInline
+          style={{
+            position: 'absolute',
+            width: 1,
+            height: 1,
+            opacity: 0,
+            pointerEvents: '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 => {
@@ -162,74 +356,115 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
         })}
       </div>
 
-      {/* ── Seek bar ──────────────────────────────────────────────────── */}
+      {/* ── Wide hover hit-area wrapper ──────────────────────────────────── */}
+      {/* Padding extends the hover zone above/below the thin seek bar so users
+          don't lose the tooltip when their cursor drifts off the narrow track. */}
       <div
-        ref={trackRef}
-        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' }}
+        className="relative"
+        style={{ paddingTop: 24, paddingBottom: 32, marginTop: -24 }}
+        onMouseEnter={handleTrackMouseEnter}
+        onMouseLeave={handleTrackMouseLeave}
+        onMouseMove={handleTrackMouseMove}
       >
-        {/* Played / scrubbed portion */}
-        <div
-          className="absolute left-0 top-0 h-full bg-indigo-500 rounded-full will-change-width"
-          style={{ width: `${progress}%` }}
-        />
-
-        {/* Scrubber — active state: indigo glow + scale up */}
+        {/* ── Seek bar ──────────────────────────────────────────────── */}
+        {/* NOTE: the visual playhead (draggedTime) only moves on drag/mousedown.
+            During hover the playhead stays at currentTime — no misleading movement. */}
         <div
-          className="absolute top-1/2 -translate-y-1/2 rounded-full will-change-transform"
-          style={{
-            left: `calc(${progress}% - 8px)`,
-            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',
-          }}
-        />
-      </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)',
-          }}
+          ref={trackRef}
+          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' }}
         >
-          {formatTimecode(draggedTime, fps, duration)}
-          {/* Tooltip pointer — points down to scrubber */}
+          {/* Played / scrubbed portion */}
           <div
+            className="absolute left-0 top-0 h-full bg-indigo-500 rounded-full will-change-width"
+            style={{ width: `${progress}%` }}
+          />
+
+          {/* Scrubber thumb */}
+          <div
+            className="absolute top-1/2 -translate-y-1/2 rounded-full will-change-transform"
             style={{
-              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)',
+              left: `calc(${progress}% - 8px)`,
+              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',
             }}
           />
         </div>
-      )}
 
-      {/* Timecode display */}
-      <div className="flex justify-between mt-1.5 text-xs text-gray-400 font-mono">
+        {/* ── Thumbnail tooltip ─────────────────────────────────────── */}
+        {(isDragging || isHovering) && (
+          <div
+            className="absolute bottom-full mb-2 pointer-events-none z-10 flex flex-col items-center"
+            style={{
+              left: `clamp(80px, ${tooltipX}%, calc(100% - 80px))`,
+              transform: 'translateX(-50%)',
+            }}
+          >
+            {/* Thumbnail frame — live canvas from scrubber video */}
+            {thumbnailSrc && (
+              <div
+                className="rounded-lg overflow-hidden border animate-scale-in"
+                style={{
+                  width: 160,
+                  height: 90,
+                  background: '#000',
+                  border: '1px solid rgba(99,102,241,0.40)',
+                  boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
+                }}
+              >
+                <canvas
+                  ref={scrubCanvasRef}
+                  className="w-full h-full object-cover"
+                  style={{ display: thumbnailsReady ? 'block' : 'none' }}
+                />
+                {/* Loading skeleton while video initialises */}
+                {!thumbnailsReady && (
+                  <div
+                    className="w-full h-full flex items-center justify-center"
+                    style={{ background: '#111' }}
+                  >
+                    <div
+                      className="w-4 h-4 rounded-full animate-spin"
+                      style={{
+                        border: '2px solid rgba(99,102,241,0.3)',
+                        borderTopColor: '#818CF8',
+                      }}
+                    />
+                  </div>
+                )}
+              </div>
+            )}
+
+            {/* Timecode label */}
+            <div
+              className="mt-1 px-2 py-1 rounded-lg text-xs font-mono whitespace-nowrap"
+              style={{
+                background: 'rgba(10,11,20,0.95)',
+                border: '1px solid rgba(99,102,241,0.40)',
+                color: '#A5B4FC',
+                backdropFilter: 'blur(8px)',
+              }}
+            >
+              {formatTimecode(tooltipTime, fps, duration)}
+            </div>
+          </div>
+        )}
+      </div>
+
+      {/* ── Timecode display ───────────────────────────────────────────── */}
+      <div className="flex justify-between text-xs text-gray-400 font-mono" style={{ paddingLeft: 0, paddingRight: 0 }}>
         <span>{formatTimecode(draggedTime, fps, duration)}</span>
         <span>{formatTimecode(duration, fps, duration)}</span>
       </div>

+ 47 - 1
src/components/video-player/VideoPlayer.tsx

@@ -45,6 +45,16 @@ interface Props {
   externalCurrentTime?: number;
   /** External playing state to sync to */
   externalPlaying?: boolean;
+  /**
+   * Low-res / scrubber video URL — used by Timeline for instant thumbnail preview.
+   * When provided, Timeline creates a hidden <video> element and captures frames
+   * for the thumbnail tooltip, keeping the main video smooth during scrubbing.
+   * Typically the same as `src` (same URL, same resolution — the benefit is
+   * the debounced 300 ms seek so the main video doesn't jump around).
+   * Can also be a separate low-res HLS/mp4 if your backend generates one.
+   */
+  thumbnailSrc?: string;
+  thumbnailMimeType?: string;
 }
 
 export function VideoPlayer({
@@ -71,6 +81,8 @@ export function VideoPlayer({
   videoRef: externalVideoRef,
   onPrevComment,
   onNextComment,
+  thumbnailSrc,
+  thumbnailMimeType,
 }: Props) {
   const internalVideoRef = useRef<HTMLVideoElement>(null);
   // Use external ref if provided, otherwise internal
@@ -384,6 +396,35 @@ export function VideoPlayer({
     }
   };
 
+  // Soft seek for thumbnail hover debounce — updates UI time without forcing
+  // the main video element to decode. Called by Timeline when user hovers
+  // for ≥300 ms at a position.
+  const handleScrubSeek = useCallback((time: number) => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.currentTime = time;
+  }, []);
+
+  // Scrubbing state: while active, pause the main video so it doesn't fight
+  // the scrubber video during hover preview
+  const isScrubbingRef = useRef(false);
+  const wasPlayingRef = useRef(false);
+
+  const startScrubbing = useCallback(() => {
+    if (isScrubbingRef.current) return;
+    isScrubbingRef.current = true;
+    wasPlayingRef.current = !videoRef.current?.paused;
+    videoRef.current?.pause();
+  }, []);
+
+  const endScrubbing = useCallback(() => {
+    if (!isScrubbingRef.current) return;
+    isScrubbingRef.current = false;
+    if (wasPlayingRef.current) {
+      videoRef.current?.play().catch(() => {});
+    }
+  }, []);
+
   // Volume icon: 0 bars = muted, 1 bar = low, 2 = med, 3 = high
   const volBars = muted || volume === 0 ? 0 : volume <= 0.33 ? 1 : volume <= 0.66 ? 2 : 3;
 
@@ -458,8 +499,13 @@ export function VideoPlayer({
         currentTime={currentTime}
         fps={fps}
         comments={comments}
-        onSeek={handleSeek}
+        thumbnailSrc={thumbnailSrc}
+        thumbnailMimeType={thumbnailMimeType}
+        mainVideoRef={videoRef}
+        onSeek={handleScrubSeek}
         onCommentClick={onCommentClick}
+        onScrubStart={startScrubbing}
+        onScrubEnd={endScrubbing}
       />
 
       {/* ── Bottom controls row ───────────────────────────────────────── */}