Initial commit
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
"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<HTMLVideoElement | null>;
|
||||
onSeek: (frame: number) => void;
|
||||
}
|
||||
|
||||
export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSeek }: FrameTimelineProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(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<number>();
|
||||
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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="frame-timeline w-full cursor-ew-resize"
|
||||
style={{ height: 48 }}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user