From 1fca9ba42bf31c31f9257234cf9b743d1279d07e Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Thu, 15 Jan 2026 07:42:25 +0530 Subject: [PATCH 1/7] Add browser-based recorder page with full recording controls --- .../app/(org)/record/RecorderPageContent.tsx | 435 ++++++++++++++++++ apps/web/app/(org)/record/page.tsx | 70 +++ 2 files changed, 505 insertions(+) create mode 100644 apps/web/app/(org)/record/RecorderPageContent.tsx create mode 100644 apps/web/app/(org)/record/page.tsx diff --git a/apps/web/app/(org)/record/RecorderPageContent.tsx b/apps/web/app/(org)/record/RecorderPageContent.tsx new file mode 100644 index 0000000000..32418abecb --- /dev/null +++ b/apps/web/app/(org)/record/RecorderPageContent.tsx @@ -0,0 +1,435 @@ +"use client"; + +import { Button } from "@cap/ui"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useCurrentUser } from "@/app/Layout/AuthContext"; +import { CameraPreviewWindow } from "../dashboard/caps/components/web-recorder-dialog/CameraPreviewWindow"; +import { CameraSelector } from "../dashboard/caps/components/web-recorder-dialog/CameraSelector"; +import { MicrophoneSelector } from "../dashboard/caps/components/web-recorder-dialog/MicrophoneSelector"; +import { + type RecordingMode, + RecordingModeSelector, +} from "../dashboard/caps/components/web-recorder-dialog/RecordingModeSelector"; +import { useCameraDevices } from "../dashboard/caps/components/web-recorder-dialog/useCameraDevices"; +import { useDevicePreferences } from "../dashboard/caps/components/web-recorder-dialog/useDevicePreferences"; +import { useMicrophoneDevices } from "../dashboard/caps/components/web-recorder-dialog/useMicrophoneDevices"; +import { useWebRecorder } from "../dashboard/caps/components/web-recorder-dialog/useWebRecorder"; +import { FREE_PLAN_MAX_RECORDING_MS } from "../dashboard/caps/components/web-recorder-dialog/web-recorder-constants"; + +export const RecorderPageContent = () => { + const router = useRouter(); + const [recordingMode, setRecordingMode] = + useState("fullscreen"); + const [cameraSelectOpen, setCameraSelectOpen] = useState(false); + const [micSelectOpen, setMicSelectOpen] = useState(false); + const [isClient, setIsClient] = useState(false); + const startSoundRef = useRef(null); + const stopSoundRef = useRef(null); + + useEffect(() => { + setIsClient(true); + + const requestPermissions = async () => { + try { + const savedCameraId = window.localStorage.getItem('cap-web-recorder-preferred-camera'); + const savedMicId = window.localStorage.getItem('cap-web-recorder-preferred-microphone'); + + const constraints: MediaStreamConstraints = { + video: savedCameraId ? { deviceId: { exact: savedCameraId } } : true, + audio: savedMicId ? { deviceId: { exact: savedMicId } } : true + }; + + const stream = await navigator.mediaDevices.getUserMedia(constraints); + stream.getTracks().forEach(track => track.stop()); + } catch (error) { + console.log('Permission request failed or denied:', error); + } + }; + + requestPermissions(); + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const startSound = new Audio("/sounds/start-recording.ogg"); + startSound.preload = "auto"; + const stopSound = new Audio("/sounds/stop-recording.ogg"); + stopSound.preload = "auto"; + + startSoundRef.current = startSound; + stopSoundRef.current = stopSound; + + return () => { + startSound.pause(); + stopSound.pause(); + startSoundRef.current = null; + stopSoundRef.current = null; + }; + }, []); + + const playAudio = useCallback((audio: HTMLAudioElement | null) => { + if (!audio) { + return; + } + audio.currentTime = 0; + void audio.play().catch(() => {}); + }, []); + + const handleRecordingStartSound = useCallback(() => { + playAudio(startSoundRef.current); + }, [playAudio]); + + const handleRecordingStopSound = useCallback(() => { + playAudio(stopSoundRef.current); + }, [playAudio]); + + const user = useCurrentUser(); + const organisationId = user?.defaultOrgId; + + if (!user) { + return ( +
+
+
+ + + + +

Loading user...

+
+
+
+ ); + } + + const { devices: availableMics, refresh: refreshMics } = + useMicrophoneDevices(isClient); + const { devices: availableCameras, refresh: refreshCameras } = + useCameraDevices(isClient); + + const { + selectedCameraId, + selectedMicId, + setSelectedCameraId, + handleCameraChange, + handleMicChange, + } = useDevicePreferences({ + open: isClient, + availableCameras, + availableMics, + }); + + const micEnabled = selectedMicId !== null; + + useEffect(() => { + if ( + recordingMode === "camera" && + !selectedCameraId && + availableCameras.length > 0 + ) { + setSelectedCameraId(availableCameras[0]?.deviceId ?? null); + } + }, [recordingMode, selectedCameraId, availableCameras, setSelectedCameraId]); + + const { + phase, + durationMs, + isRecording, + isBusy, + canStartRecording, + isBrowserSupported, + unsupportedReason, + supportsDisplayRecording, + supportCheckCompleted, + screenCaptureWarning, + startRecording, + stopRecording, + } = useWebRecorder({ + organisationId, + selectedMicId, + micEnabled, + recordingMode, + selectedCameraId, + isProUser: user.isPro, + onRecordingSurfaceDetected: (mode) => { + setRecordingMode(mode); + }, + onRecordingStart: handleRecordingStartSound, + onRecordingStop: handleRecordingStopSound, + }); + + useEffect(() => { + console.log('Recording state:', { + canStartRecording, + isBrowserSupported, + supportCheckCompleted, + supportsDisplayRecording, + recordingMode, + organisationId, + unsupportedReason + }); + }, [canStartRecording, isBrowserSupported, supportCheckCompleted, supportsDisplayRecording, recordingMode, organisationId, unsupportedReason]); + + useEffect(() => { + if ( + !supportCheckCompleted || + supportsDisplayRecording || + recordingMode === "camera" + ) { + return; + } + + setRecordingMode("camera"); + }, [supportCheckCompleted, supportsDisplayRecording, recordingMode]); + + const handleStopClick = () => { + stopRecording().catch((err: unknown) => { + console.error("Stop recording error", err); + }); + }; + + const handleSignOut = async () => { + try { + await fetch("/api/auth/signout", { + method: "POST", + credentials: "include", + }); + router.push("/login"); + } catch (error) { + console.error("Sign out failed:", error); + } + }; + + const recordingTimerDisplayMs = user.isPro + ? durationMs + : Math.max(0, FREE_PLAN_MAX_RECORDING_MS - durationMs); + + const formatDuration = (ms: number) => { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; + }; + + if (!isClient) { + return ( +
+
+
+ + + + +

Loading...

+
+
+
+ ); + } + + return ( + <> +
+
+ + + + + + +

Cap

+
+ +
+
+
+
+ {(user.name || user.email).charAt(0).toUpperCase()} +
+ + {user.name || user.email} + +
+ +
+
+ +
+
+ +
+
+ + {screenCaptureWarning && ( +
+ {screenCaptureWarning} +
+ )} + +
+ { + setCameraSelectOpen(isOpen); + if (isOpen) { + setMicSelectOpen(false); + } + }} + onCameraChange={handleCameraChange} + onRefreshDevices={refreshCameras} + /> +
+ +
+ { + setMicSelectOpen(isOpen); + if (isOpen) { + setCameraSelectOpen(false); + } + }} + onMicChange={handleMicChange} + onRefreshDevices={refreshMics} + /> +
+ + {isRecording && ( +
+ + {formatDuration(recordingTimerDisplayMs)} +
+ )} + + + + {!isBrowserSupported && unsupportedReason && ( +
+ {unsupportedReason} +
+ )} +
+ + {selectedCameraId && ( + handleCameraChange(null)} + /> + )} + + ); +}; diff --git a/apps/web/app/(org)/record/page.tsx b/apps/web/app/(org)/record/page.tsx new file mode 100644 index 0000000000..68c899b5b3 --- /dev/null +++ b/apps/web/app/(org)/record/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { UploadingProvider } from "../dashboard/caps/UploadingContext"; +import { RecorderPageContent } from "./RecorderPageContent"; + +export default function RecordPage() { + const router = useRouter(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + return ( + +
+ + +
+

+ Browser Recorder +

+

+ Record your screen, window, or tab directly from your browser. Select + your recording options and start capturing. +

+

+ Download the{" "} + + Cap desktop app + {" "} + to record over any browser or application. +

+
+ +
+ +
+
+
+ ); +} From 99ae93ee6c6d8a8a036aa1ab49422acc53966c3f Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Thu, 15 Jan 2026 11:38:58 +0530 Subject: [PATCH 2/7] Add chrome-extension --- apps/chrome-extension/.gitignore | 4 + apps/chrome-extension/README.md | 102 ++++++++++++++++++ apps/chrome-extension/manifest.json | 27 +++++ apps/chrome-extension/package.json | 15 +++ apps/chrome-extension/scripts/build.js | 60 +++++++++++ .../src/assets/icons/.gitkeep | 0 .../src/assets/icons/icon-128.png | Bin 0 -> 21877 bytes .../src/assets/icons/icon-16.png | Bin 0 -> 925 bytes .../src/assets/icons/icon-32.png | Bin 0 -> 2892 bytes .../src/assets/icons/icon-48.png | Bin 0 -> 5511 bytes .../src/background/service-worker.js | 10 ++ 11 files changed, 218 insertions(+) create mode 100644 apps/chrome-extension/.gitignore create mode 100644 apps/chrome-extension/README.md create mode 100644 apps/chrome-extension/manifest.json create mode 100644 apps/chrome-extension/package.json create mode 100644 apps/chrome-extension/scripts/build.js create mode 100644 apps/chrome-extension/src/assets/icons/.gitkeep create mode 100644 apps/chrome-extension/src/assets/icons/icon-128.png create mode 100644 apps/chrome-extension/src/assets/icons/icon-16.png create mode 100644 apps/chrome-extension/src/assets/icons/icon-32.png create mode 100644 apps/chrome-extension/src/assets/icons/icon-48.png create mode 100644 apps/chrome-extension/src/background/service-worker.js diff --git a/apps/chrome-extension/.gitignore b/apps/chrome-extension/.gitignore new file mode 100644 index 0000000000..6de233e8c6 --- /dev/null +++ b/apps/chrome-extension/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.zip +.DS_Store diff --git a/apps/chrome-extension/README.md b/apps/chrome-extension/README.md new file mode 100644 index 0000000000..0d1b104170 --- /dev/null +++ b/apps/chrome-extension/README.md @@ -0,0 +1,102 @@ +# Cap Chrome Extension + +A Chrome extension for Cap that enables instant screen, tab, and camera recording directly from your browser. + +## Features + +- **Multiple Recording Modes** + - Screen recording (entire screen or specific window) + - Tab recording (current browser tab) + - Camera recording (webcam only) + +- **Recording Controls** + - Start/stop recording + - Pause/resume during recording + - Real-time recording timer + - On-page recording indicator + +- **Audio Options** + - Optional microphone audio + - System audio capture (for tab recording) + - High-quality audio encoding + +- **Seamless Integration** + - Automatic upload to Cap + - Direct link to recorded video + - Progress tracking during upload + - Multipart upload for large files + +## Installation + +### From Source + +1. Install dependencies: + ```bash + pnpm install + ``` + +2. Build the extension: + ```bash + pnpm build + ``` + +3. Load in Chrome: + - Open Chrome and navigate to `chrome://extensions/` + - Enable "Developer mode" (toggle in top right) + - Click "Load unpacked" + - Select the `dist` folder + + +## Usage + +### First Time Setup + +1. Click the Cap extension icon in your browser toolbar +2. Click "Sign In to Cap" to authenticate +3. You'll be redirected to Cap's authentication page +4. Once authenticated, you're ready to record! + +### Recording + +1. Click the Cap extension icon +2. Select your recording mode (Screen, Tab, or Camera) +3. Configure audio options: + - Enable/disable microphone + - Enable/disable camera overlay +4. Click "Start Recording" +5. Grant necessary permissions when prompted +6. Record your content +7. Click "Stop" when finished +8. Your recording will automatically upload to Cap + + +### Adding New Features + +1. Update the appropriate component file +2. If adding new permissions, update `manifest.json` +3. Test thoroughly in development mode +4. Build and test the production version + +## Browser Compatibility + +- Chrome 100+ +- Edge 100+ +- Other Chromium-based browsers + +## Permissions + +The extension requires the following permissions: + +- **`storage`**: Store authentication tokens and user preferences +- **`tabs`**: Access tab information for tab recording +- **`activeTab`**: Capture current tab content +- **`scripting`**: Inject content script for recording indicator +- **`offscreen`**: Create offscreen document for media capture + +## Contributing + +Contributions are welcome! Please follow the existing code style and test thoroughly before submitting PRs. + +## License + +See the main Cap repository for license information. diff --git a/apps/chrome-extension/manifest.json b/apps/chrome-extension/manifest.json new file mode 100644 index 0000000000..0b458943f3 --- /dev/null +++ b/apps/chrome-extension/manifest.json @@ -0,0 +1,27 @@ +{ + "manifest_version": 3, + "name": "Cap - Screen Recorder", + "version": "1.0.0", + "description": "Record your screen, tab, or camera instantly with Cap", + "permissions": [ + "tabs" + ], + "background": { + "service_worker": "src/background/service-worker.js", + "type": "module" + }, + "action": { + "default_icon": { + "16": "src/assets/icons/icon-16.png", + "32": "src/assets/icons/icon-32.png", + "48": "src/assets/icons/icon-48.png", + "128": "src/assets/icons/icon-128.png" + } + }, + "icons": { + "16": "src/assets/icons/icon-16.png", + "32": "src/assets/icons/icon-32.png", + "48": "src/assets/icons/icon-48.png", + "128": "src/assets/icons/icon-128.png" + } +} diff --git a/apps/chrome-extension/package.json b/apps/chrome-extension/package.json new file mode 100644 index 0000000000..b57cd5e32f --- /dev/null +++ b/apps/chrome-extension/package.json @@ -0,0 +1,15 @@ +{ + "name": "@cap/chrome-extension", + "version": "1.0.0", + "type": "module", + "description": "Cap Chrome Extension - Screen recorder for the web", + "scripts": { + "build": "node scripts/build.js" + }, + "devDependencies": { + "archiver": "^7.0.1", + "chokidar": "^4.0.3", + "esbuild": "^0.24.2", + "sharp": "^0.34.5" + } +} diff --git a/apps/chrome-extension/scripts/build.js b/apps/chrome-extension/scripts/build.js new file mode 100644 index 0000000000..363171b8f8 --- /dev/null +++ b/apps/chrome-extension/scripts/build.js @@ -0,0 +1,60 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const rootDir = path.resolve(__dirname, '..'); +const srcDir = path.join(rootDir, 'src'); +const distDir = path.join(rootDir, 'dist'); + +function cleanDist() { + if (fs.existsSync(distDir)) { + fs.rmSync(distDir, { recursive: true, force: true }); + } + fs.mkdirSync(distDir, { recursive: true }); +} + +function copyDirectory(src, dest) { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + copyDirectory(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +async function build() { + try { + console.log('Building extension...'); + + cleanDist(); + + fs.copyFileSync( + path.join(rootDir, 'manifest.json'), + path.join(distDir, 'manifest.json') + ); + console.log('Copied manifest.json'); + + copyDirectory(srcDir, path.join(distDir, 'src')); + console.log('Copied src/ directory'); + + console.log('Build complete!'); + } catch (error) { + console.error('Build failed:', error); + process.exit(1); + } +} + +build(); diff --git a/apps/chrome-extension/src/assets/icons/.gitkeep b/apps/chrome-extension/src/assets/icons/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/chrome-extension/src/assets/icons/icon-128.png b/apps/chrome-extension/src/assets/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..1051ca6ab04e4acace1d56b32b7b11edf3d4a720 GIT binary patch literal 21877 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 literal 0 HcmV?d00001 diff --git a/apps/chrome-extension/src/assets/icons/icon-32.png b/apps/chrome-extension/src/assets/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..1b12dc5ede513733b4162d597ee29ad1cdf12304 GIT binary patch literal 2892 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 literal 0 HcmV?d00001 diff --git a/apps/chrome-extension/src/background/service-worker.js b/apps/chrome-extension/src/background/service-worker.js new file mode 100644 index 0000000000..973477ffe3 --- /dev/null +++ b/apps/chrome-extension/src/background/service-worker.js @@ -0,0 +1,10 @@ +const isDev = !('update_url' in chrome.runtime.getManifest()); +const WEB_RECORDER_URL = isDev ? 'http://localhost:3000/record' : 'https://cap.so/record'; + +chrome.runtime.onInstalled.addListener(() => { + console.log('Cap extension installed'); +}); + +chrome.action.onClicked.addListener(async () => { + chrome.tabs.create({ url: WEB_RECORDER_URL }); +}); From e9a7d7690512e2e13e185db9621dfc78f4b2b56b Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Thu, 15 Jan 2026 13:12:21 +0530 Subject: [PATCH 3/7] Focus existing recorder tab instead of creating duplicate tabs --- apps/chrome-extension/src/background/service-worker.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/chrome-extension/src/background/service-worker.js b/apps/chrome-extension/src/background/service-worker.js index 973477ffe3..32e9d0aedd 100644 --- a/apps/chrome-extension/src/background/service-worker.js +++ b/apps/chrome-extension/src/background/service-worker.js @@ -6,5 +6,13 @@ chrome.runtime.onInstalled.addListener(() => { }); chrome.action.onClicked.addListener(async () => { - chrome.tabs.create({ url: WEB_RECORDER_URL }); + const tabs = await chrome.tabs.query({ url: WEB_RECORDER_URL }); + + if (tabs.length > 0) { + const tab = tabs[0]; + await chrome.tabs.update(tab.id, { active: true }); + await chrome.windows.update(tab.windowId, { focused: true }); + } else { + chrome.tabs.create({ url: WEB_RECORDER_URL }); + } }); From 7b67ef62a3ccc38c9f0868abbc8afa18a56c112c Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Thu, 15 Jan 2026 13:13:36 +0530 Subject: [PATCH 4/7] Add settings panel to web recorder and improve authentication flow - Add settings panel with device memory toggle to web recorder interface - Support callbackUrl parameter in login form for post-auth redirects - Move AuthContextProvider to record layout for better auth state management - Improve permission request handling with fallback for denied devices - Show sign-in prompt when user is not authenticated on recorder page - Add settings button to recorder header with animated panel transition --- apps/web/app/(org)/login/form.tsx | 6 +- .../app/(org)/record/RecorderPageContent.tsx | 247 +++++++++++------- apps/web/app/(org)/record/layout.tsx | 18 ++ apps/web/app/(org)/record/page.tsx | 28 +- 4 files changed, 174 insertions(+), 125 deletions(-) create mode 100644 apps/web/app/(org)/record/layout.tsx diff --git a/apps/web/app/(org)/login/form.tsx b/apps/web/app/(org)/login/form.tsx index 8348b136ee..1359fa7c70 100644 --- a/apps/web/app/(org)/login/form.tsx +++ b/apps/web/app/(org)/login/form.tsx @@ -29,7 +29,7 @@ const MotionButton = motion(Button); export function LoginForm() { const searchParams = useSearchParams(); const router = useRouter(); - const next = searchParams?.get("next"); + const next = searchParams?.get("next") || searchParams?.get("callbackUrl"); const [email, setEmail] = useState(""); const [loading, setLoading] = useState(false); const [emailSent, setEmailSent] = useState(false); @@ -124,7 +124,9 @@ export function LoginForm() { ); setOrganizationName(data.name); - signIn("workos", undefined, { + signIn("workos", { + ...(next && next.length > 0 ? { callbackUrl: next } : {}), + }, { organization: data.organizationId, connection: data.connectionId, }); diff --git a/apps/web/app/(org)/record/RecorderPageContent.tsx b/apps/web/app/(org)/record/RecorderPageContent.tsx index 32418abecb..80421d30a7 100644 --- a/apps/web/app/(org)/record/RecorderPageContent.tsx +++ b/apps/web/app/(org)/record/RecorderPageContent.tsx @@ -1,10 +1,13 @@ "use client"; -import { Button } from "@cap/ui"; +import { Button, LogoBadge, Switch } from "@cap/ui"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { ArrowLeftIcon } from "lucide-react"; import { toast } from "sonner"; import { useCurrentUser } from "@/app/Layout/AuthContext"; +import { CogIcon } from "../dashboard/_components/AnimatedIcons"; import { CameraPreviewWindow } from "../dashboard/caps/components/web-recorder-dialog/CameraPreviewWindow"; import { CameraSelector } from "../dashboard/caps/components/web-recorder-dialog/CameraSelector"; import { MicrophoneSelector } from "../dashboard/caps/components/web-recorder-dialog/MicrophoneSelector"; @@ -24,31 +27,16 @@ export const RecorderPageContent = () => { useState("fullscreen"); const [cameraSelectOpen, setCameraSelectOpen] = useState(false); const [micSelectOpen, setMicSelectOpen] = useState(false); + const [settingsPanelOpen, setSettingsPanelOpen] = useState(false); const [isClient, setIsClient] = useState(false); const startSoundRef = useRef(null); const stopSoundRef = useRef(null); + const user = useCurrentUser(); + console.log('userrrrrrrrrr: ', user); + useEffect(() => { setIsClient(true); - - const requestPermissions = async () => { - try { - const savedCameraId = window.localStorage.getItem('cap-web-recorder-preferred-camera'); - const savedMicId = window.localStorage.getItem('cap-web-recorder-preferred-microphone'); - - const constraints: MediaStreamConstraints = { - video: savedCameraId ? { deviceId: { exact: savedCameraId } } : true, - audio: savedMicId ? { deviceId: { exact: savedMicId } } : true - }; - - const stream = await navigator.mediaDevices.getUserMedia(constraints); - stream.getTracks().forEach(track => track.stop()); - } catch (error) { - console.log('Permission request failed or denied:', error); - } - }; - - requestPermissions(); }, []); useEffect(() => { @@ -88,44 +76,52 @@ export const RecorderPageContent = () => { playAudio(stopSoundRef.current); }, [playAudio]); - const user = useCurrentUser(); - const organisationId = user?.defaultOrgId; + useEffect(() => { + if (!user) return; - if (!user) { - return ( -
-
-
- - - - -

Loading user...

-
-
-
- ); - } + const requestPermissions = async () => { + try { + const savedCameraId = window.localStorage.getItem('cap-web-recorder-preferred-camera'); + const savedMicId = window.localStorage.getItem('cap-web-recorder-preferred-microphone'); + + const constraints: MediaStreamConstraints = { + video: savedCameraId ? { deviceId: { exact: savedCameraId } } : true, + audio: savedMicId ? { deviceId: { exact: savedMicId } } : true + }; + + const stream = await navigator.mediaDevices.getUserMedia(constraints); + console.log('Permission granted, tracks:', stream.getTracks().map(t => ({ kind: t.kind, label: t.label, id: t.id }))); + stream.getTracks().forEach(track => track.stop()); + } catch (error) { + console.error('Permission request failed:', error); + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true + }); + console.log('Fallback permission granted'); + stream.getTracks().forEach(track => track.stop()); + } catch (fallbackError) { + console.error('Fallback permission also failed:', fallbackError); + } + } + }; + + requestPermissions(); + }, [user]); const { devices: availableMics, refresh: refreshMics } = useMicrophoneDevices(isClient); const { devices: availableCameras, refresh: refreshCameras } = useCameraDevices(isClient); - const { + rememberDevices, selectedCameraId, selectedMicId, setSelectedCameraId, handleCameraChange, handleMicChange, + handleRememberDevicesChange, } = useDevicePreferences({ open: isClient, availableCameras, @@ -133,6 +129,7 @@ export const RecorderPageContent = () => { }); const micEnabled = selectedMicId !== null; + const organisationId = user?.defaultOrgId; useEffect(() => { if ( @@ -163,7 +160,7 @@ export const RecorderPageContent = () => { micEnabled, recordingMode, selectedCameraId, - isProUser: user.isPro, + isProUser: user?.isPro ?? false, onRecordingSurfaceDetected: (mode) => { setRecordingMode(mode); }, @@ -207,13 +204,13 @@ export const RecorderPageContent = () => { method: "POST", credentials: "include", }); - router.push("/login"); + router.push(`/login?callbackUrl=${encodeURIComponent("/record")}`); } catch (error) { console.error("Sign out failed:", error); } }; - const recordingTimerDisplayMs = user.isPro + const recordingTimerDisplayMs = user?.isPro ? durationMs : Math.max(0, FREE_PLAN_MAX_RECORDING_MS - durationMs); @@ -224,6 +221,33 @@ export const RecorderPageContent = () => { return `${minutes}:${seconds.toString().padStart(2, "0")}`; }; + if (!user) { + return ( +
+
+ +
+

+ Sign in to Cap +

+

+ Sign in to start recording your screen +

+
+ +
+
+ ); + } + if (!isClient) { return (
@@ -250,38 +274,9 @@ export const RecorderPageContent = () => { return ( <> -
+
- - - - - - +

Cap

@@ -295,25 +290,35 @@ export const RecorderPageContent = () => { {user.name || user.email}
- + + + +
@@ -422,6 +427,52 @@ export const RecorderPageContent = () => { {unsupportedReason} )} + + + {settingsPanelOpen && ( + +
+ +

+ Recorder settings +

+ +
+
+
+
+

+ Automatically select your last webcam/microphone +

+

+ If available, the last used camera and mic will be + automatically selected. +

+
+ +
+
+
+ )} +
{selectedCameraId && ( diff --git a/apps/web/app/(org)/record/layout.tsx b/apps/web/app/(org)/record/layout.tsx new file mode 100644 index 0000000000..98f75b25f8 --- /dev/null +++ b/apps/web/app/(org)/record/layout.tsx @@ -0,0 +1,18 @@ +import { AuthContextProvider } from "@/app/Layout/AuthContext"; +import { resolveCurrentUser } from "@/app/Layout/current-user"; +import { runPromise } from "@/lib/server"; +import { UploadingProvider } from "../dashboard/caps/UploadingContext"; + +export const dynamic = "force-dynamic"; + +export default async function RecordLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/(org)/record/page.tsx b/apps/web/app/(org)/record/page.tsx index 68c899b5b3..14a73eb31d 100644 --- a/apps/web/app/(org)/record/page.tsx +++ b/apps/web/app/(org)/record/page.tsx @@ -2,7 +2,6 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { UploadingProvider } from "../dashboard/caps/UploadingContext"; import { RecorderPageContent } from "./RecorderPageContent"; export default function RecordPage() { @@ -18,27 +17,7 @@ export default function RecordPage() { } return ( - -
- - +

Browser Recorder @@ -48,7 +27,7 @@ export default function RecordPage() { your recording options and start capturing.

- Download the{" "} + Download the Cap desktop app - {" "} + to record over any browser or application.

@@ -65,6 +44,5 @@ export default function RecordPage() {
-
); } From d3f04d15c6cb1c8b0b3b749379909efedc2c7aa7 Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Thu, 15 Jan 2026 13:42:15 +0530 Subject: [PATCH 5/7] Add in-progress recording bar with pause/resume/restart controls to web recorder --- .../app/(org)/record/RecorderPageContent.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/web/app/(org)/record/RecorderPageContent.tsx b/apps/web/app/(org)/record/RecorderPageContent.tsx index 80421d30a7..552e2f1d94 100644 --- a/apps/web/app/(org)/record/RecorderPageContent.tsx +++ b/apps/web/app/(org)/record/RecorderPageContent.tsx @@ -10,6 +10,7 @@ import { useCurrentUser } from "@/app/Layout/AuthContext"; import { CogIcon } from "../dashboard/_components/AnimatedIcons"; import { CameraPreviewWindow } from "../dashboard/caps/components/web-recorder-dialog/CameraPreviewWindow"; import { CameraSelector } from "../dashboard/caps/components/web-recorder-dialog/CameraSelector"; +import { InProgressRecordingBar } from "../dashboard/caps/components/web-recorder-dialog/InProgressRecordingBar"; import { MicrophoneSelector } from "../dashboard/caps/components/web-recorder-dialog/MicrophoneSelector"; import { type RecordingMode, @@ -144,8 +145,12 @@ export const RecorderPageContent = () => { const { phase, durationMs, + hasAudioTrack, + chunkUploads, + errorDownload, isRecording, isBusy, + isRestarting, canStartRecording, isBrowserSupported, unsupportedReason, @@ -153,7 +158,10 @@ export const RecorderPageContent = () => { supportCheckCompleted, screenCaptureWarning, startRecording, + pauseRecording, + resumeRecording, stopRecording, + restartRecording, } = useWebRecorder({ organisationId, selectedMicId, @@ -214,6 +222,8 @@ export const RecorderPageContent = () => { ? durationMs : Math.max(0, FREE_PLAN_MAX_RECORDING_MS - durationMs); + const showInProgressBar = isRecording || isBusy || phase === "error"; + const formatDuration = (ms: number) => { const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); @@ -475,6 +485,21 @@ export const RecorderPageContent = () => { + {showInProgressBar && ( + + )} + {selectedCameraId && ( Date: Thu, 15 Jan 2026 16:04:25 +0530 Subject: [PATCH 6/7] Improve web recorder UI and cleanup permission handling --- .../RecordingModeSelector.tsx | 2 +- .../app/(org)/record/RecorderPageContent.tsx | 113 ++++++++---------- 2 files changed, 53 insertions(+), 62 deletions(-) diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingModeSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingModeSelector.tsx index acbf7f9844..3d2cc5bf55 100644 --- a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingModeSelector.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingModeSelector.tsx @@ -83,7 +83,7 @@ export const RecordingModeSelector = ({ )} - + {Object.entries(recordingModeOptions).map(([value, option]) => { const OptionIcon = option.icon; const isFullscreen = value === "fullscreen"; diff --git a/apps/web/app/(org)/record/RecorderPageContent.tsx b/apps/web/app/(org)/record/RecorderPageContent.tsx index 552e2f1d94..4956e47edb 100644 --- a/apps/web/app/(org)/record/RecorderPageContent.tsx +++ b/apps/web/app/(org)/record/RecorderPageContent.tsx @@ -2,12 +2,12 @@ import { Button, LogoBadge, Switch } from "@cap/ui"; import { useRouter } from "next/navigation"; +import { signOut } from "next-auth/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { ArrowLeftIcon } from "lucide-react"; -import { toast } from "sonner"; import { useCurrentUser } from "@/app/Layout/AuthContext"; -import { CogIcon } from "../dashboard/_components/AnimatedIcons"; +import { CogIcon, LogoutIcon } from "../dashboard/_components/AnimatedIcons"; import { CameraPreviewWindow } from "../dashboard/caps/components/web-recorder-dialog/CameraPreviewWindow"; import { CameraSelector } from "../dashboard/caps/components/web-recorder-dialog/CameraSelector"; import { InProgressRecordingBar } from "../dashboard/caps/components/web-recorder-dialog/InProgressRecordingBar"; @@ -18,6 +18,7 @@ import { } from "../dashboard/caps/components/web-recorder-dialog/RecordingModeSelector"; import { useCameraDevices } from "../dashboard/caps/components/web-recorder-dialog/useCameraDevices"; import { useDevicePreferences } from "../dashboard/caps/components/web-recorder-dialog/useDevicePreferences"; +import { useMediaPermission } from "../dashboard/caps/components/web-recorder-dialog/useMediaPermission"; import { useMicrophoneDevices } from "../dashboard/caps/components/web-recorder-dialog/useMicrophoneDevices"; import { useWebRecorder } from "../dashboard/caps/components/web-recorder-dialog/useWebRecorder"; import { FREE_PLAN_MAX_RECORDING_MS } from "../dashboard/caps/components/web-recorder-dialog/web-recorder-constants"; @@ -34,7 +35,6 @@ export const RecorderPageContent = () => { const stopSoundRef = useRef(null); const user = useCurrentUser(); - console.log('userrrrrrrrrr: ', user); useEffect(() => { setIsClient(true); @@ -77,44 +77,38 @@ export const RecorderPageContent = () => { playAudio(stopSoundRef.current); }, [playAudio]); + const { requestPermission: requestCameraPermission } = useMediaPermission( + "camera", + !!user, + ); + const { requestPermission: requestMicPermission } = useMediaPermission( + "microphone", + !!user, + ); + + const { devices: availableMics, refresh: refreshMics } = + useMicrophoneDevices(isClient); + const { devices: availableCameras, refresh: refreshCameras } = + useCameraDevices(isClient); + useEffect(() => { if (!user) return; const requestPermissions = async () => { try { - const savedCameraId = window.localStorage.getItem('cap-web-recorder-preferred-camera'); - const savedMicId = window.localStorage.getItem('cap-web-recorder-preferred-microphone'); - - const constraints: MediaStreamConstraints = { - video: savedCameraId ? { deviceId: { exact: savedCameraId } } : true, - audio: savedMicId ? { deviceId: { exact: savedMicId } } : true - }; - - const stream = await navigator.mediaDevices.getUserMedia(constraints); - console.log('Permission granted, tracks:', stream.getTracks().map(t => ({ kind: t.kind, label: t.label, id: t.id }))); - stream.getTracks().forEach(track => track.stop()); + await Promise.all([ + requestCameraPermission(), + requestMicPermission(), + ]); + refreshCameras(); + refreshMics(); } catch (error) { - console.error('Permission request failed:', error); - try { - const stream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: true - }); - console.log('Fallback permission granted'); - stream.getTracks().forEach(track => track.stop()); - } catch (fallbackError) { - console.error('Fallback permission also failed:', fallbackError); - } + console.error("Permission request failed:", error); } }; requestPermissions(); - }, [user]); - - const { devices: availableMics, refresh: refreshMics } = - useMicrophoneDevices(isClient); - const { devices: availableCameras, refresh: refreshCameras } = - useCameraDevices(isClient); + }, [user, requestCameraPermission, requestMicPermission, refreshCameras, refreshMics]); const { rememberDevices, selectedCameraId, @@ -202,20 +196,8 @@ export const RecorderPageContent = () => { const handleStopClick = () => { stopRecording().catch((err: unknown) => { - console.error("Stop recording error", err); - }); - }; - const handleSignOut = async () => { - try { - await fetch("/api/auth/signout", { - method: "POST", - credentials: "include", - }); - router.push(`/login?callbackUrl=${encodeURIComponent("/record")}`); - } catch (error) { - console.error("Sign out failed:", error); - } + }); }; const recordingTimerDisplayMs = user?.isPro @@ -285,9 +267,29 @@ export const RecorderPageContent = () => { return ( <>
-
- -

Cap

+
+
+ +

Cap

+
+
@@ -311,22 +313,11 @@ export const RecorderPageContent = () => {
@@ -397,7 +388,7 @@ export const RecorderPageContent = () => { ? handleStopClick : () => { startRecording().catch((err: unknown) => { - console.error("Start recording error", err); + }); } } From 2b7491f970767c9875a0069e9a01ed80f1d64acd Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Thu, 15 Jan 2026 16:42:31 +0530 Subject: [PATCH 7/7] Update Chrome extension to launcher-only mode and clean up recorder logging - Simplify Chrome extension README to reflect launcher-only functionality - Remove detailed feature descriptions for recording modes and controls - Update permissions list to only include required tabs permission - Fix organisationId nullish coalescing to use undefined instead of null - Remove verbose recording state debug logging from RecorderPageContent - Add error logging to start/stop recording handlers for better debugging --- apps/chrome-extension/README.md | 31 +++++-------------- .../app/(org)/record/RecorderPageContent.tsx | 17 ++-------- 2 files changed, 10 insertions(+), 38 deletions(-) diff --git a/apps/chrome-extension/README.md b/apps/chrome-extension/README.md index 0d1b104170..0bba598d85 100644 --- a/apps/chrome-extension/README.md +++ b/apps/chrome-extension/README.md @@ -1,30 +1,17 @@ # Cap Chrome Extension -A Chrome extension for Cap that enables instant screen, tab, and camera recording directly from your browser. +A Chrome extension that launches Cap's web-based recorder for instant screen, tab, and camera recording. ## Features -- **Multiple Recording Modes** - - Screen recording (entire screen or specific window) - - Tab recording (current browser tab) - - Camera recording (webcam only) - -- **Recording Controls** - - Start/stop recording - - Pause/resume during recording +- **Quick Access**: One-click launcher to open Cap's web recorder +- **Smart Tab Management**: Reuses existing recorder tabs instead of creating duplicates +- **Full Recording Suite**: Access to all Cap recording features via the web app: + - Multiple recording modes (screen, window, tab, camera) + - Recording controls (start/stop, pause/resume) + - Device selection (camera and microphone) - Real-time recording timer - - On-page recording indicator - -- **Audio Options** - - Optional microphone audio - - System audio capture (for tab recording) - - High-quality audio encoding - -- **Seamless Integration** - Automatic upload to Cap - - Direct link to recorded video - - Progress tracking during upload - - Multipart upload for large files ## Installation @@ -87,11 +74,7 @@ A Chrome extension for Cap that enables instant screen, tab, and camera recordin The extension requires the following permissions: -- **`storage`**: Store authentication tokens and user preferences - **`tabs`**: Access tab information for tab recording -- **`activeTab`**: Capture current tab content -- **`scripting`**: Inject content script for recording indicator -- **`offscreen`**: Create offscreen document for media capture ## Contributing diff --git a/apps/web/app/(org)/record/RecorderPageContent.tsx b/apps/web/app/(org)/record/RecorderPageContent.tsx index 4956e47edb..4881f50ce9 100644 --- a/apps/web/app/(org)/record/RecorderPageContent.tsx +++ b/apps/web/app/(org)/record/RecorderPageContent.tsx @@ -124,7 +124,7 @@ export const RecorderPageContent = () => { }); const micEnabled = selectedMicId !== null; - const organisationId = user?.defaultOrgId; + const organisationId = user?.defaultOrgId ?? undefined; useEffect(() => { if ( @@ -170,17 +170,6 @@ export const RecorderPageContent = () => { onRecordingStop: handleRecordingStopSound, }); - useEffect(() => { - console.log('Recording state:', { - canStartRecording, - isBrowserSupported, - supportCheckCompleted, - supportsDisplayRecording, - recordingMode, - organisationId, - unsupportedReason - }); - }, [canStartRecording, isBrowserSupported, supportCheckCompleted, supportsDisplayRecording, recordingMode, organisationId, unsupportedReason]); useEffect(() => { if ( @@ -196,7 +185,7 @@ export const RecorderPageContent = () => { const handleStopClick = () => { stopRecording().catch((err: unknown) => { - + console.error("Stop recording error", err); }); }; @@ -388,7 +377,7 @@ export const RecorderPageContent = () => { ? handleStopClick : () => { startRecording().catch((err: unknown) => { - + console.error('Failed to start recording:', err); }); } }