"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, editRange } = 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); // Slate and handles — under the ruler so ticks still read across them const regions: { from: number; to: number; kind: "slate" | "handle" }[] = []; if (editRange && totalFrames > 0) { const { pictureStart, cutIn, tailStart, pictureEnd } = editRange; if (pictureStart > 0) regions.push({ from: 0, to: pictureStart, kind: "slate" }); if (cutIn > pictureStart) regions.push({ from: pictureStart, to: cutIn, kind: "handle" }); if (pictureEnd > tailStart) regions.push({ from: tailStart, to: pictureEnd, kind: "handle" }); } const xOf = (f: number) => Math.round((f / totalFrames) * W); for (const r of regions) { const x0 = xOf(r.from); const x1 = xOf(r.to); if (x1 <= x0) continue; if (r.kind === "slate") { ctx.fillStyle = "hsl(0 0% 2%)"; ctx.fillRect(x0, 0, x1 - x0, H); continue; } ctx.fillStyle = "hsl(38 92% 50% / 0.14)"; ctx.fillRect(x0, 0, x1 - x0, H); // Diagonal hatching, clipped to the region ctx.save(); ctx.beginPath(); ctx.rect(x0, 0, x1 - x0, H); ctx.clip(); ctx.strokeStyle = "hsl(38 92% 50% / 0.28)"; ctx.lineWidth = 1; for (let x = x0 - H; x < x1; x += 7) { ctx.beginPath(); ctx.moveTo(x, H); ctx.lineTo(x + H, 0); ctx.stroke(); } ctx.restore(); } // 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); } } } // Region labels and cut points, over the ruler for (const r of regions) { const x0 = xOf(r.from); const x1 = xOf(r.to); const label = r.kind === "slate" ? "SLATE" : "HANDLE"; ctx.font = "bold 9px monospace"; if (x1 - x0 >= ctx.measureText(label).width + 8) { ctx.fillStyle = r.kind === "slate" ? "hsl(0 0% 45%)" : "hsl(38 92% 60%)"; ctx.textAlign = "center"; ctx.fillText(label, (x0 + x1) / 2, H / 2 + 8); } } if (editRange && totalFrames > 0) { ctx.strokeStyle = "hsl(38 92% 55%)"; ctx.lineWidth = 1; for (const f of [editRange.cutIn, editRange.tailStart]) { if (f <= editRange.pictureStart || f >= editRange.pictureEnd) continue; const x = xOf(f) + 0.5; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); } } // 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, editRange]); // 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; }, []); // ── Touch scrubbing ────────────────────────────────────────────────────── const getFrameFromTouch = useCallback( (touch: { clientX: number }): number => { const canvas = canvasRef.current; if (!canvas || totalFrames === 0) return 0; const rect = canvas.getBoundingClientRect(); const x = Math.max(0, Math.min(touch.clientX - rect.left, rect.width)); return Math.round((x / rect.width) * totalFrames); }, [totalFrames] ); const handleTouchStart = useCallback( (e: React.TouchEvent) => { e.preventDefault(); const touch = e.touches[0]; if (!touch) return; isDragging.current = true; onSeek(getFrameFromTouch(touch)); }, [getFrameFromTouch, onSeek] ); const handleTouchMove = useCallback( (e: TouchEvent) => { if (!isDragging.current) return; e.preventDefault(); const touch = e.touches[0]; if (!touch) return; onSeek(getFrameFromTouch(touch)); }, [getFrameFromTouch, onSeek] ); useEffect(() => { window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); window.addEventListener("touchmove", handleTouchMove, { passive: false }); window.addEventListener("touchend", handleMouseUp); return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener("touchmove", handleTouchMove); window.removeEventListener("touchend", handleMouseUp); }; }, [handleMouseMove, handleMouseUp, handleTouchMove]); return ( ); }