Timeline.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. 'use client';
  2. import { useRef, useCallback, useEffect, useState } from 'react';
  3. import { Comment } from '../../lib/api';
  4. import { formatTimecode } from '../../lib/format';
  5. interface Props {
  6. duration: number;
  7. currentTime: number;
  8. fps: number;
  9. comments: Comment[];
  10. onSeek: (time: number) => void;
  11. onCommentClick: (comment: Comment) => void;
  12. }
  13. export function Timeline({ duration, currentTime, fps, comments, onSeek, onCommentClick }: Props) {
  14. const trackRef = useRef<HTMLDivElement>(null);
  15. // ── Drag state ─────────────────────────────────────────────────────────────
  16. // draggingRef: controls video seeks during drag (written by handlers, read by effect)
  17. const draggingRef = useRef(false);
  18. // isDragging: React state — controls visual active state (scrubber scale, tooltip)
  19. const [isDragging, setIsDragging] = useState(false);
  20. // draggedTime: tracks the visual position of the scrubber during drag, decoupled from
  21. // the actual video time so the scrubber never lags behind the cursor
  22. const [draggedTime, setDraggedTime] = useState(currentTime);
  23. // tooltipX: CSS left position (%) for tooltip positioning during drag
  24. const [tooltipX, setTooltipX] = useState(0);
  25. // ── Sync display time when NOT dragging ──────────────────────────────────
  26. useEffect(() => {
  27. if (draggingRef.current) return;
  28. setDraggedTime(currentTime);
  29. }, [currentTime]);
  30. // ── Cursor → time ────────────────────────────────────────────────────────
  31. const cursorToTime = useCallback((clientX: number): number | null => {
  32. if (!trackRef.current) return null;
  33. const rect = trackRef.current.getBoundingClientRect();
  34. const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
  35. return ratio * duration;
  36. }, [duration]);
  37. const cursorToPercent = useCallback((clientX: number): number => {
  38. if (!trackRef.current) return 0;
  39. const rect = trackRef.current.getBoundingClientRect();
  40. return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
  41. }, []);
  42. // ── Core seek — updates display + video ──────────────────────────────────
  43. const seekTo = useCallback((t: number) => {
  44. const clamped = Math.max(0, Math.min(duration, t));
  45. setDraggedTime(clamped);
  46. onSeek(clamped);
  47. }, [duration, onSeek]);
  48. // ── Mouse handlers (desktop) ─────────────────────────────────────────────
  49. const handleMouseDown = useCallback((e: React.MouseEvent) => {
  50. if (e.button !== 0) return;
  51. e.preventDefault();
  52. // Reset stale drag state
  53. draggingRef.current = false;
  54. setIsDragging(false);
  55. // Begin drag
  56. draggingRef.current = true;
  57. setIsDragging(true);
  58. const t = cursorToTime(e.clientX);
  59. if (t === null) return;
  60. seekTo(t);
  61. setTooltipX(cursorToPercent(e.clientX));
  62. // Capture pointer so mouseup always fires even if cursor leaves the track
  63. trackRef.current?.setPointerCapture((e as unknown as React.PointerEvent).pointerId);
  64. }, [cursorToTime, cursorToPercent, seekTo]);
  65. const handleMouseMove = useCallback((e: MouseEvent) => {
  66. if (!draggingRef.current) return;
  67. const t = cursorToTime(e.clientX);
  68. if (t === null) return;
  69. seekTo(t);
  70. setTooltipX(cursorToPercent(e.clientX));
  71. }, [cursorToTime, cursorToPercent, seekTo]);
  72. const handleMouseUp = useCallback((e: MouseEvent) => {
  73. if (!draggingRef.current) return;
  74. draggingRef.current = false;
  75. setIsDragging(false);
  76. const t = cursorToTime(e.clientX);
  77. if (t !== null) seekTo(t);
  78. }, [cursorToTime, seekTo]);
  79. // ── Touch handlers (mobile) ───────────────────────────────────────────────
  80. const handleTouchStart = useCallback((e: React.TouchEvent) => {
  81. if (e.touches.length !== 1) return;
  82. const touch = e.touches[0];
  83. draggingRef.current = true;
  84. setIsDragging(true);
  85. const t = cursorToTime(touch.clientX);
  86. if (t !== null) seekTo(t);
  87. setTooltipX(cursorToPercent(touch.clientX));
  88. }, [cursorToTime, cursorToPercent, seekTo]);
  89. const handleTouchMove = useCallback((e: React.TouchEvent) => {
  90. if (!draggingRef.current || e.touches.length !== 1) return;
  91. const touch = e.touches[0];
  92. const t = cursorToTime(touch.clientX);
  93. if (t !== null) seekTo(t);
  94. setTooltipX(cursorToPercent(touch.clientX));
  95. }, [cursorToTime, cursorToPercent, seekTo]);
  96. const handleTouchEnd = useCallback((e: React.TouchEvent) => {
  97. if (!draggingRef.current) return;
  98. draggingRef.current = false;
  99. setIsDragging(false);
  100. const touch = e.changedTouches[0];
  101. if (touch) {
  102. const t = cursorToTime(touch.clientX);
  103. if (t !== null) seekTo(t);
  104. }
  105. }, [cursorToTime, seekTo]);
  106. // ── Global mouse listeners (desktop drag) ─────────────────────────────────
  107. useEffect(() => {
  108. window.addEventListener('mousemove', handleMouseMove);
  109. window.addEventListener('mouseup', handleMouseUp);
  110. return () => {
  111. window.removeEventListener('mousemove', handleMouseMove);
  112. window.removeEventListener('mouseup', handleMouseUp);
  113. };
  114. }, [handleMouseMove, handleMouseUp]);
  115. // ── Scrubber position ─────────────────────────────────────────────────────
  116. const progress = duration > 0 ? (draggedTime / duration) * 100 : 0;
  117. return (
  118. <div className="relative py-3 select-none">
  119. {/* Comment tick marks */}
  120. <div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-0 pointer-events-none">
  121. {comments.map(comment => {
  122. if (comment.timestamp == null) return null;
  123. const pos = duration > 0 ? ((comment.timestamp) / duration) * 100 : 0;
  124. return (
  125. <button
  126. key={comment.id}
  127. className="absolute top-1/2 -translate-y-1/2 w-0.5 h-3 rounded-full transition-transform hover:h-4"
  128. style={{
  129. left: `${pos}%`,
  130. backgroundColor: comment.resolveStatus === 'RESOLVED' ? '#22c55e' : '#818CF8',
  131. pointerEvents: 'auto',
  132. }}
  133. onClick={(e) => {
  134. e.stopPropagation();
  135. onCommentClick(comment);
  136. }}
  137. title={`${comment.user?.name ?? 'Unknown'}: ${comment.content.slice(0, 60)}`}
  138. />
  139. );
  140. })}
  141. </div>
  142. {/* ── Seek bar ──────────────────────────────────────────────────── */}
  143. <div
  144. ref={trackRef}
  145. className="relative h-2 md:h-1.5 bg-white/10 rounded-full cursor-pointer"
  146. onMouseDown={handleMouseDown}
  147. onTouchStart={handleTouchStart}
  148. onTouchMove={handleTouchMove}
  149. onTouchEnd={handleTouchEnd}
  150. style={{ touchAction: 'none' }}
  151. >
  152. {/* Played / scrubbed portion */}
  153. <div
  154. className="absolute left-0 top-0 h-full bg-indigo-500 rounded-full will-change-width"
  155. style={{ width: `${progress}%` }}
  156. />
  157. {/* Scrubber — active state: indigo glow + scale up */}
  158. <div
  159. className="absolute top-1/2 -translate-y-1/2 rounded-full will-change-transform"
  160. style={{
  161. left: `calc(${progress}% - 8px)`,
  162. width: isDragging ? '18px' : '14px',
  163. height: isDragging ? '18px' : '14px',
  164. marginLeft: isDragging ? '-2px' : '0px',
  165. marginTop: isDragging ? '-2px' : '0px',
  166. background: 'white',
  167. border: '2px solid #818CF8',
  168. boxShadow: isDragging
  169. ? '0 0 0 4px rgba(99,102,241,0.25), 0 0 12px rgba(99,102,241,0.4)'
  170. : '0 1px 4px rgba(0,0,0,0.3)',
  171. transition: isDragging ? 'none' : 'width 0.12s, height 0.12s, margin 0.12s, box-shadow 0.12s',
  172. }}
  173. />
  174. </div>
  175. {/* ── Timecode tooltip — appears above scrubber during drag ───────── */}
  176. {isDragging && (
  177. <div
  178. className="absolute bottom-full mb-2 px-2 py-1 rounded-lg text-xs font-mono pointer-events-none whitespace-nowrap z-10"
  179. style={{
  180. left: `clamp(40px, ${tooltipX}%, calc(100% - 40px))`,
  181. transform: 'translateX(-50%)',
  182. background: 'rgba(10,11,20,0.95)',
  183. border: '1px solid rgba(99,102,241,0.40)',
  184. color: '#A5B4FC',
  185. backdropFilter: 'blur(8px)',
  186. }}
  187. >
  188. {formatTimecode(draggedTime, fps, duration)}
  189. {/* Tooltip pointer — points down to scrubber */}
  190. <div
  191. style={{
  192. position: 'absolute',
  193. top: '100%',
  194. left: '50%',
  195. transform: 'translateX(-50%)',
  196. width: 0,
  197. height: 0,
  198. borderLeft: '5px solid transparent',
  199. borderRight: '5px solid transparent',
  200. borderTop: '5px solid rgba(99,102,241,0.40)',
  201. }}
  202. />
  203. </div>
  204. )}
  205. {/* Timecode display */}
  206. <div className="flex justify-between mt-1.5 text-xs text-gray-400 font-mono">
  207. <span>{formatTimecode(draggedTime, fps, duration)}</span>
  208. <span>{formatTimecode(duration, fps, duration)}</span>
  209. </div>
  210. </div>
  211. );
  212. }