import "./overlay.css"; import { SCREEN_CAPTURE_FRAME_RATE } from "@shared/recording-capture"; import { captureExtensionError, initExtensionSentry } from "./sentry"; initExtensionSentry("overlay"); // The overlay runs as an extension-origin iframe injected into the page by the // content script. Each iframe renders one "part" of the Loom-style recording UI // (camera bubble, countdown, or control toolbar) selected via ?part=. Running at // the chrome-extension:// origin gives us a persistent camera permission grant, // CSS isolation from the host page, and direct chrome.runtime messaging with the // background service worker. type OverlayPhase = "idle" | "countdown" | "recording" | "paused" | "saving"; // Hover-expand heights for the vertical toolbar, posted to the content script // which owns the iframe size (the iframe can't resize itself). const TOOLBAR_COLLAPSED_H = 154; const TOOLBAR_EXPANDED_H = 236; function isDeviceUnavailableError(error: unknown): boolean { const name = error && typeof error === "object" && "name" in error ? String((error as { name?: unknown }).name) : ""; return ( name === "OverconstrainedError" || name === "NotFoundError" || name === "DevicesNotFoundError" ); } function cameraConstraint(deviceId: string): MediaTrackConstraints { const video: MediaTrackConstraints = { width: { ideal: 480 }, height: { ideal: 480 }, frameRate: { ideal: SCREEN_CAPTURE_FRAME_RATE, max: SCREEN_CAPTURE_FRAME_RATE, }, }; if (deviceId) video.deviceId = { exact: deviceId }; else video.facingMode = "user"; return video; } async function getCameraBubbleStream(deviceId: string): Promise { try { return await navigator.mediaDevices.getUserMedia({ video: cameraConstraint(deviceId), audio: false, }); } catch (error) { if (!deviceId || !isDeviceUnavailableError(error)) throw error; captureExtensionError( new Error("Selected Clips camera was unavailable; using default camera."), { tags: { surface: "overlay", mechanism: "camera-device-fallback" }, extra: { requestedDeviceId: deviceId, originalError: error instanceof Error ? { name: error.name, message: error.message } : String(error), }, }, ); return navigator.mediaDevices.getUserMedia({ video: cameraConstraint(""), audio: false, }); } } function postToolbarSize(height: number): void { try { window.parent.postMessage( { source: "clips-overlay", kind: "resize", part: "toolbar", height }, "*", ); } catch { /* parent gone */ } } function postToolbarDragStart(): void { try { window.parent.postMessage( { source: "clips-overlay", kind: "toolbar-drag-start", part: "toolbar" }, "*", ); } catch { /* parent gone */ } } function postCountdownFinished(): void { try { window.parent.postMessage( { source: "clips-overlay", kind: "countdown-finished", part: "countdown", }, "*", ); } catch { /* parent gone */ } } type OverlayState = { phase: OverlayPhase; baseElapsedMs: number; baseEpochMs: number; countdownEndsAtMs: number; }; const COUNTDOWN_FALLBACK = 3; const params = new URLSearchParams(location.search); const part = params.get("part"); const root = document.getElementById("root") as HTMLDivElement; function icon(paths: string): string { return `${paths}`; } const ICONS = { pause: icon( '', ), resume: icon(''), stop: icon( '', ), restart: icon(''), trash: icon( '', ), cameraOff: icon( '', ), cancel: icon(''), skipForward: icon( '', ), }; function send( type: string, extra: Record = {}, onComplete?: (ok: boolean) => void, ): void { try { chrome.runtime.sendMessage( { type, ...extra }, (response?: { ok?: boolean }) => { const error = chrome.runtime.lastError; onComplete?.(!error && response?.ok !== false); }, ); } catch { /* the background may be momentarily asleep; state will re-sync */ onComplete?.(false); } } /* ---------------------------------------------------------------- bubble --- */ function postBubble(kind: string, extra: Record = {}): void { try { window.parent.postMessage( { source: "clips-overlay", kind, part: "bubble", ...extra }, "*", ); } catch { /* parent gone */ } } async function initBubble(): Promise { const bubble = document.createElement("div"); bubble.className = "bubble"; const ring = document.createElement("div"); ring.className = "bubble-ring"; bubble.appendChild(ring); // Drag: the content script owns the iframe position, so we just signal the // start of a drag and it captures the pointer page-wide. bubble.style.cursor = "grab"; bubble.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; if ((e.target as HTMLElement).closest("[data-no-drag]")) return; e.preventDefault(); bubble.style.cursor = "grabbing"; postBubble("bubble-drag-start"); const restore = (): void => { bubble.style.cursor = "grab"; window.removeEventListener("pointerup", restore); }; window.addEventListener("pointerup", restore); }); // Size dots (small / large), revealed on hover — like the desktop bubble. const sizes = document.createElement("div"); sizes.className = "bubble-sizes"; sizes.setAttribute("data-no-drag", ""); for (const key of ["sm", "lg"] as const) { const dot = document.createElement("button"); dot.type = "button"; dot.className = `bubble-size-dot bubble-size-${key}`; dot.title = key === "sm" ? "Small" : "Large"; dot.setAttribute( "aria-label", key === "sm" ? "Small bubble" : "Large bubble", ); dot.setAttribute("data-no-drag", ""); dot.addEventListener("click", (e) => { e.stopPropagation(); postBubble("bubble-size", { size: key }); }); sizes.appendChild(dot); } bubble.appendChild(sizes); root.appendChild(bubble); let currentStream: MediaStream | null = null; let currentVideo: HTMLVideoElement | null = null; let healthTimer: number | undefined; let reconnectTimer: number | undefined; let unhealthyTicks = 0; let readyPosted = false; const postReadyOnce = (): void => { if (readyPosted) return; readyPosted = true; // Tell the host the feed is live so it can start the countdown — the "3" // shouldn't appear until the camera is actually showing. postBubble("camera-ready"); }; const videoDeviceId = await new Promise((resolve) => { try { chrome.storage.sync.get("videoDeviceId", (v) => resolve(typeof v.videoDeviceId === "string" ? v.videoDeviceId : ""), ); } catch { resolve(""); } }); const stopCurrentStream = (): void => { if (healthTimer !== undefined) window.clearInterval(healthTimer); healthTimer = undefined; if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer); reconnectTimer = undefined; currentVideo?.remove(); currentVideo = null; currentStream?.getTracks().forEach((track) => track.stop()); currentStream = null; unhealthyTicks = 0; }; const showCameraUnavailable = (): void => { stopCurrentStream(); ring.replaceChildren(); const empty = document.createElement("div"); empty.className = "bubble-empty"; empty.innerHTML = ICONS.cameraOff; ring.appendChild(empty); // Still release the countdown — a blocked/failed camera must not hang it. postReadyOnce(); }; const scheduleReconnect = (reason: string): void => { if (reconnectTimer !== undefined) return; reconnectTimer = window.setTimeout(() => { reconnectTimer = undefined; void connectCamera(reason); }, 500); }; const watchCameraHealth = ( video: HTMLVideoElement, track: MediaStreamTrack, ) => { healthTimer = window.setInterval(() => { const unhealthy = track.readyState !== "live" || track.muted || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || video.videoWidth === 0; unhealthyTicks = unhealthy ? unhealthyTicks + 1 : 0; if (unhealthyTicks >= 3) scheduleReconnect("camera stream stalled"); }, 2000); }; async function connectCamera(reason = "initial"): Promise { stopCurrentStream(); try { const stream = await getCameraBubbleStream(videoDeviceId); const video = document.createElement("video"); video.muted = true; video.autoplay = true; video.playsInline = true; video.srcObject = stream; ring.replaceChildren(video); currentStream = stream; currentVideo = video; const track = stream.getVideoTracks()[0]; track?.addEventListener("ended", () => scheduleReconnect("track ended")); track?.addEventListener("mute", () => scheduleReconnect("track muted")); video.addEventListener("stalled", () => scheduleReconnect("video stalled"), ); video.addEventListener("emptied", () => scheduleReconnect("video emptied"), ); await video.play().catch(() => undefined); console.log("[clips-overlay] camera bubble live", reason); postReadyOnce(); if (track) watchCameraHealth(video, track); } catch (err) { console.warn("[clips-overlay] camera getUserMedia failed:", err); captureExtensionError(err, { tags: { surface: "overlay", overlayPart: "bubble" }, extra: { reason }, }); showCameraUnavailable(); } } window.addEventListener("pagehide", stopCurrentStream, { once: true }); await connectCamera(); } /* ------------------------------------------------------------- countdown --- */ // The countdown only *visualizes* the worker's clock (state.countdownEndsAtMs). // The worker owns the real timer and starts the recorder, so this never *needs* // to signal "done" — which is what lets recording work on pages where no overlay // can be injected at all. The skip button is the one exception: it explicitly // asks the worker to start now (CLIPS_OVERLAY_COUNTDOWN_DONE → beginNow), which // is idempotent and harmless if the timer also fires. function initCountdown(): void { const wrap = document.createElement("div"); wrap.className = "countdown"; const controls = document.createElement("div"); controls.className = "countdown-controls"; const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; cancelBtn.className = "countdown-control countdown-control-cancel"; cancelBtn.setAttribute("aria-label", "Cancel recording"); cancelBtn.innerHTML = ICONS.cancel; cancelBtn.addEventListener("click", () => send("CLIPS_OVERLAY_CANCEL")); const number = document.createElement("div"); number.className = "countdown-number"; const skipBtn = document.createElement("button"); skipBtn.type = "button"; skipBtn.className = "countdown-control countdown-control-skip"; skipBtn.setAttribute("aria-label", "Skip countdown and start recording now"); skipBtn.innerHTML = ICONS.skipForward; skipBtn.addEventListener("click", () => send("CLIPS_OVERLAY_COUNTDOWN_DONE")); controls.append(cancelBtn, number, skipBtn); const hint = document.createElement("div"); hint.className = "countdown-hint"; hint.textContent = "Get ready…"; wrap.append(controls, hint); root.appendChild(wrap); // Each number is shown via a CHAINED setTimeout — the next step is scheduled // one second after the current one actually renders, not on a fixed interval. // This is deliberate: when the camera is slow to connect (e.g. an iPhone // Continuity Camera) it can hog the main thread and stall a tick. A setInterval // would then fire all the missed ticks back-to-back ("3"… then "2 1 Go" in a // burst); chaining means a stall only delays the next number, it never bursts. // At "Go" we tell the worker to start the recorder; the worker's own timer is // just a fallback for pages where no overlay can be injected. const STEP_MS = 1000; const steps = ["3", "2", "1", "Go"]; let doneSent = false; const showStep = (index: number): void => { const text = steps[index]; number.textContent = text; number.classList.toggle("countdown-go", text === "Go"); number.style.animation = "none"; void number.offsetWidth; number.style.animation = ""; if (text === "Go") { if (!doneSent) { doneSent = true; send("CLIPS_OVERLAY_COUNTDOWN_DONE"); postCountdownFinished(); } return; } window.setTimeout(() => showStep(index + 1), STEP_MS); }; showStep(0); // "3" immediately, then chain "2" → "1" → "Go" } /* --------------------------------------------------------------- toolbar --- */ // Vertical pill anchored to the LEFT edge — mirrors the desktop app's toolbar. // Big Stop on top, elapsed time, pause; on hover it grows to reveal restart + // cancel. Pure command emitter; the background owns the recorder. function initToolbar(): void { const pill = document.createElement("div"); pill.className = "toolbar-v"; pill.style.cursor = "grab"; // The content script owns iframe geometry, so ask it to capture the pointer // page-wide when the user drags the toolbar outside its current bounds. pill.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; if ((event.target as HTMLElement).closest("button")) return; event.preventDefault(); pill.style.cursor = "grabbing"; postToolbarDragStart(); const restore = (): void => { pill.style.cursor = "grab"; window.removeEventListener("pointerup", restore); }; window.addEventListener("pointerup", restore); }); const makeBtn = ( cls: string, title: string, svg: string, onClick: () => void, activateOnPointerDown = false, ): HTMLButtonElement => { const btn = document.createElement("button"); btn.type = "button"; btn.className = cls; btn.title = title; btn.setAttribute("aria-label", title); btn.innerHTML = svg; if (activateOnPointerDown) { btn.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; // The iframe grows when the pointer enters the toolbar. Act before that // resize/focus work can cancel the browser's later click event. event.preventDefault(); onClick(); }); btn.addEventListener("click", (event) => { // Pointer activation already ran above. Preserve native keyboard // activation, whose synthetic click has detail=0. if (event.detail === 0) onClick(); }); } else { btn.addEventListener("click", onClick); } return btn; }; const stopBtn = makeBtn( "toolbar-v-stop", "Stop & save", '', () => send("CLIPS_OVERLAY_STOP"), ); const time = document.createElement("div"); time.className = "toolbar-v-time"; const clock = document.createElement("span"); clock.textContent = "0:00"; time.append(clock); let pauseCommandPending = false; const pauseBtn = makeBtn( "toolbar-v-pause", "Pause", ICONS.pause, () => { if (pauseCommandPending) return; const resume = state.phase === "paused"; const command = resume ? "CLIPS_OVERLAY_RESUME" : "CLIPS_OVERLAY_PAUSE"; pauseCommandPending = true; // Make the control react to the first press even if waking the MV3 // service worker takes a moment. The worker remains authoritative and // immediately broadcasts the confirmed state. state.phase = resume ? "recording" : "paused"; toolbarRender?.(); send(command, {}, (ok) => { pauseCommandPending = false; if (!ok) send("CLIPS_OVERLAY_HELLO", { part }); }); }, true, ); const hoverGroup = document.createElement("div"); hoverGroup.className = "toolbar-v-hover-actions"; const restartBtn = makeBtn("toolbar-v-action", "Restart", ICONS.restart, () => send("CLIPS_OVERLAY_RESTART"), ); const cancelBtn = makeBtn( "toolbar-v-action toolbar-v-action-danger", "Discard", ICONS.trash, () => send("CLIPS_OVERLAY_CANCEL"), ); hoverGroup.append(restartBtn, cancelBtn); pill.append(stopBtn, time, pauseBtn, hoverGroup); root.appendChild(pill); // The iframe can't size itself, so ask the content script to grow/shrink it. pill.addEventListener("mouseenter", () => postToolbarSize(TOOLBAR_EXPANDED_H), ); pill.addEventListener("mouseleave", () => postToolbarSize(TOOLBAR_COLLAPSED_H), ); const render = (): void => { const paused = state.phase === "paused"; pill.classList.toggle("toolbar-v-paused", paused); pauseBtn.title = paused ? "Resume" : "Pause"; pauseBtn.innerHTML = paused ? ICONS.resume : ICONS.pause; const elapsed = paused ? state.baseElapsedMs : state.baseElapsedMs + Math.max(0, Date.now() - state.baseEpochMs); clock.textContent = formatDuration(elapsed); }; window.setInterval(render, 250); toolbarRender = render; render(); } /* ---------------------------------------------------------------- saving --- */ // Bottom-left "Saving…" card shown from Stop until the clip opens, mirroring the // desktop Finalizing overlay so the upload gap isn't a blank screen. function initSaving(): void { const card = document.createElement("div"); card.className = "saving-card"; const caption = document.createElement("div"); caption.className = "saving-caption"; caption.textContent = "Saving clip…"; const bar = document.createElement("div"); bar.className = "saving-bar"; const fill = document.createElement("div"); fill.className = "saving-bar-fill"; bar.appendChild(fill); // One progress indicator only — the indeterminate bar (no circular spinner). card.append(caption, bar); root.appendChild(card); } function formatDuration(ms: number): string { const total = Math.max(0, Math.floor(ms / 1000)); const minutes = Math.floor(total / 60); const seconds = total % 60; return `${minutes}:${String(seconds).padStart(2, "0")}`; } /* ----------------------------------------------------------------- state --- */ const state: OverlayState = { phase: "recording", baseElapsedMs: 0, baseEpochMs: Date.now(), countdownEndsAtMs: 0, }; let toolbarRender: (() => void) | null = null; chrome.runtime.onMessage.addListener((message) => { if (!message || typeof message !== "object") return; if ((message as { type?: unknown }).type !== "CLIPS_OVERLAY_STATE") return; const next = (message as { state?: Partial }).state; if (!next) return; if (typeof next.phase === "string") state.phase = next.phase as OverlayPhase; if (typeof next.baseElapsedMs === "number") state.baseElapsedMs = next.baseElapsedMs; if (typeof next.baseEpochMs === "number") state.baseEpochMs = next.baseEpochMs; if (typeof next.countdownEndsAtMs === "number") state.countdownEndsAtMs = next.countdownEndsAtMs; toolbarRender?.(); }); if (part === "bubble") void initBubble(); else if (part === "countdown") initCountdown(); else if (part === "toolbar") initToolbar(); else if (part === "saving") initSaving(); // Ask the background for the current state so a freshly-injected toolbar (e.g. // after the user navigated to a new page mid-recording) shows the right timer. send("CLIPS_OVERLAY_HELLO", { part });