diff --git a/apps/chrome-extension/src/background/service-worker.ts b/apps/chrome-extension/src/background/service-worker.ts index 937631bd2d7..166072e3050 100644 --- a/apps/chrome-extension/src/background/service-worker.ts +++ b/apps/chrome-extension/src/background/service-worker.ts @@ -501,7 +501,7 @@ const shouldAutoPipCaptureSource = (source: RecordingCaptureSource) => isWindowCaptureSource(source) && !isLikelyBrowserWindow(source); const isWebcamPreviewEnabled = (settings: ExtensionSettings) => - settings.webcam.enabled && Boolean(settings.webcam.deviceId); + Boolean(settings.webcam.enabled); const shouldShowWebcamPreview = async ( settings: ExtensionSettings, @@ -817,6 +817,29 @@ const broadcastOverlayHide = async () => { pendingPreviewTabId = null; }; +const broadcastOverlayCountdown = async ( + seconds: number, + durationMs: number, +) => { + const tabs = await getTabs(); + await Promise.all( + tabs.map((tab) => { + if (!canInjectIntoTab(tab) || tab.id === undefined) { + return undefined; + } + return sendOverlay( + tab.id, + { + type: "overlay-countdown", + seconds, + durationMs, + }, + false, + ).catch(() => undefined); + }), + ); +}; + const broadcastRecordingStatusToTabs = async (status: RecordingStatus) => { const message: RecordingStatusBroadcast = { target: "recording-status", @@ -1621,6 +1644,15 @@ const handleRequest = async ( : { ok: false, error: response.error }; } + if (message.type === "toggle-microphone-mute") { + const response = await sendOffscreen({ + target: "offscreen", + type: "toggle-microphone-mute", + muted: message.muted, + }); + return response; + } + if (message.type === "open-options") { chrome.tabs.create({ url: chrome.runtime.getURL("options.html") }); return { ok: true }; @@ -1737,17 +1769,7 @@ const handleRequest = async ( } if (message.type === "show-countdown") { - // Relay the offscreen recorder's countdown to the recorded tab. Inject - // the overlay if it is not there yet; a tab that cannot host it (e.g. a - // chrome:// page) just shows nothing while the recorder waits out the - // same countdown, so the count is still kept out of the capture. - if (message.tabId !== undefined) { - void sendOverlay(message.tabId, { - type: "overlay-countdown", - seconds: message.seconds, - durationMs: message.durationMs, - }).catch(() => undefined); - } + await broadcastOverlayCountdown(message.seconds, message.durationMs); return { ok: true }; } diff --git a/apps/chrome-extension/src/content/blur-overlay.tsx b/apps/chrome-extension/src/content/blur-overlay.tsx new file mode 100644 index 00000000000..3512d93360d --- /dev/null +++ b/apps/chrome-extension/src/content/blur-overlay.tsx @@ -0,0 +1,137 @@ +import { useEffect, useRef, useState } from "react"; + +type HighlightRect = { + top: number; + left: number; + width: number; + height: number; +}; + +type BlurOverlayProps = { + active: boolean; + onDone: () => void; +}; + +const OVERLAY_ROOT_ID = "cap-extension-recorder-overlay"; + +export function BlurOverlay({ active, onDone }: BlurOverlayProps) { + const [highlightRect, setHighlightRect] = useState( + null, + ); + const rafRef = useRef(null); + + useEffect(() => { + if (!active) { + setHighlightRect(null); + return; + } + + const handlePointerMove = (event: PointerEvent) => { + if (rafRef.current !== null) return; + rafRef.current = window.requestAnimationFrame(() => { + rafRef.current = null; + const target = document.elementFromPoint( + event.clientX, + event.clientY, + ) as HTMLElement | null; + + if (!target || target.closest(`#${OVERLAY_ROOT_ID}`)) { + setHighlightRect(null); + return; + } + + const rect = target.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + setHighlightRect(null); + return; + } + + setHighlightRect({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }); + }); + }; + + const handleClick = (event: MouseEvent) => { + const target = document.elementFromPoint( + event.clientX, + event.clientY, + ) as HTMLElement | null; + + if (!target || target.closest(`#${OVERLAY_ROOT_ID}`)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (target.dataset.capBlurred === "true") { + const orig = target.dataset.capOrigFilter ?? ""; + if (orig) { + target.style.filter = orig; + } else { + target.style.removeProperty("filter"); + } + target.style.removeProperty("user-select"); + delete target.dataset.capBlurred; + delete target.dataset.capOrigFilter; + } else { + target.dataset.capOrigFilter = target.style.filter || ""; + target.style.setProperty("filter", "blur(12px)", "important"); + target.style.setProperty("user-select", "none", "important"); + target.dataset.capBlurred = "true"; + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onDone(); + } + }; + + window.addEventListener("pointermove", handlePointerMove, { + capture: true, + passive: true, + }); + window.addEventListener("click", handleClick, { + capture: true, + }); + window.addEventListener("keydown", handleKeyDown, { capture: true }); + + return () => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + window.removeEventListener("pointermove", handlePointerMove, { + capture: true, + }); + window.removeEventListener("click", handleClick, { + capture: true, + }); + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }; + }, [active, onDone]); + + if (!active || !highlightRect) return null; + + return ( +
+ Click to blur / unblur +
+ ); +} diff --git a/apps/chrome-extension/src/content/overlay.css b/apps/chrome-extension/src/content/overlay.css index 7456ae6ab53..2202cb47c88 100644 --- a/apps/chrome-extension/src/content/overlay.css +++ b/apps/chrome-extension/src/content/overlay.css @@ -266,6 +266,37 @@ border-radius: 0 0 6px; } +.cap-extension-camera-dismiss-btn { + position: absolute; + top: 10px; + right: 10px; + z-index: 12; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: 50%; + background: rgba(18, 18, 23, 0.65); + color: #ffffff; + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: all 140ms ease; + backdrop-filter: blur(8px); +} + +.cap-extension-camera-window:hover .cap-extension-camera-dismiss-btn { + opacity: 1; + pointer-events: auto; +} + +.cap-extension-camera-dismiss-btn:hover { + background: rgba(239, 68, 68, 0.9); + transform: scale(1.1); +} + .cap-extension-camera-iframe { position: absolute; inset: 0; @@ -846,6 +877,377 @@ } } +.cap-extension-active-recording-container { + position: fixed; + z-index: 2147483647; + display: flex; + flex-direction: column; + align-items: flex-start; + pointer-events: auto; + touch-action: none; + user-select: none; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; + font-size: 13px; + line-height: 1; + isolation: isolate; + animation: cap-extension-badge-in 240ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.cap-extension-active-recording-container.is-dragging { + cursor: grabbing; +} + +.cap-extension-recording-badge { + display: flex; + align-items: center; + gap: 8px; + height: 48px; + padding: 4px 12px 4px 6px; + border: 1.5px solid rgba(18, 18, 23, 0.12); + border-radius: 9999px; + background: rgba(255, 255, 255, 0.98); + box-shadow: + 0 14px 34px rgba(0, 0, 0, 0.18), + 0 3px 10px rgba(0, 0, 0, 0.1); + color: #18181b; + cursor: grab; + backdrop-filter: blur(24px); + transition: + transform 140ms ease, + box-shadow 140ms ease, + border-color 140ms ease; +} + +.cap-extension-recording-badge.is-counting { + width: 48px; + height: 48px; + padding: 0; + justify-content: center; + border-color: #ef4444; + box-shadow: + 0 0 0 4px rgba(239, 68, 68, 0.2), + 0 14px 34px rgba(0, 0, 0, 0.18); +} + +.cap-extension-badge-countdown-ring { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.cap-extension-badge-countdown-number { + font-size: 22px; + font-weight: 800; + color: #ef4444; + line-height: 1; + animation: cap-extension-countdown-pop 400ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes cap-extension-countdown-pop { + 0% { + transform: scale(0.6); + opacity: 0.5; + } + 50% { + transform: scale(1.15); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.cap-extension-recording-badge:hover { + transform: scale(1.05); + border-color: rgba(18, 18, 23, 0.25); + box-shadow: + 0 18px 42px rgba(0, 0, 0, 0.22), + 0 4px 12px rgba(0, 0, 0, 0.12); +} + +.cap-extension-recording-badge.is-active { + border-color: #3b82f6; + box-shadow: + 0 0 0 3px rgba(59, 130, 246, 0.25), + 0 14px 34px rgba(0, 0, 0, 0.18); +} + +.cap-extension-recording-badge-icon-wrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; +} + +.cap-extension-recording-badge-logo { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: contain; +} + +.cap-extension-recording-badge-icon-wrap .cap-extension-control-bar-dot { + position: absolute; + right: -1px; + bottom: -1px; + width: 10px; + height: 10px; + border: 2px solid #fff; + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.3); +} + +.cap-extension-recording-badge-time { + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #18181b; + white-space: nowrap; +} + +.cap-extension-recording-badge-time.is-warning { + color: #e5484d; +} + +.cap-extension-recording-badge::before { + content: ""; + position: absolute; + top: -8px; + bottom: -8px; + left: -8px; + right: -8px; + z-index: -1; + pointer-events: auto; +} + +.cap-extension-vertical-dock { + position: absolute; + left: 0; + z-index: 10; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + width: 48px; + padding: 6px; + border: 1px solid rgba(18, 18, 23, 0.12); + border-radius: 28px; + background: rgba(255, 255, 255, 0.96); + box-shadow: + 0 20px 48px rgba(0, 0, 0, 0.22), + 0 6px 14px rgba(0, 0, 0, 0.08); + backdrop-filter: blur(28px); + animation: cap-extension-dock-slide-up 220ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.cap-extension-vertical-dock::before { + content: ""; + position: absolute; + top: -12px; + bottom: -12px; + left: -12px; + right: -12px; + z-index: -1; + pointer-events: auto; +} + +.cap-extension-active-recording-container.opens-up + .cap-extension-vertical-dock { + bottom: calc(100% + 8px); + transform-origin: bottom center; +} + +.cap-extension-active-recording-container.opens-down + .cap-extension-vertical-dock { + top: calc(100% + 8px); + transform-origin: top center; +} + +.cap-extension-dock-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: rgba(18, 18, 23, 0.05); + color: #27272a; + cursor: pointer; + transition: all 140ms ease; +} + +.cap-extension-dock-btn:hover { + background: rgba(18, 18, 23, 0.1); + transform: scale(1.08); +} + +.cap-extension-dock-btn:active { + transform: scale(0.96); +} + +.cap-extension-dock-btn.is-stop { + background: #ef4444; + color: #ffffff; + box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3); +} + +.cap-extension-dock-btn.is-stop:hover { + background: #dc2626; + box-shadow: 0 4px 14px rgba(220, 38, 38, 0.4); +} + +.cap-extension-dock-btn.is-pause { + background: rgba(18, 18, 23, 0.07); + color: #18181b; +} + +.cap-extension-dock-btn.is-pause.is-paused { + background: #eab308; + color: #ffffff; +} + +.cap-extension-dock-btn.is-blur.is-active { + background: #3b82f6; + color: #ffffff; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); +} + +.cap-extension-dock-btn.is-pen.is-active { + background: #8b5cf6; + color: #ffffff; + box-shadow: 0 2px 8px rgba(139, 92, 246, 0.3); +} + +.cap-extension-dock-btn.is-camera.is-active { + background: rgba(34, 197, 94, 0.14); + color: #16a34a; +} + +.cap-extension-dock-btn.is-mic.is-muted { + background: rgba(239, 68, 68, 0.14); + color: #dc2626; +} + +.cap-extension-dock-btn.is-collapse { + background: transparent; + color: #a1a1aa; +} + +.cap-extension-dock-btn.is-collapse:hover { + background: rgba(18, 18, 23, 0.06); + color: #18181b; +} + +.cap-extension-dock-btn::after { + content: attr(data-tooltip); + position: absolute; + top: 50%; + transform: translateY(-50%) scale(0.9); + padding: 4px 10px; + border-radius: 8px; + background: #18181b; + color: #ffffff; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: all 140ms cubic-bezier(0.16, 1, 0.3, 1); + z-index: 20; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.cap-extension-active-recording-container.tooltip-left + .cap-extension-dock-btn::after { + right: calc(100% + 10px); +} + +.cap-extension-active-recording-container.tooltip-right + .cap-extension-dock-btn::after { + left: calc(100% + 10px); +} + +.cap-extension-dock-btn:hover::after { + opacity: 1; + transform: translateY(-50%) scale(1); +} + +@keyframes cap-extension-dock-slide-up { + from { + opacity: 0; + transform: translateY(12px) scale(0.92); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.cap-extension-blur-highlight { + position: fixed; + z-index: 2147483646; + box-sizing: border-box; + border: 2px dashed #3b82f6; + border-radius: 6px; + background: rgba(59, 130, 246, 0.12); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5) inset; + pointer-events: none; + animation: cap-extension-fade-in 140ms ease; +} + +.cap-extension-blur-tooltip { + position: absolute; + top: -24px; + left: 0; + padding: 2px 8px; + border-radius: 6px; + background: #18181b; + color: #ffffff; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + pointer-events: none; +} + +@keyframes cap-extension-badge-in { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes cap-extension-popover-in { + from { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes cap-extension-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + /* The drawing surface sits above the recorder panel's backdrop (so drawing works even while the panel that hosts the "Ready to record" bar is open) but below the floating bars and the draw toolbar (which stay at the maximum diff --git a/apps/chrome-extension/src/content/overlay.tsx b/apps/chrome-extension/src/content/overlay.tsx index 1a55d5a6e75..f7f2946cef4 100644 --- a/apps/chrome-extension/src/content/overlay.tsx +++ b/apps/chrome-extension/src/content/overlay.tsx @@ -1,12 +1,4 @@ -import { - Circle, - FlipHorizontal, - Maximize2, - PictureInPicture, - RectangleHorizontal, - Square, - X, -} from "lucide-react"; +import { X } from "lucide-react"; import { type PointerEvent as ReactPointerEvent, useCallback, @@ -56,10 +48,6 @@ import { replayStartupMessages, setStartupMessages } from "./startup-messages"; const ROOT_ID = "cap-extension-recorder-overlay"; const WINDOW_PADDING = 20; const BAR_HEIGHT = 52; -// The iframe tokens are readable from the host page DOM (they sit in the -// iframe src), so token checks alone cannot authenticate window messages. -// Frames we embed always run on the extension origin; require it too. -const EXTENSION_ORIGIN = new URL(chrome.runtime.getURL("")).origin; const createSecureToken = () => { if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); const bytes = new Uint8Array(16); @@ -73,12 +61,6 @@ const PREVIEW_TOKEN = createSecureToken(); const PREVIEW_URL = chrome.runtime.getURL("camera-preview.html"); const PREVIEW_SRC = `${PREVIEW_URL}#${encodeURIComponent(PREVIEW_TOKEN)}`; const PREVIEW_ERROR_DELAY_MS = 1200; -const PANEL_TOKEN = createSecureToken(); -const PANEL_URL = chrome.runtime.getURL("popup.html"); -const PANEL_SRC = `${PANEL_URL}#${encodeURIComponent(PANEL_TOKEN)}`; -const PANEL_WIDTH = 300; -const PANEL_DEFAULT_HEIGHT = 460; -const PANEL_MARGIN = 16; const CAMERA_MIN_SIZE = 120; const CAMERA_MAX_SIZE = 420; const CAMERA_RESIZE_CORNERS = ["nw", "ne", "sw", "se"] as const; @@ -96,7 +78,7 @@ let overlayTokensRegistration: Promise | null = null; const ensureOverlayTokensRegistered = () => { overlayTokensRegistration ??= Promise.all( - [PREVIEW_TOKEN, PANEL_TOKEN].map((token) => + [PREVIEW_TOKEN].map((token) => sendServiceWorkerMessage({ target: "service-worker", type: "register-overlay-token", @@ -321,15 +303,10 @@ const connectCameraPreview = async ( offer: toSessionDescriptionInit(peer.localDescription), }); - if (!response.ok) { - peer.close(); - throw new Error(response.error); - } - if (!response.answer) { + if (!response.ok || !response.answer) { peer.close(); - throw new Error("Camera preview did not return an answer."); + throw new Error(response.ok ? "Missing camera answer" : response.error); } - await peer.setRemoteDescription(response.answer); return { peer, @@ -337,212 +314,6 @@ const connectCameraPreview = async ( }; }; -type PanelFrameMessage = - | { - source: "cap-extension-panel"; - token: string; - type: "size"; - height: number; - } - | { - source: "cap-extension-panel"; - token: string; - type: "dismiss"; - }; - -const isPanelFrameMessage = (value: unknown): value is PanelFrameMessage => { - if (!value || typeof value !== "object") return false; - const candidate = value as Partial; - if ( - candidate.source !== "cap-extension-panel" || - candidate.token !== PANEL_TOKEN - ) { - return false; - } - if (candidate.type === "dismiss") return true; - return ( - candidate.type === "size" && - typeof candidate.height === "number" && - Number.isFinite(candidate.height) - ); -}; - -function RecorderPanelOverlay({ - onDismiss, - onOpenChange, -}: { - onDismiss: () => void; - onOpenChange: (open: boolean) => void; -}) { - const [open, setOpen] = useState(false); - const [pageVisible, setPageVisible] = useState( - () => document.visibilityState === "visible", - ); - const [contentHeight, setContentHeight] = useState(PANEL_DEFAULT_HEIGHT); - const [viewportHeight, setViewportHeight] = useState( - () => window.innerHeight, - ); - const [tokenReady, setTokenReady] = useState(false); - const startupReplayedRef = useRef(false); - - useEffect(() => { - onOpenChange(open); - }, [onOpenChange, open]); - - useEffect(() => { - if (!open || tokenReady) return; - let disposed = false; - void ensureOverlayTokensRegistered().then((registered) => { - if (!disposed && registered) setTokenReady(true); - }); - return () => { - disposed = true; - }; - }, [open, tokenReady]); - - // The open flag lives in chrome.storage.session so the panel follows the - // user across tabs: opening or closing it anywhere applies everywhere. - useEffect(() => { - let disposed = false; - - const syncPanelState = () => { - loadSharedUiState() - .then((state) => { - if (!disposed) setOpen(state.panelOpen); - }) - .catch(() => undefined); - }; - - const handleStorageChange = ( - changes: Record, - areaName: string, - ) => { - if (areaName === "session" && changes[SHARED_UI_STATE_KEY]) { - syncPanelState(); - } - }; - - syncPanelState(); - chrome.storage.onChanged.addListener(handleStorageChange); - return () => { - disposed = true; - chrome.storage.onChanged.removeListener(handleStorageChange); - }; - }, []); - - useEffect(() => { - const handleVisibility = () => - setPageVisible(document.visibilityState === "visible"); - document.addEventListener("visibilitychange", handleVisibility); - return () => - document.removeEventListener("visibilitychange", handleVisibility); - }, []); - - const closePanel = useCallback(() => { - setOpen(false); - onDismiss(); - void updateSharedUiState((current) => ({ - ...current, - panelOpen: false, - updatedAt: Date.now(), - })).catch(() => undefined); - }, [onDismiss]); - - useEffect(() => { - const handleMessage = ( - message: unknown, - _sender: chrome.runtime.MessageSender, - sendResponse: (response?: unknown) => void, - ) => { - if (!isOverlayMessage(message)) return false; - if (message.type === "overlay-panel-toggle") { - sendResponse({ ok: true }); - // Flip the shared flag; every tab (this one included) follows the - // storage change. Reopening also resurfaces a dismissed ready bar. - void updateSharedUiState((current) => ({ - ...current, - panelOpen: !current.panelOpen, - readyBarDismissed: current.panelOpen - ? current.readyBarDismissed - : false, - updatedAt: Date.now(), - })) - .then((state) => setOpen(state.panelOpen)) - .catch(() => undefined); - return false; - } - if (message.type === "overlay-panel-hide") { - sendResponse({ ok: true }); - closePanel(); - return false; - } - return false; - }; - - chrome.runtime.onMessage.addListener(handleMessage); - if (!startupReplayedRef.current) { - startupReplayedRef.current = true; - replayStartupMessages(handleMessage); - } - return () => chrome.runtime.onMessage.removeListener(handleMessage); - }, [closePanel]); - - useEffect(() => { - const handleFrameMessage = (event: MessageEvent) => { - if (event.origin !== EXTENSION_ORIGIN) return; - if (!isPanelFrameMessage(event.data)) return; - if (event.data.type === "size") { - setContentHeight(Math.max(320, Math.ceil(event.data.height))); - return; - } - closePanel(); - }; - - window.addEventListener("message", handleFrameMessage); - return () => window.removeEventListener("message", handleFrameMessage); - }, [closePanel]); - - useEffect(() => { - if (!open) return; - const handleResize = () => setViewportHeight(window.innerHeight); - handleResize(); - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, [open]); - - // Only the tab on screen renders the iframe; hidden tabs keep just the - // shared flag so the panel reappears instantly when they come forward. - // The iframe also waits for its token registration so the panel page can - // verify it was embedded by this extension. - if (!open || !pageVisible || !tokenReady) return null; - - const height = Math.min(contentHeight, viewportHeight - PANEL_MARGIN * 2); - - return ( - <> -