| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- '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<HTMLDivElement>(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 (
- <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 => {
- if (comment.timestamp == null) return null;
- const pos = duration > 0 ? ((comment.timestamp) / duration) * 100 : 0;
- return (
- <button
- key={comment.id}
- className="absolute top-1/2 -translate-y-1/2 w-0.5 h-3 rounded-full transition-transform hover:h-4"
- style={{
- left: `${pos}%`,
- backgroundColor: comment.resolveStatus === 'RESOLVED' ? '#22c55e' : '#818CF8',
- pointerEvents: 'auto',
- }}
- onClick={(e) => {
- e.stopPropagation();
- onCommentClick(comment);
- }}
- title={`${comment.user?.name ?? 'Unknown'}: ${comment.content.slice(0, 60)}`}
- />
- );
- })}
- </div>
- {/* ── Seek bar ──────────────────────────────────────────────────── */}
- <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' }}
- >
- {/* 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 */}
- <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)',
- }}
- >
- {formatTimecode(draggedTime, fps, duration)}
- {/* Tooltip pointer — points down to scrubber */}
- <div
- 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)',
- }}
- />
- </div>
- )}
- {/* Timecode display */}
- <div className="flex justify-between mt-1.5 text-xs text-gray-400 font-mono">
- <span>{formatTimecode(draggedTime, fps, duration)}</span>
- <span>{formatTimecode(duration, fps, duration)}</span>
- </div>
- </div>
- );
- }
|