import { useTranslation } from "@browser-skill/i18n/react"; import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState, } from "react"; import recordIconUrl from "../../assets/record-icon.png"; export interface RecordRequestData { id: string; /** * Epoch ms when the recording began. Supplied by the background so the timer * keeps counting the whole session across navigations; falls back to mount * time when the background could not report it. */ startedAtMs?: number; onFinish: () => void; } type Props = { request: RecordRequestData | null; }; const VIEWPORT_MARGIN = 16; const DRAG_THRESHOLD_PX = 8; const INTRO_DURATION_MS = 10_000; const TIMER_REFRESH_MS = 250; function formatElapsed(seconds: number): string { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function clampDragPos( top: number, left: number, panelW: number, panelH: number, ): { top: number; left: number } { const maxLeft = window.innerWidth - panelW - VIEWPORT_MARGIN; const maxTop = window.innerHeight - panelH - VIEWPORT_MARGIN; return { top: clamp(top, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, maxTop)), left: clamp(left, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, maxLeft)), }; } export function RecordOverlay({ request }: Props) { const { t } = useTranslation("extension"); const [show, setShow] = useState(false); const [elapsedSeconds, setElapsedSeconds] = useState(0); const [dragPos, setDragPos] = useState<{ top: number; left: number } | null>(null); const [dragging, setDragging] = useState(false); const pillRef = useRef(null); const dragStartRef = useRef<{ pointerX: number; pointerY: number; originLeft: number; originTop: number; panelW: number; panelH: number; armed: boolean; } | null>(null); useEffect(() => { if (request) { const raf = requestAnimationFrame(() => setShow(true)); return () => cancelAnimationFrame(raf); } setShow(false); }, [request]); const requestStartedAtMs = request?.startedAtMs; useEffect(() => { setDragPos(null); setDragging(false); dragStartRef.current = null; if (!request) { setElapsedSeconds(0); return; } const startedAt = requestStartedAtMs ?? Date.now(); const updateElapsed = () => { setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1_000))); }; updateElapsed(); const timer = window.setInterval(updateElapsed, TIMER_REFRESH_MS); return () => window.clearInterval(timer); }, [request?.id, requestStartedAtMs]); const onPillPointerDown = useCallback((e: ReactPointerEvent) => { if (e.button !== 0) return; if (e.target instanceof Element && e.target.closest("button, a, input, textarea, select")) { return; } const pill = pillRef.current; if (!pill) return; const rect = pill.getBoundingClientRect(); dragStartRef.current = { pointerX: e.clientX, pointerY: e.clientY, originLeft: rect.left, originTop: rect.top, panelW: pill.offsetWidth || rect.width, panelH: pill.offsetHeight || rect.height, armed: false, }; }, []); useEffect(() => { const onMove = (e: PointerEvent) => { const start = dragStartRef.current; if (!start) return; const dx = e.clientX - start.pointerX; const dy = e.clientY - start.pointerY; if (!start.armed) { if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; start.armed = true; setDragging(true); setDragPos({ top: start.originTop, left: start.originLeft }); } setDragPos( clampDragPos(start.originTop + dy, start.originLeft + dx, start.panelW, start.panelH), ); }; const onUp = () => { const start = dragStartRef.current; if (!start) return; if (start.armed) { setDragging(false); } dragStartRef.current = null; }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); return () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); }; }, []); if (!request) return null; const introVisible = elapsedSeconds * 1_000 < INTRO_DURATION_MS; const positioned = dragPos !== null; const defaultTransform = show ? "translateX(-50%) translateY(0)" : "translateX(-50%) translateY(8px)"; return ( <>
{introVisible ? t("recordOverlay.recording") : formatElapsed(elapsedSeconds)}
); }