import { IconAlertTriangle, IconLoader2, IconPlayerPauseFilled, IconPlayerPlayFilled, IconRefresh, IconTrash, } from "@tabler/icons-react"; import { invoke } from "@tauri-apps/api/core"; import { LogicalSize } from "@tauri-apps/api/dpi"; import { emit, listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useEffect, useRef, useState } from "react"; import type { FocusEvent } from "react"; const OVERLAY_SHADOW_GUTTER = 18; const TOOLBAR_CONTENT_WIDTH = 72; const TOOLBAR_COLLAPSED_HEIGHT = 150; // Collapsed content box (150 − 20 padding = 130) holds the centered primary // zone; the expanded height must fit that fixed 130 zone + the 88px hover // actions (+2 margin) + 20 vertical padding so nothing clips on hover. const TOOLBAR_EXPANDED_HEIGHT = 240; const TOOLBAR_WINDOW_WIDTH = TOOLBAR_CONTENT_WIDTH + OVERLAY_SHADOW_GUTTER * 2; const TOOLBAR_COLLAPSED_WINDOW_HEIGHT = TOOLBAR_COLLAPSED_HEIGHT + OVERLAY_SHADOW_GUTTER * 2; const TOOLBAR_EXPANDED_WINDOW_HEIGHT = TOOLBAR_EXPANDED_HEIGHT + OVERLAY_SHADOW_GUTTER * 2; /** * Floating recording toolbar — vertical pill anchored to the LEFT edge of * the screen (Loom's placement). Big orange Stop at the top, elapsed time * below, pause underneath. On hover, it grows downward to expose restart * and cancel controls. Pure command emitter — the popover owns the * MediaRecorder. * * IPC contract: * receives → `clips:recorder-state` { paused, elapsedMs } * emits → `clips:recorder-stop`, `:pause`, `:resume`, `:restart`, `:cancel` * * IMPORTANT: The Stop button MUST NOT close its own window. The popover's * recorder listener is what drives the stop flow, and it invokes * `hide_overlays` from the Rust side once the MediaRecorder has been * flushed. Closing the toolbar window synchronously here races the * IPC delivery: Tauri's `emit()` promise resolves when the event is * queued on the wire, not when listeners have run — if we immediately * `.close()` the emitting window, the popover listener can miss the * event entirely (observed as: toolbar disappears, nothing else * happens, user has to hit the tray icon to actually stop the * recording). Let the recorder own the close. */ export function Toolbar() { const [paused, setPaused] = useState(false); const [elapsed, setElapsed] = useState(0); const [pendingAction, setPendingAction] = useState< "stop" | "restart" | "cancel" | null >(null); // Pre-record mode: the toolbar shows alongside the pre-record bubble so // the user can drag both around and position them before hitting Start. // Stop / Pause are disabled until the recorder actually begins, at which // point `clips:toolbar-enabled` fires with `true` from the recorder. const [enabled, setEnabled] = useState(false); const [preparing, setPreparing] = useState(false); const [popoverVisible, setPopoverVisible] = useState(true); const [diskSpaceLevel, setDiskSpaceLevel] = useState< "ok" | "warning" | "critical" >("ok"); const fallbackTimer = useRef | null>(null); const expandedRef = useRef(false); const pauseTransitionRef = useRef<"pause" | "resume" | null>(null); const pauseTransitionTimerRef = useRef | null>( null, ); function clearPauseTransition() { pauseTransitionRef.current = null; if (pauseTransitionTimerRef.current) { clearTimeout(pauseTransitionTimerRef.current); pauseTransitionTimerRef.current = null; } } useEffect(() => { const unlistens: Array<() => void> = []; let stopped = false; // Same race-safe listen tracker as elsewhere: if this effect // cleans up before `listen()` resolves, the unlisten is called // immediately — otherwise the listener lingers for the life of // the webview, holding the setState closures captive. const trackListen = (p: Promise<() => void>) => { p.then((u) => { if (stopped) { try { u(); } catch { // ignore } return; } unlistens.push(u); }).catch(() => { // ignore }); }; trackListen( listen<{ paused: boolean; elapsedMs: number }>( "clips:recorder-state", (ev) => { const nextPaused = !!ev.payload.paused; const pendingTransition = pauseTransitionRef.current; const transitionReached = (pendingTransition === "pause" && nextPaused) || (pendingTransition === "resume" && !nextPaused); // Native pause/resume can take a beat. Keep the first click's // optimistic state instead of letting an in-flight timer tick briefly // flip the button back and invite duplicate clicks. if (!pendingTransition || transitionReached) { setPaused(nextPaused); } if (transitionReached) clearPauseTransition(); setElapsed(ev.payload.elapsedMs ?? 0); }, ), ); trackListen( listen("clips:toolbar-enabled", (ev) => { setEnabled(!!ev.payload); setPreparing(false); setPendingAction(null); if (!ev.payload) { setDiskSpaceLevel("ok"); setPaused(false); setElapsed(0); } }), ); trackListen( listen("clips:toolbar-preparing", (ev) => { setPreparing(!!ev.payload); }), ); trackListen( listen("clips:popover-visible", (ev) => { setPopoverVisible(!!ev.payload); }), ); trackListen( listen<{ freeMb: number }>("clips:disk-space-warning", () => { setDiskSpaceLevel((prev) => prev === "critical" ? "critical" : "warning", ); }), ); trackListen( listen<{ freeMb: number }>("clips:disk-space-critical", () => { setDiskSpaceLevel("critical"); }), ); trackListen( listen<{ freeMb: number }>("clips:disk-space-ok", () => { setDiskSpaceLevel("ok"); }), ); return () => { stopped = true; unlistens.forEach((u) => { try { u(); } catch { // ignore } }); unlistens.length = 0; if (fallbackTimer.current) { clearTimeout(fallbackTimer.current); fallbackTimer.current = null; } clearPauseTransition(); }; }, []); function scheduleCloseFallback(action: string) { fallbackTimer.current = setTimeout(() => { console.warn( `[clips-toolbar] recorder did not close toolbar within 3s after ${action} — self-closing`, ); getCurrentWindow() .close() .catch(() => {}); }, 3_000); } function resizeToolbarWindow(expanded: boolean) { if (expandedRef.current === expanded) return; expandedRef.current = expanded; const height = expanded ? TOOLBAR_EXPANDED_WINDOW_HEIGHT : TOOLBAR_COLLAPSED_WINDOW_HEIGHT; getCurrentWindow() .setSize(new LogicalSize(TOOLBAR_WINDOW_WIDTH, height)) .catch((err) => { console.warn("[clips-toolbar] resize failed", err); }); } function stop() { if (pendingAction || !enabled) return; setPendingAction("stop"); console.log("[clips-toolbar] stop clicked — emitting clips:recorder-stop"); emit("clips:recorder-stop") .then(() => scheduleCloseFallback("stop")) .catch((err) => { console.error("[clips-toolbar] emit clips:recorder-stop failed:", err); setPendingAction(null); }); // Defensive fallback: the recorder normally closes us via // `hide_overlays` within a second or two. If for any reason the // popover listener never fires (popover window closed, listener // torn down mid-emit, etc.), self-close after 3s so the user isn't // left with a zombie pill floating over their screen. The recorder // closing us first is a no-op on the already-closed window. } const isPreparing = preparing || (!enabled && !popoverVisible); function togglePause() { if (!enabled || pendingAction) return; const transition = paused ? "resume" : "pause"; clearPauseTransition(); pauseTransitionRef.current = transition; setPaused(transition === "pause"); pauseTransitionTimerRef.current = setTimeout(clearPauseTransition, 3_000); emit(`clips:recorder-${transition}`).catch((err) => { console.error( `[clips-toolbar] emit clips:recorder-${transition} failed:`, err, ); clearPauseTransition(); setPaused(paused); }); } function activatePauseFromPointer(e: React.PointerEvent) { if (e.button !== 0) return; // The native toolbar resizes as the pointer enters it. Dispatch on // pointer-down so that resize/focus changes cannot cancel the subsequent // click, while the click handler below remains available to keyboards. e.preventDefault(); togglePause(); } function restart() { if (pendingAction || !enabled) return; setPendingAction("restart"); console.log( "[clips-toolbar] restart clicked — emitting clips:recorder-restart", ); emit("clips:recorder-restart") .then(() => scheduleCloseFallback("restart")) .catch((err) => { console.error( "[clips-toolbar] emit clips:recorder-restart failed:", err, ); setPendingAction(null); }); } function cancel() { if (pendingAction || !enabled) return; setPendingAction("cancel"); console.log( "[clips-toolbar] cancel clicked — emitting clips:recorder-cancel", ); emit("clips:recorder-cancel") .then(() => scheduleCloseFallback("cancel")) .catch((err) => { console.error( "[clips-toolbar] emit clips:recorder-cancel failed:", err, ); setPendingAction(null); }); } // Same explicit-drag pattern the bubble uses — `data-tauri-drag-region` // has been unreliable across iterations so we call `startDragging()` // directly on mousedown. Interactive controls are marked `data-no-drag` // so their clicks reach onClick instead of starting a drag. const handleToolbarMouseDown = (e: React.MouseEvent) => { if (e.button !== 0) return; const target = e.target as HTMLElement; if (target.closest("[data-no-drag]")) return; getCurrentWindow() .startDragging() .catch((err) => { console.warn("[clips-toolbar] startDragging failed", err); }); }; const handleToolbarBlur = (e: FocusEvent) => { if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; resizeToolbarWindow(false); }; const pendingActionLabel = pendingAction === "restart" ? "Restarting..." : pendingAction === "cancel" ? "Cancelling..." : "Stopping..."; return (
resizeToolbarWindow(true)} onMouseLeave={() => resizeToolbarWindow(false)} onFocusCapture={() => resizeToolbarWindow(true)} onBlurCapture={handleToolbarBlur} > {/* Primary controls live in a fixed-height zone so they stay pinned to the same vertical position whether or not the pill is hovered. Centering happens INSIDE this zone (not on the pill), so the collapsed→expanded `justify-content` change can't nudge the Stop button up — only the hover actions below grow into the new space. */}
{diskSpaceLevel !== "ok" && (
)}
); } function formatTime(ms: number): string { const total = Math.max(0, Math.floor(ms / 1000)); const m = Math.floor(total / 60); const s = total % 60; return `${m}:${s.toString().padStart(2, "0")}`; }