"use client"; import { useRef, useEffect, useCallback, RefObject } from "react"; import { useReviewStore } from "@/hooks/use-review-player"; import { frameToPosition } from "@/lib/frame-utils"; import type { CommentWithReplies } from "@/types"; interface FrameTimelineProps { fps: number; comments: CommentWithReplies[]; annotations?: { frameNumber: number }[]; videoRef: RefObject; onSeek: (frame: number) => void; } export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSeek }: FrameTimelineProps) { const canvasRef = useRef(null); const isDragging = useRef(false); const { currentFrame, totalFrames } = useReviewStore(); const draw = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const W = canvas.width; const H = canvas.height; ctx.clearRect(0, 0, W, H); // Background ctx.fillStyle = "hsl(0 0% 6%)"; ctx.fillRect(0, 0, W, H); // Frame ruler ticks if (totalFrames > 0) { const tickInterval = Math.max(1, Math.floor(totalFrames / (W / 40))); ctx.strokeStyle = "hsl(0 0% 20%)"; ctx.lineWidth = 1; ctx.fillStyle = "hsl(0 0% 35%)"; ctx.font = "9px monospace"; ctx.textAlign = "center"; for (let f = 0; f <= totalFrames; f += tickInterval) { const x = Math.round((f / totalFrames) * W); const isMajor = f % (tickInterval * 5) === 0 || tickInterval > 20; ctx.strokeStyle = isMajor ? "hsl(0 0% 22%)" : "hsl(0 0% 16%)"; ctx.beginPath(); ctx.moveTo(x, isMajor ? 0 : H / 2); ctx.lineTo(x, H); ctx.stroke(); if (isMajor && f > 0) { ctx.fillStyle = "hsl(0 0% 35%)"; ctx.fillText(String(f), x, 11); } } } // Annotation markers — amber diamonds at top const seenAnnotationFrames = new Set(); annotations.forEach((ann) => { if (totalFrames === 0) return; if (seenAnnotationFrames.has(ann.frameNumber)) return; seenAnnotationFrames.add(ann.frameNumber); const x = Math.round(frameToPosition(ann.frameNumber, totalFrames) * W); // Amber diamond ctx.fillStyle = "hsl(38 92% 50%)"; ctx.beginPath(); ctx.moveTo(x, 2); ctx.lineTo(x + 4, 7); ctx.lineTo(x, 12); ctx.lineTo(x - 4, 7); ctx.closePath(); ctx.fill(); }); // Comment markers — blue triangles pointing down from top comments.forEach((comment) => { if (totalFrames === 0) return; const x = Math.round(frameToPosition(comment.frameNumber, totalFrames) * W); if (comment.isResolved) { ctx.fillStyle = "hsl(142 71% 45% / 0.7)"; } else { ctx.fillStyle = "hsl(213 94% 68%)"; } // Triangle marker pointing down from top ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 8); ctx.closePath(); ctx.fill(); }); // Playhead if (totalFrames > 0) { const playheadX = Math.round( frameToPosition(currentFrame, totalFrames) * W ); // Red line ctx.strokeStyle = "hsl(0 72% 51%)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(playheadX, 0); ctx.lineTo(playheadX, H); ctx.stroke(); // Red diamond at top ctx.fillStyle = "hsl(0 72% 51%)"; ctx.beginPath(); ctx.moveTo(playheadX, H - 4); ctx.lineTo(playheadX - 5, H - 10); ctx.lineTo(playheadX, H - 16); ctx.lineTo(playheadX + 5, H - 10); ctx.closePath(); ctx.fill(); } }, [currentFrame, totalFrames, comments, annotations]); // Resize observer to match canvas to container useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const observer = new ResizeObserver(() => { canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; draw(); }); observer.observe(canvas); return () => observer.disconnect(); }, [draw]); useEffect(() => { draw(); }, [draw]); // Seek on click/drag const getFrameFromEvent = useCallback( (e: React.MouseEvent | MouseEvent): number => { const canvas = canvasRef.current; if (!canvas || totalFrames === 0) return 0; const rect = canvas.getBoundingClientRect(); const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); return Math.round((x / rect.width) * totalFrames); }, [totalFrames] ); const handleMouseDown = (e: React.MouseEvent) => { isDragging.current = true; onSeek(getFrameFromEvent(e)); }; const handleMouseMove = useCallback( (e: MouseEvent) => { if (!isDragging.current) return; onSeek(getFrameFromEvent(e)); }, [getFrameFromEvent, onSeek] ); const handleMouseUp = useCallback(() => { isDragging.current = false; }, []); useEffect(() => { window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, [handleMouseMove, handleMouseUp]); return ( ); }