import { useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { scrubberFillPercent, scrubberPositionFromClientX, } from "./scrubber-position"; export interface ScrubberProps { currentMs: number; durationMs: number; onSeek: (ms: number) => void; comments?: { id: string; videoTimestampMs: number; content: string }[]; chapters?: { startMs: number; title: string }[]; reactions?: { id: string; emoji: string; videoTimestampMs: number }[]; excludedRanges?: { startMs: number; endMs: number }[]; } export function Scrubber(props: ScrubberProps) { const { currentMs, durationMs, onSeek, comments, chapters, reactions, excludedRanges, } = props; const barRef = useRef(null); const activePointerIdRef = useRef(null); const [hoverMs, setHoverMs] = useState(null); const [hoverX, setHoverX] = useState(0); const [dragging, setDragging] = useState(false); const [tooltip, setTooltip] = useState< | { kind: "comment"; content: string; ms: number } | { kind: "chapter"; title: string; ms: number } | null >(null); const pct = scrubberFillPercent(currentMs, durationMs); const recentReactions = useMemo( () => (reactions ? reactions.slice(-50) : []), [reactions], ); function positionFromClientX(clientX: number): { ms: number; x: number } { const el = barRef.current; if (!el) return { ms: 0, x: 0 }; return scrubberPositionFromClientX( clientX, el.getBoundingClientRect(), durationMs, ); } function seekFromClientX(clientX: number): void { const next = positionFromClientX(clientX); setHoverX(next.x); setHoverMs(next.ms); onSeek(next.ms); } function onPointerDown(e: React.PointerEvent) { if (e.pointerType === "mouse" && e.button !== 0) return; e.preventDefault(); e.stopPropagation(); activePointerIdRef.current = e.pointerId; setDragging(true); try { e.currentTarget.setPointerCapture(e.pointerId); } catch { // Older test/browser environments may not implement pointer capture. } seekFromClientX(e.clientX); } function onPointerMove(e: React.PointerEvent) { if (activePointerIdRef.current === e.pointerId) { e.preventDefault(); seekFromClientX(e.clientX); return; } if (e.pointerType === "mouse") { const next = positionFromClientX(e.clientX); setHoverX(next.x); setHoverMs(next.ms); } } function endPointerDrag(e: React.PointerEvent) { if (activePointerIdRef.current !== e.pointerId) return; activePointerIdRef.current = null; setDragging(false); if (e.pointerType !== "mouse") setHoverMs(null); try { if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); } } catch { // Older test/browser environments may not implement pointer capture. } } const commentsByMs = useMemo(() => { const map = new Map(); (comments ?? []).forEach((c) => { // Bucket by 500ms so overlapping comments cluster. const key = Math.round(c.videoTimestampMs / 500) * 500; const list = map.get(key) ?? []; list.push({ id: c.id, content: c.content }); map.set(key, list); }); return map; }, [comments]); return (
{ activePointerIdRef.current = null; setDragging(false); }} onPointerLeave={() => { if (activePointerIdRef.current === null) setHoverMs(null); }} > {/* Hover bubble */} {hoverMs !== null && !tooltip ? (
{msToClock(hoverMs)}
) : null} {/* Tooltip (comment / chapter) */} {tooltip ? (
{tooltip.kind === "comment" ? tooltip.content : tooltip.title}
) : null}
{/* Filled portion */}
{/* Cut ranges */} {excludedRanges?.map((range, i) => { const startPct = (Math.max(0, range.startMs) / Math.max(1, durationMs)) * 100; const endPct = (Math.min(durationMs, range.endMs) / Math.max(1, durationMs)) * 100; return (
); })} {/* Chapter notches */} {chapters?.map((ch, i) => (
); } export function msToClock(ms: number): string { if (!isFinite(ms) || ms < 0) return "0:00"; const total = Math.floor(ms / 1000); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; if (h > 0) { return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; } return `${m}:${String(s).padStart(2, "0")}`; }