diff --git a/components/player/ReviewPlayer.tsx b/components/player/ReviewPlayer.tsx index 51e4537..1696747 100644 --- a/components/player/ReviewPlayer.tsx +++ b/components/player/ReviewPlayer.tsx @@ -15,6 +15,8 @@ import { AnnotationTools } from "@/components/annotations/AnnotationTools"; import { AspectOverlay } from "./AspectOverlay"; import { FrameTimeline } from "./FrameTimeline"; import { PlaybackControls } from "./PlaybackControls"; +import { useZoomPan } from "@/hooks/use-zoom-pan"; +import { Minus, Plus } from "lucide-react"; import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils"; import { cn } from "@/lib/utils"; import type { CommentWithReplies } from "@/types"; @@ -61,6 +63,8 @@ export const ReviewPlayer = forwardRef( const stableAnnotations = useMemo(() => annotations ?? [], [annotations]); const videoRef = useRef(null); const containerRef = useRef(null); + const stageRef = useRef(null); + const zoomLayerRef = useRef(null); const reverseIntervalRef = useRef | null>(null); const [isReversing, setIsReversing] = useState(false); @@ -79,6 +83,11 @@ export const ReviewPlayer = forwardRef( setTotalFrames, } = useReviewStore(); + const zoom = useZoomPan(stageRef, zoomLayerRef, { + gesturesEnabled: !isAnnotating, + }); + const { reset: resetZoom, zoomBy } = zoom; + // Initialize player config from props useEffect(() => { setFps(fps); @@ -92,7 +101,8 @@ export const ReviewPlayer = forwardRef( setCurrentTime(0); setDuration(0); setTotalFrames(0); - }, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames]); + resetZoom(); + }, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames, resetZoom]); // ── Playback state sync ────────────────────────────────────────────────── const handleTimeUpdate = useCallback(() => { @@ -222,6 +232,20 @@ export const ReviewPlayer = forwardRef( e.preventDefault(); toggleFullscreen(); break; + case "=": + case "+": + e.preventDefault(); + zoomBy(1.25); + break; + case "-": + case "_": + e.preventDefault(); + zoomBy(0.8); + break; + case "0": + e.preventDefault(); + resetZoom(); + break; } }; @@ -230,7 +254,7 @@ export const ReviewPlayer = forwardRef( window.removeEventListener("keydown", handleKeyDown); stopReverse(); }; - }, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback]); + }, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback, zoomBy, resetZoom]); // ── Fullscreen ─────────────────────────────────────────────────────────── const toggleFullscreen = () => { @@ -269,11 +293,28 @@ export const ReviewPlayer = forwardRef( className )} > - {/* Video — tap anywhere to play/pause (pointer-events pass through canvas when not annotating) */} + {/* Video — tap anywhere to play/pause (pointer-events pass through canvas when not annotating). + Wheel / pinch zooms, drag pans when zoomed; a drag never counts as a tap. */}
{ + if (!zoom.consumeDrag()) togglePlayback(); + } + : undefined + } > + {/* Zoomed as one layer so blanking and annotations stay pinned to the + picture — annotation coords are normalised from the canvas's + transformed bounding rect, so drawing while zoomed lands correctly. */} +
+ + {/* Zoom badge — only while zoomed */} + {zoom.zoomed && ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + + + {Math.round(zoom.scale * 100)}% + + + +
+ )} {/* JKL hint overlay — shown briefly when reversing */} {isReversing && ( diff --git a/hooks/use-zoom-pan.ts b/hooks/use-zoom-pan.ts new file mode 100644 index 0000000..354a461 --- /dev/null +++ b/hooks/use-zoom-pan.ts @@ -0,0 +1,245 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; + +export const MIN_ZOOM = 1; +export const MAX_ZOOM = 8; + +interface Transform { + scale: number; + x: number; + y: number; +} + +interface Pointer { + x: number; + y: number; +} + +type Gesture = + | { kind: "pan"; startX: number; startY: number; originX: number; originY: number } + | { + kind: "pinch"; + startDist: number; + // Content-space point that sat under the fingers' midpoint at the start; + // it is kept under the (moving) midpoint for the whole pinch. + contentX: number; + contentY: number; + startScale: number; + } + | null; + +/** Movement (px) past which a press counts as a drag rather than a click. */ +const DRAG_THRESHOLD = 4; + +/** + * Zoom and pan for the review player's picture. + * + * `stageRef` is the clipping box that receives gestures; `layerRef` is the + * element that gets transformed. The transform is written straight to the + * layer's style so panning never re-renders the player; only `scale` is state + * (for the zoom badge). + * + * - Wheel / trackpad pinch: zoom toward the cursor (always, even while drawing) + * - Drag (mouse, or one finger): pan, when zoomed and `gesturesEnabled` + * - Two-finger pinch: zoom + pan, when `gesturesEnabled` + */ +export function useZoomPan( + stageRef: RefObject, + layerRef: RefObject, + { gesturesEnabled }: { gesturesEnabled: boolean } +) { + const [scale, setScale] = useState(1); + const t = useRef({ scale: 1, x: 0, y: 0 }); + const pointers = useRef(new Map()); + const gesture = useRef(null); + const dragged = useRef(false); + const pressStart = useRef(null); + + const apply = useCallback( + (next: Transform) => { + const stage = stageRef.current; + const w = stage?.clientWidth ?? 0; + const h = stage?.clientHeight ?? 0; + const s = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, next.scale)); + // Keep the layer covering the stage: no empty gutter past the picture edge. + const x = Math.min(0, Math.max(w - w * s, next.x)); + const y = Math.min(0, Math.max(h - h * s, next.y)); + t.current = { scale: s, x, y }; + const layer = layerRef.current; + if (layer) { + layer.style.transform = + s === 1 ? "" : `translate(${x}px, ${y}px) scale(${s})`; + } + setScale(s); + }, + [stageRef, layerRef] + ); + + /** Zoom to `nextScale`, keeping the stage point (px, py) fixed on screen. */ + const zoomAt = useCallback( + (nextScale: number, px: number, py: number) => { + const { scale: s, x, y } = t.current; + const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, nextScale)); + const cx = (px - x) / s; + const cy = (py - y) / s; + apply({ scale: clamped, x: px - cx * clamped, y: py - cy * clamped }); + }, + [apply] + ); + + const zoomBy = useCallback( + (factor: number) => { + const stage = stageRef.current; + if (!stage) return; + zoomAt(t.current.scale * factor, stage.clientWidth / 2, stage.clientHeight / 2); + }, + [stageRef, zoomAt] + ); + + const reset = useCallback(() => apply({ scale: 1, x: 0, y: 0 }), [apply]); + + // Wheel needs a non-passive native listener to be able to preventDefault. + useEffect(() => { + const stage = stageRef.current; + if (!stage) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? stage.clientHeight : 1; + // Trackpad pinch arrives as ctrl+wheel with small deltas — make it snappier. + const k = e.ctrlKey ? 0.01 : 0.0015; + const rect = stage.getBoundingClientRect(); + zoomAt( + t.current.scale * Math.exp(-e.deltaY * unit * k), + e.clientX - rect.left, + e.clientY - rect.top + ); + }; + stage.addEventListener("wheel", onWheel, { passive: false }); + return () => stage.removeEventListener("wheel", onWheel); + }, [stageRef, zoomAt]); + + // Re-clamp when the player changes size (panel resize, fullscreen, rotate). + useEffect(() => { + const stage = stageRef.current; + if (!stage) return; + const observer = new ResizeObserver(() => apply(t.current)); + observer.observe(stage); + return () => observer.disconnect(); + }, [stageRef, apply]); + + const local = (e: React.PointerEvent): Pointer => { + const rect = stageRef.current!.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + }; + + const beginGesture = () => { + const pts = [...pointers.current.values()]; + const { scale: s, x, y } = t.current; + if (pts.length >= 2) { + const [a, b] = pts; + const mx = (a.x + b.x) / 2; + const my = (a.y + b.y) / 2; + gesture.current = { + kind: "pinch", + startDist: Math.hypot(a.x - b.x, a.y - b.y) || 1, + contentX: (mx - x) / s, + contentY: (my - y) / s, + startScale: s, + }; + } else if (pts.length === 1 && s > 1) { + gesture.current = { + kind: "pan", + startX: pts[0].x, + startY: pts[0].y, + originX: x, + originY: y, + }; + } else { + gesture.current = null; + } + }; + + const onPointerDown = (e: React.PointerEvent) => { + if (!gesturesEnabled || !stageRef.current) return; + if (e.pointerType === "mouse" && e.button !== 0) return; + const p = local(e); + if (pointers.current.size === 0) { + dragged.current = false; + pressStart.current = p; + } + pointers.current.set(e.pointerId, p); + if (pointers.current.size >= 2) dragged.current = true; // a pinch is never a tap + beginGesture(); + if (gesture.current) stageRef.current.setPointerCapture?.(e.pointerId); + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!pointers.current.has(e.pointerId)) return; + const p = local(e); + pointers.current.set(e.pointerId, p); + + const start = pressStart.current; + if (start && Math.hypot(p.x - start.x, p.y - start.y) > DRAG_THRESHOLD) { + dragged.current = true; + } + + const g = gesture.current; + if (!g) return; + if (g.kind === "pan") { + apply({ + scale: t.current.scale, + x: g.originX + (p.x - g.startX), + y: g.originY + (p.y - g.startY), + }); + } else { + const [a, b] = [...pointers.current.values()]; + if (!a || !b) return; + const mx = (a.x + b.x) / 2; + const my = (a.y + b.y) / 2; + const s = Math.min( + MAX_ZOOM, + Math.max(MIN_ZOOM, (g.startScale * Math.hypot(a.x - b.x, a.y - b.y)) / g.startDist) + ); + apply({ scale: s, x: mx - g.contentX * s, y: my - g.contentY * s }); + } + }; + + const onPointerEnd = (e: React.PointerEvent) => { + if (!pointers.current.delete(e.pointerId)) return; + // Lifting one finger of a pinch hands over to a pan from where it is now. + beginGesture(); + }; + + /** + * Call from the stage's click handler: true when the click is really the + * tail end of a drag/pinch and should not toggle playback. + */ + const consumeDrag = useCallback(() => { + const was = dragged.current; + dragged.current = false; + return was; + }, []); + + const zoomed = scale > 1; + + return { + scale, + zoomed, + zoomBy, + reset, + consumeDrag, + stageHandlers: { + onPointerDown, + onPointerMove, + onPointerUp: onPointerEnd, + onPointerCancel: onPointerEnd, + }, + stageStyle: { + // At 1x leave vertical page scrolling to the browser on phones, but keep + // pinch for us; once zoomed every touch belongs to the player. + touchAction: zoomed ? "none" : "pan-y", + cursor: gesturesEnabled && zoomed ? "grab" : undefined, + } as React.CSSProperties, + }; +}