'use client'; import { useRef, useCallback, useEffect, useState } from 'react'; import { Comment } from '../../lib/api'; import { formatTimecode } from '../../lib/format'; interface Props { duration: number; currentTime: number; fps: number; comments: Comment[]; onSeek: (time: number) => void; onCommentClick: (comment: Comment) => void; } export function Timeline({ duration, currentTime, fps, comments, onSeek, onCommentClick }: Props) { const trackRef = useRef(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 ────────────────────────────────── useEffect(() => { if (draggingRef.current) return; setDraggedTime(currentTime); }, [currentTime]); // ── Cursor → time ──────────────────────────────────────────────────────── const cursorToTime = useCallback((clientX: number): number | null => { if (!trackRef.current) return null; const rect = trackRef.current.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); return ratio * duration; }, [duration]); 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)); setDraggedTime(clamped); onSeek(clamped); }, [duration, onSeek]); // ── 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); const t = cursorToTime(e.clientX); if (t === null) return; seekTo(t); setTooltipX(cursorToPercent(e.clientX)); // 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]); 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]; draggingRef.current = true; 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]; const t = cursorToTime(touch.clientX); if (t !== null) seekTo(t); setTooltipX(cursorToPercent(touch.clientX)); }, [cursorToTime, cursorToPercent, seekTo]); const handleTouchEnd = useCallback((e: React.TouchEvent) => { 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]); // ── 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]); // ── Scrubber position ───────────────────────────────────────────────────── const progress = duration > 0 ? (draggedTime / duration) * 100 : 0; return (
{/* Comment tick marks */}
{comments.map(comment => { if (comment.timestamp == null) return null; const pos = duration > 0 ? ((comment.timestamp) / duration) * 100 : 0; return (
{/* ── Seek bar ──────────────────────────────────────────────────── */}
{/* Played / scrubbed portion */}
{/* Scrubber — active state: indigo glow + scale up */}
{/* ── Timecode tooltip — appears above scrubber during drag ───────── */} {isDragging && (
{formatTimecode(draggedTime, fps, duration)} {/* Tooltip pointer — points down to scrubber */}
)} {/* Timecode display */}
{formatTimecode(draggedTime, fps, duration)} {formatTimecode(duration, fps, duration)}
); }