From a95f32a0954bd4d863b3f1883fc1e57129bf6323 Mon Sep 17 00:00:00 2001 From: Gurpreet Kait Date: Tue, 17 Mar 2026 16:05:23 +0530 Subject: [PATCH 1/5] - Redesign HUD overlay bar with dark glassmorphism aesthetic and inline source dropdown - Add pause, resume, and cancel recording controls with elapsed timer - Move folder, language, and system audio into three-dots overflow menu with source highlight wave --- electron/electron-env.d.ts | 1 + electron/ipc/handlers.ts | 163 ++++ electron/preload.ts | 3 + electron/windows.ts | 6 +- src/App.tsx | 6 + src/components/launch/LaunchWindow.module.css | 239 ++++- src/components/launch/LaunchWindow.tsx | 836 +++++++++++------- src/hooks/useScreenRecorder.ts | 58 +- 8 files changed, 932 insertions(+), 380 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f859957..a173474 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -28,6 +28,7 @@ interface Window { switchToEditor: () => Promise openSourceSelector: () => Promise selectSource: (source: any) => Promise + showSourceHighlight: (source: any) => Promise<{ success: boolean }> getSelectedSource: () => Promise startNativeScreenRecording: ( source: any, diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 3c3430d..aace1d4 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1715,6 +1715,169 @@ export function registerIpcHandlers( return selectedSource }) + ipcMain.handle('show-source-highlight', async (_, source: SelectedSource) => { + try { + const isWindow = source.id?.startsWith('window:') + const windowId = isWindow ? parseWindowId(source.id) : null + + // ── 1. Bring window to front & get its bounds via AppleScript ── + let asBounds: { x: number; y: number; width: number; height: number } | null = null + + if (isWindow && process.platform === 'darwin') { + const appName = source.appName || source.name?.split(' — ')[0]?.trim() + if (appName) { + // Single AppleScript: activate AND return window bounds + try { + const { stdout } = await execFileAsync('osascript', ['-e', + `tell application "${appName}"\n` + + ` activate\n` + + `end tell\n` + + `delay 0.3\n` + + `tell application "System Events"\n` + + ` tell process "${appName}"\n` + + ` set frontWindow to front window\n` + + ` set {x1, y1} to position of frontWindow\n` + + ` set {w1, h1} to size of frontWindow\n` + + ` return (x1 as text) & "," & (y1 as text) & "," & (w1 as text) & "," & (h1 as text)\n` + + ` end tell\n` + + `end tell` + ], { timeout: 4000 }) + const parts = stdout.trim().split(',').map(Number) + if (parts.length === 4 && parts.every(n => Number.isFinite(n))) { + asBounds = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] } + } + } catch { + // Fallback: just activate without bounds + try { + await execFileAsync('osascript', ['-e', + `tell application "${appName}" to activate` + ], { timeout: 2000 }) + await new Promise((resolve) => setTimeout(resolve, 350)) + } catch { /* ignore */ } + } + } + } else if (windowId && process.platform === 'linux') { + try { + await execFileAsync('wmctrl', ['-i', '-a', `0x${windowId.toString(16)}`], { timeout: 1500 }) + } catch { + try { + await execFileAsync('xdotool', ['windowactivate', String(windowId)], { timeout: 1500 }) + } catch { /* not available */ } + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + + // ── 2. Resolve bounds ── + let bounds = asBounds + + if (!bounds) { + if (source.id?.startsWith('screen:')) { + bounds = getDisplayBoundsForSource(source) + } else if (isWindow) { + if (process.platform === 'darwin') { + bounds = await resolveMacWindowBounds(source) + } else if (process.platform === 'linux') { + bounds = await resolveLinuxWindowBounds(source) + } + } + } + + if (!bounds || bounds.width <= 0 || bounds.height <= 0) { + bounds = getDisplayBoundsForSource(source) + } + + // ── 3. Show traveling wave highlight ── + const pad = 6 + const highlightWin = new BrowserWindow({ + x: bounds.x - pad, + y: bounds.y - pad, + width: bounds.width + pad * 2, + height: bounds.height + pad * 2, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + resizable: false, + focusable: false, + webPreferences: { nodeIntegration: false, contextIsolation: true }, + }) + + highlightWin.setIgnoreMouseEvents(true) + + const html = ` + +
+
+` + + await highlightWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) + + setTimeout(() => { + if (!highlightWin.isDestroyed()) highlightWin.close() + }, 1700) + + return { success: true } + } catch (error) { + console.error('Failed to show source highlight:', error) + return { success: false } + } + }) + ipcMain.handle('get-selected-source', () => { return selectedSource }) diff --git a/electron/preload.ts b/electron/preload.ts index ebc4fed..50fe971 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -26,6 +26,9 @@ contextBridge.exposeInMainWorld('electronAPI', { selectSource: (source: any) => { return ipcRenderer.invoke('select-source', source) }, + showSourceHighlight: (source: any) => { + return ipcRenderer.invoke('show-source-highlight', source) + }, getSelectedSource: () => { return ipcRenderer.invoke('get-selected-source') }, diff --git a/electron/windows.ts b/electron/windows.ts index d2f7466..a762366 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -29,7 +29,7 @@ export function createHudOverlayWindow(): BrowserWindow { const windowWidth = 600; - const windowHeight = 155; + const windowHeight = 450; const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); @@ -39,8 +39,8 @@ export function createHudOverlayWindow(): BrowserWindow { height: windowHeight, minWidth: 600, maxWidth: 600, - minHeight: 155, - maxHeight: 155, + minHeight: 450, + maxHeight: 450, x: x, y: y, frame: false, diff --git a/src/App.tsx b/src/App.tsx index 2a471f1..242e464 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,12 @@ export default function App() { document.getElementById('root')?.style.setProperty('background', 'transparent'); } + if (type === 'hud-overlay') { + document.documentElement.style.overflow = 'visible'; + document.body.style.overflow = 'visible'; + document.getElementById('root')?.style.setProperty('overflow', 'visible'); + } + // Load custom fonts on app initialization loadAllCustomFonts().catch((error) => { console.error('Failed to load custom fonts:', error); diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 8e01e55..3030458 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -1,51 +1,232 @@ .electronDrag { - -webkit-app-region: drag; + -webkit-app-region: drag; } -.hudBar { - isolation: isolate; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18); +.electronNoDrag { + -webkit-app-region: no-drag; } -.electronNoDrag { - -webkit-app-region: no-drag; +.bar { + display: flex; + align-items: center; + gap: 6px; + background: rgba(18, 18, 24, 0.97); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 16px; + padding: 7px 10px; + box-shadow: + 0 8px 40px rgba(0, 0, 0, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + overflow: visible; + position: relative; +} + +.sep { + width: 1px; + height: 22px; + background: #2a2a34; + margin: 0 4px; + flex-shrink: 0; +} + +.ib { + position: relative; + width: 36px; + height: 36px; + border-radius: 10px; + border: none; + background: transparent; + color: #6b6b78; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.ib:hover { + background: rgba(255, 255, 255, 0.07); + color: #eeeef2; +} + +.ibActive { + color: #6360f5; +} + +.ibActive:hover { + color: #7b78ff; +} + +.ibRed { + color: #f43f5e; +} + +.ibRed:hover { + color: #ff5a75; +} + +.ibGreen { + color: #34d399; +} + +.ibGreen:hover { + color: #4eeeb0; +} + +.screenSel { + position: relative; + display: inline-flex; + align-items: center; + gap: 7px; + height: 36px; + padding: 0 12px 0 10px; + border-radius: 10px; + border: 1px solid #2a2a34; + background: #1a1a22; + color: #eeeef2; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.screenSel:hover { + border-color: #3e3e4c; + background: #20202a; +} + +.dropdown { + width: 260px; + max-height: 320px; + overflow-y: auto; + background: rgba(22, 22, 30, 0.96); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 12px; + padding: 6px; + margin-bottom: 8px; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5); + animation: dropdownIn 0.15s ease; +} + +@keyframes dropdownIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dropdown::-webkit-scrollbar { + width: 4px; +} + +.dropdown::-webkit-scrollbar-track { + background: transparent; } +.dropdown::-webkit-scrollbar-thumb { + background: #2a2a34; + border-radius: 2px; +} + +.ddLabel { + font-size: 9px; + font-weight: 600; + color: #6b6b78; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 6px 10px 4px; +} -.folderButton { - cursor: pointer; - display: flex; - align-items: center; - gap: 4px; +.ddItem { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: none; + background: transparent; + font-size: 12px; + color: #6b6b78; + cursor: pointer; + transition: all 0.12s ease; + text-align: left; } -.folderText { - color: #cbd5e1; - transition: text-decoration 0.15s; +.ddItem:hover { + background: rgba(255, 255, 255, 0.06); + color: #eeeef2; } -.folderButton:hover .folderText { - text-decoration: underline; +.ddItemSelected { + color: #6360f5; } -.hudOverlayButton { - cursor: pointer; - background: none; - border: none; - color: #fff; - opacity: 0.7; - transition: opacity 0.15s; +.recBtn { + position: relative; + width: 42px; + height: 42px; + border-radius: 50%; + border: none; + background: #f43f5e; + color: #fff; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + flex-shrink: 0; + box-shadow: 0 0 0 0 rgba(244, 63, 94, 0.3); } -.hudOverlayButton:hover { - opacity: 0.7; - background: none !important; + +.recBtn:hover { + background: #ff5a75; + box-shadow: 0 0 0 6px rgba(244, 63, 94, 0.15); +} + +.recBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.recBtn:disabled:hover { + background: #f43f5e; + box-shadow: none; +} + +.recDot { + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + transition: all 0.2s ease; +} + +.recDotBlink { + animation: blink 1.2s ease-in-out infinite; +} + +@keyframes blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.2; + } } .micSelect { - color-scheme: dark; + color-scheme: dark; } .micSelect option { - background-color: #131722; - color: #e5e7eb; + background-color: #1a1a22; + color: #eeeef2; } diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 20d121b..e97cae1 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,355 +1,509 @@ -import { useEffect, useState } from "react"; -import { BsRecordCircle } from "react-icons/bs"; -import { FaRegStopCircle } from "react-icons/fa"; -import { FaFolderOpen } from "react-icons/fa6"; -import { FiMinus, FiX } from "react-icons/fi"; -import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md"; -import { Languages } from "lucide-react"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Monitor, + Mic, + MicOff, + ChevronUp, + Pause, + Square, + X, + Play, + Minus, + MoreVertical, + FolderOpen, + VideoIcon, + Languages, + Volume2, + VolumeX, + AppWindow, +} from "lucide-react"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { useScopedT } from "../../contexts/I18nContext"; -import { Button } from "../ui/button"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { ContentClamp } from "../ui/content-clamp"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; import { useI18n } from "@/contexts/I18nContext"; import { SUPPORTED_LOCALES } from "@/i18n/config"; import type { AppLocale } from "@/i18n/config"; import styles from "./LaunchWindow.module.css"; -export function LaunchWindow() { - const { locale, setLocale } = useI18n(); - const t = useScopedT('launch'); - - const LOCALE_LABELS: Record = { en: "EN", es: "ES", "zh-CN": "中文" }; - const { - recording, - toggleRecording, - microphoneEnabled, - setMicrophoneEnabled, - microphoneDeviceId, - setMicrophoneDeviceId, - systemAudioEnabled, - setSystemAudioEnabled, - } = useScreenRecorder(); - const [recordingStart, setRecordingStart] = useState(null); - const [elapsed, setElapsed] = useState(0); - const showMicControls = microphoneEnabled && !recording; - const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices(microphoneEnabled); - const { level } = useAudioLevelMeter({ - enabled: showMicControls, - deviceId: microphoneDeviceId, - }); - - useEffect(() => { - if (selectedDeviceId && selectedDeviceId !== "default") { - setMicrophoneDeviceId(selectedDeviceId); - } - }, [selectedDeviceId, setMicrophoneDeviceId]); - - useEffect(() => { - let timer: NodeJS.Timeout | null = null; - if (recording) { - if (!recordingStart) setRecordingStart(Date.now()); - timer = setInterval(() => { - if (recordingStart) { - setElapsed(Math.floor((Date.now() - recordingStart) / 1000)); - } - }, 1000); - } else { - setRecordingStart(null); - setElapsed(0); - if (timer) clearInterval(timer); - } - return () => { - if (timer) clearInterval(timer); - }; - }, [recording, recordingStart]); - - const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60).toString().padStart(2, "0"); - const s = (seconds % 60).toString().padStart(2, "0"); - return `${m}:${s}`; - }; - - const [selectedSource, setSelectedSource] = useState("Screen"); - const [hasSelectedSource, setHasSelectedSource] = useState(false); - const [recordingsDirectory, setRecordingsDirectory] = useState(null); - - useEffect(() => { - const checkSelectedSource = async () => { - if (window.electronAPI) { - const source = await window.electronAPI.getSelectedSource(); - if (source) { - setSelectedSource(source.name); - setHasSelectedSource(true); - } else { - setSelectedSource("Screen"); - setHasSelectedSource(false); - } - } - }; - - void checkSelectedSource(); - const interval = setInterval(checkSelectedSource, 500); - return () => clearInterval(interval); - }, []); - - const openSourceSelector = () => { - window.electronAPI?.openSourceSelector(); - }; - - const openVideoFile = async () => { - const result = await window.electronAPI.openVideoFilePicker(); - if (result.canceled) { - return; - } - - if (result.success && result.path) { - await window.electronAPI.setCurrentVideoPath(result.path); - await window.electronAPI.switchToEditor(); - } - }; - - const openProjectFile = async () => { - const result = await window.electronAPI.loadProjectFile(); - if (result.canceled || !result.success) { - return; - } - await window.electronAPI.switchToEditor(); - }; - - const sendHudOverlayHide = () => { - window.electronAPI?.hudOverlayHide?.(); - }; - - const sendHudOverlayClose = () => { - window.electronAPI?.hudOverlayClose?.(); - }; - - const chooseRecordingsDirectory = async () => { - const result = await window.electronAPI.chooseRecordingsDirectory(); - if (result.canceled) { - return; - } - if (result.success && result.path) { - setRecordingsDirectory(result.path); - } - }; - - useEffect(() => { - const loadRecordingsDirectory = async () => { - const result = await window.electronAPI.getRecordingsDirectory(); - if (result.success) { - setRecordingsDirectory(result.path); - } - }; - - void loadRecordingsDirectory(); - }, []); - - const recordingsDirectoryName = recordingsDirectory - ? recordingsDirectory.split(/[\\/]/).filter(Boolean).pop() || recordingsDirectory - : "recordings"; - const dividerClass = "mx-1 h-5 w-px shrink-0 bg-white/35"; - - const toggleMicrophone = () => { - if (!recording) { - setMicrophoneEnabled(!microphoneEnabled); - } - }; - - return ( -
-
- {showMicControls && ( -
- - -
- )} - -
-
- -
- - - -
- -
- - -
- -
- - - - - -
-
- - - - - - - - {SUPPORTED_LOCALES.map((code) => ( - setLocale(code as AppLocale)} - className={`text-xs cursor-pointer ${ - locale === code ? "text-white font-medium" : "text-white/60" - }`} - > - {LOCALE_LABELS[code] ?? code} - - ))} - - -
- - -
-
-
-
- ); +interface DesktopSource { + id: string; + name: string; + thumbnail: string | null; + display_id: string; + appIcon: string | null; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; } +const LOCALE_LABELS: Record = { + en: "EN", + es: "ES", + "zh-CN": "中文", +}; + +function IconButton({ + onClick, + title, + className = "", + children, +}: { + onClick?: () => void; + title?: string; + className?: string; + children: ReactNode; +}) { + return ( + + ); +} + +function DropdownItem({ + onClick, + selected, + icon, + children, + trailing, +}: { + onClick: () => void; + selected?: boolean; + icon: ReactNode; + children: ReactNode; + trailing?: ReactNode; +}) { + return ( + + ); +} + +function Separator() { + return
; +} + +export function LaunchWindow() { + const { locale, setLocale } = useI18n(); + const t = useScopedT("launch"); + + const { + recording, + paused, + toggleRecording, + pauseRecording, + resumeRecording, + cancelRecording, + microphoneEnabled, + setMicrophoneEnabled, + microphoneDeviceId, + setMicrophoneDeviceId, + systemAudioEnabled, + setSystemAudioEnabled, + } = useScreenRecorder(); + + const [recordingStart, setRecordingStart] = useState(null); + const [elapsed, setElapsed] = useState(0); + const [pausedAt, setPausedAt] = useState(null); + const [pausedTotal, setPausedTotal] = useState(0); + const [selectedSource, setSelectedSource] = useState("Screen"); + const [hasSelectedSource, setHasSelectedSource] = useState(false); + const [recordingsDirectory, setRecordingsDirectory] = useState(null); + const [activeDropdown, setActiveDropdown] = useState<"none" | "sources" | "more">("none"); + const [sources, setSources] = useState([]); + const [sourcesLoading, setSourcesLoading] = useState(false); + const dropdownRef = useRef(null); + + const showMicControls = microphoneEnabled && !recording; + const { devices, selectedDeviceId, setSelectedDeviceId } = + useMicrophoneDevices(microphoneEnabled); + const { level } = useAudioLevelMeter({ + enabled: showMicControls, + deviceId: microphoneDeviceId, + }); + + useEffect(() => { + if (selectedDeviceId && selectedDeviceId !== "default") { + setMicrophoneDeviceId(selectedDeviceId); + } + }, [selectedDeviceId, setMicrophoneDeviceId]); + + useEffect(() => { + let timer: NodeJS.Timeout | null = null; + if (recording) { + if (!recordingStart) { + setRecordingStart(Date.now()); + setPausedTotal(0); + } + if (paused) { + if (!pausedAt) setPausedAt(Date.now()); + if (timer) clearInterval(timer); + } else { + if (pausedAt) { + setPausedTotal((prev) => prev + (Date.now() - pausedAt)); + setPausedAt(null); + } + timer = setInterval(() => { + if (recordingStart) { + setElapsed(Math.floor((Date.now() - recordingStart - pausedTotal) / 1000)); + } + }, 1000); + } + } else { + setRecordingStart(null); + setElapsed(0); + setPausedAt(null); + setPausedTotal(0); + if (timer) clearInterval(timer); + } + return () => { + if (timer) clearInterval(timer); + }; + }, [recording, recordingStart, paused, pausedAt, pausedTotal]); + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60).toString().padStart(2, "0"); + const s = (seconds % 60).toString().padStart(2, "0"); + return `${m}:${s}`; + }; + + useEffect(() => { + const checkSelectedSource = async () => { + if (!window.electronAPI) return; + const source = await window.electronAPI.getSelectedSource(); + if (source) { + setSelectedSource(source.name); + setHasSelectedSource(true); + } else { + setSelectedSource("Screen"); + setHasSelectedSource(false); + } + }; + void checkSelectedSource(); + const interval = setInterval(checkSelectedSource, 500); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + const load = async () => { + const result = await window.electronAPI.getRecordingsDirectory(); + if (result.success) setRecordingsDirectory(result.path); + }; + void load(); + }, []); + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setActiveDropdown("none"); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + const fetchSources = useCallback(async () => { + if (!window.electronAPI) return; + setSourcesLoading(true); + try { + const rawSources = await window.electronAPI.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 160, height: 90 }, + fetchWindowIcons: true, + }); + setSources( + rawSources.map((s) => { + const isWindow = s.id.startsWith("window:"); + const type = s.sourceType ?? (isWindow ? "window" : "screen"); + let displayName = s.name; + let appName = s.appName; + if (isWindow && !appName && s.name.includes(" — ")) { + const parts = s.name.split(" — "); + appName = parts[0]?.trim(); + displayName = parts.slice(1).join(" — ").trim() || s.name; + } else if (isWindow && s.windowTitle) { + displayName = s.windowTitle; + } + return { + id: s.id, + name: displayName, + thumbnail: s.thumbnail, + display_id: s.display_id, + appIcon: s.appIcon, + sourceType: type, + appName, + windowTitle: s.windowTitle ?? displayName, + }; + }), + ); + } catch (error) { + console.error("Failed to fetch sources:", error); + } finally { + setSourcesLoading(false); + } + }, []); + + const toggleDropdown = (which: "sources" | "more") => { + setActiveDropdown(activeDropdown === which ? "none" : which); + if (activeDropdown !== which && which === "sources") fetchSources(); + }; + + const handleSourceSelect = async (source: DesktopSource) => { + await window.electronAPI.selectSource(source); + setSelectedSource(source.name); + setHasSelectedSource(true); + setActiveDropdown("none"); + window.electronAPI.showSourceHighlight?.({ + ...source, + name: source.appName ? `${source.appName} — ${source.name}` : source.name, + appName: source.appName, + }); + }; + + const openVideoFile = async () => { + setActiveDropdown("none"); + const result = await window.electronAPI.openVideoFilePicker(); + if (result.canceled) return; + if (result.success && result.path) { + await window.electronAPI.setCurrentVideoPath(result.path); + await window.electronAPI.switchToEditor(); + } + }; + + const openProjectFile = async () => { + setActiveDropdown("none"); + const result = await window.electronAPI.loadProjectFile(); + if (result.canceled || !result.success) return; + await window.electronAPI.switchToEditor(); + }; + + const chooseRecordingsDirectory = async () => { + setActiveDropdown("none"); + const result = await window.electronAPI.chooseRecordingsDirectory(); + if (result.canceled) return; + if (result.success && result.path) setRecordingsDirectory(result.path); + }; + + const toggleMicrophone = () => { + if (!recording) setMicrophoneEnabled(!microphoneEnabled); + }; + + const screenSources = sources.filter((s) => s.sourceType === "screen"); + const windowSources = sources.filter((s) => s.sourceType === "window"); + + return ( +
+ {activeDropdown !== "none" && ( +
+ {activeDropdown === "sources" && ( + <> + {sourcesLoading ? ( +
+
+
+ ) : ( + <> + {screenSources.length > 0 && ( + <> +
Screens
+ {screenSources.map((source) => ( + } + selected={selectedSource === source.name} + onClick={() => handleSourceSelect(source)} + > + {source.name} + + ))} + + )} + {windowSources.length > 0 && ( + <> +
0 ? { marginTop: 4 } : undefined}> + Windows +
+ {windowSources.map((source) => ( + } + selected={selectedSource === source.name} + onClick={() => handleSourceSelect(source)} + > + {source.appName && source.appName !== source.name + ? `${source.appName} — ${source.name}` + : source.name} + + ))} + + )} + {screenSources.length === 0 && windowSources.length === 0 && ( +
+ No sources found +
+ )} + + )} + + )} + + {activeDropdown === "more" && ( + <> + : } + onClick={() => setSystemAudioEnabled(!systemAudioEnabled)} + trailing={systemAudioEnabled ? : undefined} + > + System Audio + + } onClick={chooseRecordingsDirectory}> + Recordings Folder + + } onClick={openVideoFile}> + {t("recording.openVideoFile")} + + } onClick={openProjectFile}> + {t("recording.openProject")} + +
Language
+ {SUPPORTED_LOCALES.map((code) => ( + } + selected={locale === code} + onClick={() => { setLocale(code as AppLocale); setActiveDropdown("none"); }} + > + {LOCALE_LABELS[code] ?? code} + + ))} + + )} +
+ )} + + {showMicControls && ( +
+ + +
+ )} + +
+
+ +
+ + {recording ? ( + <> +
+
+ + {paused ? "PAUSED" : "REC"} + +
+ + + {formatTime(elapsed)} + + + + + + {microphoneEnabled ? : } + + + + + + {paused ? : } + + + + + + + + + + + ) : ( + <> + + + + + + {microphoneEnabled ? : } + + + + + + + + + toggleDropdown("more")} title="More"> + + + + window.electronAPI?.hudOverlayHide?.()} title={t("recording.hideHud")}> + + + + window.electronAPI?.hudOverlayClose?.()} title={t("recording.closeApp")}> + + + + )} +
+
+ ); +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 53ae672..84253b8 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -28,7 +28,11 @@ const MIC_GAIN_BOOST = 1.4; type UseScreenRecorderReturn = { recording: boolean; + paused: boolean; toggleRecording: () => void; + pauseRecording: () => void; + resumeRecording: () => void; + cancelRecording: () => void; preparePermissions: (options?: { startup?: boolean }) => Promise; isMacOS: boolean; microphoneEnabled: boolean; @@ -41,6 +45,7 @@ type UseScreenRecorderReturn = { export function useScreenRecorder(): UseScreenRecorderReturn { const [recording, setRecording] = useState(false); + const [paused, setPaused] = useState(false); const [starting, setStarting] = useState(false); const [isMacOS, setIsMacOS] = useState(false); const [microphoneEnabled, setMicrophoneEnabled] = useState(false); @@ -150,6 +155,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }, []); const stopRecording = useRef(() => { + setPaused(false); if (nativeScreenRecording.current) { nativeScreenRecording.current = false; setRecording(false); @@ -303,13 +309,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { microphoneLabel: micLabel, }); if (!nativeResult.success) { - if (useWgcCapture) { - console.warn("WGC capture failed, falling back to browser capture:", nativeResult.error ?? nativeResult.message); - } else { - throw new Error( - nativeResult.error ?? nativeResult.message ?? "Failed to start native screen recording", - ); - } + console.warn("Native capture failed, falling back to browser capture:", nativeResult.error ?? nativeResult.message); } if (nativeResult.success) { @@ -548,6 +548,46 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; + const pauseRecording = useCallback(() => { + if (!recording || paused) return; + if (mediaRecorder.current?.state === "recording") { + mediaRecorder.current.pause(); + setPaused(true); + } + }, [recording, paused]); + + const resumeRecording = useCallback(() => { + if (!recording || !paused) return; + if (mediaRecorder.current?.state === "paused") { + mediaRecorder.current.resume(); + setPaused(false); + } + }, [recording, paused]); + + const cancelRecording = useCallback(() => { + if (!recording) return; + setPaused(false); + + if (nativeScreenRecording.current) { + nativeScreenRecording.current = false; + wgcRecording.current = false; + setRecording(false); + window.electronAPI?.setRecordingState(false); + void window.electronAPI.stopNativeScreenRecording(); + return; + } + + if (mediaRecorder.current) { + chunks.current = []; + cleanupCapturedMedia(); + if (mediaRecorder.current.state !== "inactive") { + mediaRecorder.current.stop(); + } + setRecording(false); + window.electronAPI?.setRecordingState(false); + } + }, [recording, cleanupCapturedMedia]); + const toggleRecording = () => { if (starting) { return; @@ -558,7 +598,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return { recording, + paused, toggleRecording, + pauseRecording, + resumeRecording, + cancelRecording, preparePermissions, isMacOS, microphoneEnabled, From 080b7826591e2aa2ae5c966bd3221ccc9e8f1d4e Mon Sep 17 00:00:00 2001 From: Gurpreet Kait Date: Tue, 17 Mar 2026 16:32:25 +0530 Subject: [PATCH 2/5] fix(recording): allow stop while paused and guard native pause - Fix stopRecording to handle MediaRecorder 'paused' state by resuming before stopping, ensuring final data chunk is flushed - Guard pauseRecording as no-op for native recordings (macOS SCK / Windows WGC) that lack pause IPC support - Add 14 unit tests for pause/stop state machine --- src/hooks/useScreenRecorder.test.ts | 233 ++++++++++++++++++++++++++++ src/hooks/useScreenRecorder.ts | 8 +- 2 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useScreenRecorder.test.ts diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts new file mode 100644 index 0000000..2a5610f --- /dev/null +++ b/src/hooks/useScreenRecorder.test.ts @@ -0,0 +1,233 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Tests for the MediaRecorder pause/stop state machine logic + * extracted from useScreenRecorder. + * + * These verify that: + * - Stop works from both "recording" and "paused" states + * - Resume is called before stop when stopping from paused state + * - Pause is a no-op when already paused or not recording + * - Resume is a no-op when not paused + */ + +function createMockMediaRecorder(initialState: RecordingState = "inactive") { + let _state: RecordingState = initialState; + return { + get state() { + return _state; + }, + pause: vi.fn(() => { + if (_state === "recording") _state = "paused"; + }), + resume: vi.fn(() => { + if (_state === "paused") _state = "recording"; + }), + stop: vi.fn(() => { + _state = "inactive"; + }), + start: vi.fn(() => { + _state = "recording"; + }), + }; +} + +/** + * Extracted state machine logic matching useScreenRecorder's stopRecording, + * pauseRecording, and resumeRecording implementations. + */ +function stopRecording( + recorder: ReturnType, + isNativeRecording: boolean, +) { + if (isNativeRecording) { + return { stopped: true, wasNative: true }; + } + + const recorderState = recorder.state; + if (recorderState === "recording" || recorderState === "paused") { + if (recorderState === "paused") { + recorder.resume(); + } + recorder.stop(); + return { stopped: true, wasNative: false }; + } + return { stopped: false, wasNative: false }; +} + +function pauseRecording( + recorder: ReturnType, + recording: boolean, + paused: boolean, + isNativeRecording: boolean, +): boolean { + if (!recording || paused) return false; + if (isNativeRecording) return false; + if (recorder.state === "recording") { + recorder.pause(); + return true; + } + return false; +} + +function resumeRecording( + recorder: ReturnType, + recording: boolean, + paused: boolean, +): boolean { + if (!recording || !paused) return false; + if (recorder.state === "paused") { + recorder.resume(); + return true; + } + return false; +} + +describe("useScreenRecorder state machine", () => { + let recorder: ReturnType; + + beforeEach(() => { + recorder = createMockMediaRecorder("recording"); + }); + + describe("stopRecording", () => { + it("stops from recording state", () => { + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(recorder.stop).toHaveBeenCalled(); + expect(recorder.resume).not.toHaveBeenCalled(); + expect(recorder.state).toBe("inactive"); + }); + + it("resumes then stops from paused state", () => { + recorder.pause(); + expect(recorder.state).toBe("paused"); + + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(recorder.resume).toHaveBeenCalled(); + expect(recorder.stop).toHaveBeenCalled(); + expect(recorder.state).toBe("inactive"); + }); + + it("resume is called before stop when paused", () => { + recorder.pause(); + const callOrder: string[] = []; + recorder.resume.mockImplementation(() => { + callOrder.push("resume"); + }); + recorder.stop.mockImplementation(() => { + callOrder.push("stop"); + }); + + stopRecording(recorder, false); + + expect(callOrder).toEqual(["resume", "stop"]); + }); + + it("does nothing when already inactive", () => { + const inactiveRecorder = createMockMediaRecorder("inactive"); + + const result = stopRecording(inactiveRecorder, false); + + expect(result.stopped).toBe(false); + expect(inactiveRecorder.stop).not.toHaveBeenCalled(); + }); + + it("delegates to native path for native recordings", () => { + const result = stopRecording(recorder, true); + + expect(result.stopped).toBe(true); + expect(result.wasNative).toBe(true); + expect(recorder.stop).not.toHaveBeenCalled(); + }); + }); + + describe("pauseRecording", () => { + it("pauses an active recording", () => { + const result = pauseRecording(recorder, true, false, false); + + expect(result).toBe(true); + expect(recorder.pause).toHaveBeenCalled(); + expect(recorder.state).toBe("paused"); + }); + + it("does nothing when already paused", () => { + recorder.pause(); + recorder.pause.mockClear(); + + const result = pauseRecording(recorder, true, true, false); + + expect(result).toBe(false); + expect(recorder.pause).not.toHaveBeenCalled(); + }); + + it("does nothing when not recording", () => { + const result = pauseRecording(recorder, false, false, false); + + expect(result).toBe(false); + expect(recorder.pause).not.toHaveBeenCalled(); + }); + + it("does nothing for native recordings", () => { + const result = pauseRecording(recorder, true, false, true); + + expect(result).toBe(false); + expect(recorder.pause).not.toHaveBeenCalled(); + }); + }); + + describe("resumeRecording", () => { + it("resumes a paused recording", () => { + recorder.pause(); + + const result = resumeRecording(recorder, true, true); + + expect(result).toBe(true); + expect(recorder.resume).toHaveBeenCalled(); + expect(recorder.state).toBe("recording"); + }); + + it("does nothing when not paused", () => { + const result = resumeRecording(recorder, true, false); + + expect(result).toBe(false); + expect(recorder.resume).not.toHaveBeenCalled(); + }); + + it("does nothing when not recording", () => { + const result = resumeRecording(recorder, false, true); + + expect(result).toBe(false); + }); + }); + + describe("pause → stop → editor flow", () => { + it("full lifecycle: record → pause → stop completes cleanly", () => { + expect(recorder.state).toBe("recording"); + + pauseRecording(recorder, true, false, false); + expect(recorder.state).toBe("paused"); + + const result = stopRecording(recorder, false); + expect(result.stopped).toBe(true); + expect(recorder.state).toBe("inactive"); + }); + + it("full lifecycle: record → pause → resume → stop completes cleanly", () => { + expect(recorder.state).toBe("recording"); + + pauseRecording(recorder, true, false, false); + expect(recorder.state).toBe("paused"); + + resumeRecording(recorder, true, true); + expect(recorder.state).toBe("recording"); + + const result = stopRecording(recorder, false); + expect(result.stopped).toBe(true); + expect(recorder.state).toBe("inactive"); + }); + }); +}); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 84253b8..d6af409 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -185,7 +185,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - if (mediaRecorder.current?.state === "recording") { + const recorderState = mediaRecorder.current?.state; + if (recorderState === "recording" || recorderState === "paused") { + if (recorderState === "paused") { + mediaRecorder.current.resume(); + } cleanupCapturedMedia(); mediaRecorder.current.stop(); setRecording(false); @@ -550,6 +554,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const pauseRecording = useCallback(() => { if (!recording || paused) return; + // Native recordings (macOS SCK / Windows WGC) don't support pause yet + if (nativeScreenRecording.current) return; if (mediaRecorder.current?.state === "recording") { mediaRecorder.current.pause(); setPaused(true); From 03c05c846481dfedd3647928e46ca3cd90389f18 Mon Sep 17 00:00:00 2001 From: Gurpreet Kait Date: Tue, 17 Mar 2026 20:54:49 +0530 Subject: [PATCH 3/5] - Fix dropdown menu pushing HUD bar down by using 100vh height container with flex layout that reserves space above the bar for menu cards - Increase HUD window size to 620x520 for menu card headroom - Replace mic on/off toggle with mic selector card showing all available microphones with live audio level meters per device - Remove old separate mic controls panel - Increase bar size with larger buttons, padding, and separators - Fix TS null safety in stopRecording MediaRecorder accessfix --- electron/windows.ts | 12 +- src/components/launch/LaunchWindow.module.css | 58 ++-- src/components/launch/LaunchWindow.tsx | 270 +++++++++++------- src/hooks/useScreenRecorder.ts | 9 +- 4 files changed, 207 insertions(+), 142 deletions(-) diff --git a/electron/windows.ts b/electron/windows.ts index a762366..f5a46a5 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -28,8 +28,8 @@ export function createHudOverlayWindow(): BrowserWindow { const { workArea } = primaryDisplay; - const windowWidth = 600; - const windowHeight = 450; + const windowWidth = 620; + const windowHeight = 520; const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); @@ -37,10 +37,10 @@ export function createHudOverlayWindow(): BrowserWindow { const win = new BrowserWindow({ width: windowWidth, height: windowHeight, - minWidth: 600, - maxWidth: 600, - minHeight: 450, - maxHeight: 450, + minWidth: 620, + maxWidth: 620, + minHeight: 520, + maxHeight: 520, x: x, y: y, frame: false, diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 3030458..2ca0623 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -9,11 +9,11 @@ .bar { display: flex; align-items: center; - gap: 6px; + gap: 8px; background: rgba(18, 18, 24, 0.97); border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 16px; - padding: 7px 10px; + border-radius: 18px; + padding: 10px 14px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.04); @@ -23,7 +23,7 @@ .sep { width: 1px; - height: 22px; + height: 26px; background: #2a2a34; margin: 0 4px; flex-shrink: 0; @@ -31,9 +31,9 @@ .ib { position: relative; - width: 36px; - height: 36px; - border-radius: 10px; + width: 40px; + height: 40px; + border-radius: 11px; border: none; background: transparent; color: #6b6b78; @@ -79,9 +79,9 @@ display: inline-flex; align-items: center; gap: 7px; - height: 36px; - padding: 0 12px 0 10px; - border-radius: 10px; + height: 40px; + padding: 0 14px 0 12px; + border-radius: 11px; border: 1px solid #2a2a34; background: #1a1a22; color: #eeeef2; @@ -97,23 +97,35 @@ background: #20202a; } -.dropdown { - width: 260px; - max-height: 320px; +.menuArea { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + pointer-events: none; + overflow: hidden; + min-height: 0; +} + +.menuCard { + width: 300px; + max-height: 400px; overflow-y: auto; background: rgba(22, 22, 30, 0.96); border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 12px; - padding: 6px; + border-radius: 14px; + padding: 8px; + margin-top: auto; margin-bottom: 8px; box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5); - animation: dropdownIn 0.15s ease; + pointer-events: auto; + animation: menuCardIn 0.18s ease; } -@keyframes dropdownIn { +@keyframes menuCardIn { from { opacity: 0; - transform: translateY(8px); + transform: translateY(12px); } to { opacity: 1; @@ -121,15 +133,15 @@ } } -.dropdown::-webkit-scrollbar { +.menuCard::-webkit-scrollbar { width: 4px; } -.dropdown::-webkit-scrollbar-track { +.menuCard::-webkit-scrollbar-track { background: transparent; } -.dropdown::-webkit-scrollbar-thumb { +.menuCard::-webkit-scrollbar-thumb { background: #2a2a34; border-radius: 2px; } @@ -170,8 +182,8 @@ .recBtn { position: relative; - width: 42px; - height: 42px; + width: 46px; + height: 46px; border-radius: 50%; border: none; background: #f43f5e; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index e97cae1..3e5da6e 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -100,6 +100,33 @@ function Separator() { return
; } +function MicDeviceRow({ + device, + selected, + onSelect, +}: { + device: { deviceId: string; label: string }; + selected: boolean; + onSelect: () => void; +}) { + const { level } = useAudioLevelMeter({ + enabled: true, + deviceId: device.deviceId, + }); + + return ( + + ); +} + export function LaunchWindow() { const { locale, setLocale } = useI18n(); const t = useScopedT("launch"); @@ -126,18 +153,14 @@ export function LaunchWindow() { const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); const [recordingsDirectory, setRecordingsDirectory] = useState(null); - const [activeDropdown, setActiveDropdown] = useState<"none" | "sources" | "more">("none"); + const [activeDropdown, setActiveDropdown] = useState<"none" | "sources" | "more" | "mic">("none"); const [sources, setSources] = useState([]); const [sourcesLoading, setSourcesLoading] = useState(false); const dropdownRef = useRef(null); - const showMicControls = microphoneEnabled && !recording; + const micDropdownOpen = activeDropdown === "mic"; const { devices, selectedDeviceId, setSelectedDeviceId } = - useMicrophoneDevices(microphoneEnabled); - const { level } = useAudioLevelMeter({ - enabled: showMicControls, - deviceId: microphoneDeviceId, - }); + useMicrophoneDevices(microphoneEnabled || micDropdownOpen); useEffect(() => { if (selectedDeviceId && selectedDeviceId !== "default") { @@ -260,7 +283,7 @@ export function LaunchWindow() { } }, []); - const toggleDropdown = (which: "sources" | "more") => { + const toggleDropdown = (which: "sources" | "more" | "mic") => { setActiveDropdown(activeDropdown === which ? "none" : which); if (activeDropdown !== which && which === "sources") fetchSources(); }; @@ -302,7 +325,8 @@ export function LaunchWindow() { }; const toggleMicrophone = () => { - if (!recording) setMicrophoneEnabled(!microphoneEnabled); + if (recording) return; + toggleDropdown("mic"); }; const screenSources = sources.filter((s) => s.sourceType === "screen"); @@ -310,113 +334,140 @@ export function LaunchWindow() { return (
- {activeDropdown !== "none" && ( -
- {activeDropdown === "sources" && ( - <> - {sourcesLoading ? ( -
-
-
- ) : ( - <> - {screenSources.length > 0 && ( - <> -
Screens
- {screenSources.map((source) => ( - } - selected={selectedSource === source.name} - onClick={() => handleSourceSelect(source)} - > - {source.name} - - ))} - - )} - {windowSources.length > 0 && ( - <> -
0 ? { marginTop: 4 } : undefined}> - Windows + {/* Top area — flex:1 reserves space, menu card sits at bottom of this area */} +
+ {activeDropdown !== "none" && ( +
+ {activeDropdown === "sources" && ( + <> + {sourcesLoading ? ( +
+
+
+ ) : ( + <> + {screenSources.length > 0 && ( + <> +
Screens
+ {screenSources.map((source) => ( + } + selected={selectedSource === source.name} + onClick={() => handleSourceSelect(source)} + > + {source.name} + + ))} + + )} + {windowSources.length > 0 && ( + <> +
0 ? { marginTop: 4 } : undefined}> + Windows +
+ {windowSources.map((source) => ( + } + selected={selectedSource === source.name} + onClick={() => handleSourceSelect(source)} + > + {source.appName && source.appName !== source.name + ? `${source.appName} — ${source.name}` + : source.name} + + ))} + + )} + {screenSources.length === 0 && windowSources.length === 0 && ( +
+ No sources found
- {windowSources.map((source) => ( - } - selected={selectedSource === source.name} - onClick={() => handleSourceSelect(source)} - > - {source.appName && source.appName !== source.name - ? `${source.appName} — ${source.name}` - : source.name} - - ))} - - )} - {screenSources.length === 0 && windowSources.length === 0 && ( -
- No sources found -
- )} - - )} - - )} - - {activeDropdown === "more" && ( - <> - : } - onClick={() => setSystemAudioEnabled(!systemAudioEnabled)} - trailing={systemAudioEnabled ? : undefined} - > - System Audio - - } onClick={chooseRecordingsDirectory}> - Recordings Folder - - } onClick={openVideoFile}> - {t("recording.openVideoFile")} - - } onClick={openProjectFile}> - {t("recording.openProject")} - -
Language
- {SUPPORTED_LOCALES.map((code) => ( + )} + + )} + + )} + + {activeDropdown === "mic" && ( + <> +
Microphone
+ {microphoneEnabled && ( + } + onClick={() => { setMicrophoneEnabled(false); setActiveDropdown("none"); }} + > + Turn Off Microphone + + )} + {!microphoneEnabled && ( +
+ Select a microphone to enable +
+ )} + {devices.map((device) => ( + { + setMicrophoneEnabled(true); + setSelectedDeviceId(device.deviceId); + setMicrophoneDeviceId(device.deviceId); + }} + /> + ))} + {devices.length === 0 && ( +
+ No microphones found +
+ )} + + )} + + {activeDropdown === "more" && ( + <> } - selected={locale === code} - onClick={() => { setLocale(code as AppLocale); setActiveDropdown("none"); }} + icon={systemAudioEnabled ? : } + onClick={() => setSystemAudioEnabled(!systemAudioEnabled)} + trailing={systemAudioEnabled ? : undefined} > - {LOCALE_LABELS[code] ?? code} + System Audio - ))} - - )} -
- )} - - {showMicControls && ( -
- - -
- )} + } onClick={chooseRecordingsDirectory}> + Recordings Folder + + } onClick={openVideoFile}> + {t("recording.openVideoFile")} + + } onClick={openProjectFile}> + {t("recording.openProject")} + +
Language
+ {SUPPORTED_LOCALES.map((code) => ( + } + selected={locale === code} + onClick={() => { setLocale(code as AppLocale); setActiveDropdown("none"); }} + > + {LOCALE_LABELS[code] ?? code} + + ))} + + )} +
+ )} +
-
+ {/* Bottom section — fixed height, bar always stays here */} +
+
@@ -503,6 +554,7 @@ export function LaunchWindow() { )} +
); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index d6af409..26a4385 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -185,13 +185,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - const recorderState = mediaRecorder.current?.state; - if (recorderState === "recording" || recorderState === "paused") { + const recorder = mediaRecorder.current; + const recorderState = recorder?.state; + if (recorder && (recorderState === "recording" || recorderState === "paused")) { if (recorderState === "paused") { - mediaRecorder.current.resume(); + recorder.resume(); } cleanupCapturedMedia(); - mediaRecorder.current.stop(); + recorder.stop(); setRecording(false); window.electronAPI?.setRecordingState(false); } From bb826a54bf68c5d0971998ad956ddba242acd48a Mon Sep 17 00:00:00 2001 From: Gurpreet Kait Date: Wed, 18 Mar 2026 10:00:55 +0530 Subject: [PATCH 4/5] show system audio in HUD bar --- src/components/launch/LaunchWindow.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 3e5da6e..388af22 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -432,13 +432,6 @@ export function LaunchWindow() { {activeDropdown === "more" && ( <> - : } - onClick={() => setSystemAudioEnabled(!systemAudioEnabled)} - trailing={systemAudioEnabled ? : undefined} - > - System Audio - } onClick={chooseRecordingsDirectory}> Recordings Folder @@ -528,6 +521,14 @@ export function LaunchWindow() { {microphoneEnabled ? : } + setSystemAudioEnabled(!systemAudioEnabled)} + title={systemAudioEnabled ? "Disable System Audio" : "Enable System Audio"} + className={systemAudioEnabled ? styles.ibActive : ""} + > + {systemAudioEnabled ? : } + +
)} @@ -533,18 +533,18 @@ export function LaunchWindow() { {activeDropdown === "mic" && ( <> -
Microphone
+
{t("recording.microphone")}
{microphoneEnabled && ( } onClick={() => { setMicrophoneEnabled(false); setActiveDropdown("none"); }} > - Turn Off Microphone + {t("recording.turnOffMicrophone")} )} {!microphoneEnabled && (
- Select a microphone to enable + {t("recording.selectMicToEnable")}
)} {devices.map((device) => ( @@ -561,7 +561,7 @@ export function LaunchWindow() { ))} {devices.length === 0 && (
- No microphones found + {t("recording.noMicrophonesFound")}
)} @@ -569,18 +569,18 @@ export function LaunchWindow() { {activeDropdown === "webcam" && ( <> -
Webcam
+
{t("recording.webcam")}
{webcamEnabled && ( } onClick={() => { setWebcamEnabled(false); setActiveDropdown("none"); }} > - Turn Off Webcam + {t("recording.turnOffWebcam")} )} {!webcamEnabled && (
- Select a webcam to enable + {t("recording.selectWebcamToEnable")}
)} {showWebcamControls && ( @@ -612,7 +612,7 @@ export function LaunchWindow() { ))} {videoDevices.length === 0 && (
- No webcams found + {t("recording.noWebcamsFound")}
)} @@ -637,7 +637,7 @@ export function LaunchWindow() { {activeDropdown === "more" && ( <> } onClick={chooseRecordingsDirectory}> - Recordings Folder + {t("recording.recordingsFolder")} } onClick={openVideoFile}> {t("recording.openVideoFile")} @@ -645,7 +645,7 @@ export function LaunchWindow() { } onClick={openProjectFile}> {t("recording.openProject")} -
Language
+
{t("recording.language")}
{SUPPORTED_LOCALES.map((code) => (
- {paused ? "PAUSED" : "REC"} + {paused ? t("recording.paused") : t("recording.rec")}
@@ -690,15 +690,15 @@ export function LaunchWindow() { - + {paused ? : } - + - + @@ -772,7 +772,7 @@ export function LaunchWindow() { - toggleDropdown("more")} title="More"> + toggleDropdown("more")} title={t("recording.more")}> diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index 2a5610f..e8c2b6a 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -1,15 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -/** - * Tests for the MediaRecorder pause/stop state machine logic - * extracted from useScreenRecorder. - * - * These verify that: - * - Stop works from both "recording" and "paused" states - * - Resume is called before stop when stopping from paused state - * - Pause is a no-op when already paused or not recording - * - Resume is a no-op when not paused - */ +type RecordingState = "inactive" | "recording" | "paused"; function createMockMediaRecorder(initialState: RecordingState = "inactive") { let _state: RecordingState = initialState; @@ -32,15 +23,15 @@ function createMockMediaRecorder(initialState: RecordingState = "inactive") { }; } -/** - * Extracted state machine logic matching useScreenRecorder's stopRecording, - * pauseRecording, and resumeRecording implementations. - */ function stopRecording( recorder: ReturnType, isNativeRecording: boolean, + webcamRecorder?: ReturnType | null, ) { if (isNativeRecording) { + if (webcamRecorder && webcamRecorder.state !== "inactive") { + webcamRecorder.stop(); + } return { stopped: true, wasNative: true }; } @@ -49,6 +40,9 @@ function stopRecording( if (recorderState === "paused") { recorder.resume(); } + if (webcamRecorder && webcamRecorder.state !== "inactive") { + webcamRecorder.stop(); + } recorder.stop(); return { stopped: true, wasNative: false }; } @@ -60,11 +54,20 @@ function pauseRecording( recording: boolean, paused: boolean, isNativeRecording: boolean, + webcamRecorder?: ReturnType | null, ): boolean { if (!recording || paused) return false; - if (isNativeRecording) return false; + if (isNativeRecording) { + if (webcamRecorder?.state === "recording") { + webcamRecorder.pause(); + } + return true; + } if (recorder.state === "recording") { recorder.pause(); + if (webcamRecorder?.state === "recording") { + webcamRecorder.pause(); + } return true; } return false; @@ -74,15 +77,49 @@ function resumeRecording( recorder: ReturnType, recording: boolean, paused: boolean, + isNativeRecording: boolean, + webcamRecorder?: ReturnType | null, ): boolean { if (!recording || !paused) return false; + if (isNativeRecording) { + if (webcamRecorder?.state === "paused") { + webcamRecorder.resume(); + } + return true; + } if (recorder.state === "paused") { recorder.resume(); + if (webcamRecorder?.state === "paused") { + webcamRecorder.resume(); + } return true; } return false; } +function cancelRecording( + recorder: ReturnType, + isNativeRecording: boolean, + chunks: { current: Blob[] }, + webcamRecorder?: ReturnType | null, + webcamChunks?: { current: Blob[] }, +) { + if (webcamChunks) webcamChunks.current = []; + if (webcamRecorder && webcamRecorder.state !== "inactive") { + webcamRecorder.stop(); + } + + if (isNativeRecording) { + return { cancelled: true, wasNative: true }; + } + + chunks.current = []; + if (recorder.state !== "inactive") { + recorder.stop(); + } + return { cancelled: true, wasNative: false }; +} + describe("useScreenRecorder state machine", () => { let recorder: ReturnType; @@ -143,6 +180,24 @@ describe("useScreenRecorder state machine", () => { expect(result.wasNative).toBe(true); expect(recorder.stop).not.toHaveBeenCalled(); }); + + it("stops webcam when stopping browser recording", () => { + const webcam = createMockMediaRecorder("recording"); + + stopRecording(recorder, false, webcam); + + expect(webcam.stop).toHaveBeenCalled(); + expect(webcam.state).toBe("inactive"); + }); + + it("stops webcam when stopping native recording", () => { + const webcam = createMockMediaRecorder("recording"); + + stopRecording(recorder, true, webcam); + + expect(webcam.stop).toHaveBeenCalled(); + expect(webcam.state).toBe("inactive"); + }); }); describe("pauseRecording", () => { @@ -171,11 +226,36 @@ describe("useScreenRecorder state machine", () => { expect(recorder.pause).not.toHaveBeenCalled(); }); - it("does nothing for native recordings", () => { + it("allows pause for native recordings", () => { const result = pauseRecording(recorder, true, false, true); - expect(result).toBe(false); - expect(recorder.pause).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("pauses webcam alongside browser recording", () => { + const webcam = createMockMediaRecorder("recording"); + + pauseRecording(recorder, true, false, false, webcam); + + expect(recorder.state).toBe("paused"); + expect(webcam.state).toBe("paused"); + }); + + it("pauses webcam during native recording pause", () => { + const webcam = createMockMediaRecorder("recording"); + + const result = pauseRecording(recorder, true, false, true, webcam); + + expect(result).toBe(true); + expect(webcam.state).toBe("paused"); + }); + + it("skips webcam pause when webcam is not recording", () => { + const webcam = createMockMediaRecorder("inactive"); + + pauseRecording(recorder, true, false, false, webcam); + + expect(webcam.pause).not.toHaveBeenCalled(); }); }); @@ -183,7 +263,7 @@ describe("useScreenRecorder state machine", () => { it("resumes a paused recording", () => { recorder.pause(); - const result = resumeRecording(recorder, true, true); + const result = resumeRecording(recorder, true, true, false); expect(result).toBe(true); expect(recorder.resume).toHaveBeenCalled(); @@ -191,21 +271,108 @@ describe("useScreenRecorder state machine", () => { }); it("does nothing when not paused", () => { - const result = resumeRecording(recorder, true, false); + const result = resumeRecording(recorder, true, false, false); expect(result).toBe(false); expect(recorder.resume).not.toHaveBeenCalled(); }); it("does nothing when not recording", () => { - const result = resumeRecording(recorder, false, true); + const result = resumeRecording(recorder, false, true, false); expect(result).toBe(false); }); + + it("resumes webcam alongside browser recording", () => { + const webcam = createMockMediaRecorder("recording"); + recorder.pause(); + webcam.pause(); + + resumeRecording(recorder, true, true, false, webcam); + + expect(recorder.state).toBe("recording"); + expect(webcam.state).toBe("recording"); + }); + + it("resumes webcam during native recording resume", () => { + const webcam = createMockMediaRecorder("recording"); + webcam.pause(); + + const result = resumeRecording(recorder, true, true, true, webcam); + + expect(result).toBe(true); + expect(webcam.state).toBe("recording"); + }); + + it("skips webcam resume when webcam is not paused", () => { + recorder.pause(); + const webcam = createMockMediaRecorder("inactive"); + + resumeRecording(recorder, true, true, false, webcam); + + expect(webcam.resume).not.toHaveBeenCalled(); + }); + }); + + describe("cancelRecording", () => { + it("clears chunks and stops browser recording", () => { + const chunks = { current: [new Blob(["data"])] }; + + const result = cancelRecording(recorder, false, chunks); + + expect(result.cancelled).toBe(true); + expect(result.wasNative).toBe(false); + expect(chunks.current).toEqual([]); + expect(recorder.stop).toHaveBeenCalled(); + expect(recorder.state).toBe("inactive"); + }); + + it("clears webcam chunks and stops webcam on cancel", () => { + const chunks = { current: [new Blob(["data"])] }; + const webcamChunks = { current: [new Blob(["cam"])] }; + const webcam = createMockMediaRecorder("recording"); + + cancelRecording(recorder, false, chunks, webcam, webcamChunks); + + expect(webcamChunks.current).toEqual([]); + expect(webcam.stop).toHaveBeenCalled(); + expect(webcam.state).toBe("inactive"); + }); + + it("stops webcam when cancelling native recording", () => { + const chunks = { current: [] as Blob[] }; + const webcam = createMockMediaRecorder("recording"); + + const result = cancelRecording(recorder, true, chunks, webcam); + + expect(result.wasNative).toBe(true); + expect(webcam.stop).toHaveBeenCalled(); + expect(recorder.stop).not.toHaveBeenCalled(); + }); + + it("handles cancel when recorder is already inactive", () => { + const inactiveRecorder = createMockMediaRecorder("inactive"); + const chunks = { current: [new Blob(["data"])] }; + + const result = cancelRecording(inactiveRecorder, false, chunks); + + expect(result.cancelled).toBe(true); + expect(chunks.current).toEqual([]); + expect(inactiveRecorder.stop).not.toHaveBeenCalled(); + }); + + it("handles cancel when webcam is already inactive", () => { + const chunks = { current: [] as Blob[] }; + const webcam = createMockMediaRecorder("inactive"); + + cancelRecording(recorder, false, chunks, webcam); + + expect(webcam.stop).not.toHaveBeenCalled(); + }); }); describe("pause → stop → editor flow", () => { - it("full lifecycle: record → pause → stop completes cleanly", () => { + it("record → pause → stop completes cleanly", () => { expect(recorder.state).toBe("recording"); pauseRecording(recorder, true, false, false); @@ -216,18 +383,59 @@ describe("useScreenRecorder state machine", () => { expect(recorder.state).toBe("inactive"); }); - it("full lifecycle: record → pause → resume → stop completes cleanly", () => { + it("record → pause → resume → stop completes cleanly", () => { expect(recorder.state).toBe("recording"); pauseRecording(recorder, true, false, false); expect(recorder.state).toBe("paused"); - resumeRecording(recorder, true, true); + resumeRecording(recorder, true, true, false); expect(recorder.state).toBe("recording"); const result = stopRecording(recorder, false); expect(result.stopped).toBe(true); expect(recorder.state).toBe("inactive"); }); + + it("webcam stays in sync through full pause/resume/stop cycle", () => { + const webcam = createMockMediaRecorder("recording"); + + pauseRecording(recorder, true, false, false, webcam); + expect(recorder.state).toBe("paused"); + expect(webcam.state).toBe("paused"); + + resumeRecording(recorder, true, true, false, webcam); + expect(recorder.state).toBe("recording"); + expect(webcam.state).toBe("recording"); + + stopRecording(recorder, false, webcam); + expect(recorder.state).toBe("inactive"); + expect(webcam.state).toBe("inactive"); + }); + + it("native recording: webcam pauses/resumes while screen keeps capturing", () => { + const webcam = createMockMediaRecorder("recording"); + + pauseRecording(recorder, true, false, true, webcam); + expect(webcam.state).toBe("paused"); + expect(recorder.pause).not.toHaveBeenCalled(); + + resumeRecording(recorder, true, true, true, webcam); + expect(webcam.state).toBe("recording"); + expect(recorder.resume).not.toHaveBeenCalled(); + }); + + it("cancel discards both screen and webcam recordings", () => { + const webcam = createMockMediaRecorder("recording"); + const chunks = { current: [new Blob(["screen"])] }; + const webcamChunks = { current: [new Blob(["cam"])] }; + + cancelRecording(recorder, false, chunks, webcam, webcamChunks); + + expect(chunks.current).toEqual([]); + expect(webcamChunks.current).toEqual([]); + expect(recorder.state).toBe("inactive"); + expect(webcam.state).toBe("inactive"); + }); }); }); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 2804d0d..10905d1 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -750,18 +750,37 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const pauseRecording = useCallback(() => { if (!recording || paused) return; - // Native recordings (macOS SCK / Windows WGC) don't support pause yet - if (nativeScreenRecording.current) return; + if (nativeScreenRecording.current) { + // Native captures cannot truly pause, but we pause the timer/UI and webcam + if (webcamRecorder.current?.state === "recording") { + webcamRecorder.current.pause(); + } + setPaused(true); + return; + } if (mediaRecorder.current?.state === "recording") { mediaRecorder.current.pause(); + if (webcamRecorder.current?.state === "recording") { + webcamRecorder.current.pause(); + } setPaused(true); } }, [recording, paused]); const resumeRecording = useCallback(() => { if (!recording || !paused) return; + if (nativeScreenRecording.current) { + if (webcamRecorder.current?.state === "paused") { + webcamRecorder.current.resume(); + } + setPaused(false); + return; + } if (mediaRecorder.current?.state === "paused") { mediaRecorder.current.resume(); + if (webcamRecorder.current?.state === "paused") { + webcamRecorder.current.resume(); + } setPaused(false); } }, [recording, paused]); @@ -770,12 +789,31 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (!recording) return; setPaused(false); + // Discard webcam recording regardless of recording mode + webcamChunks.current = []; + if (webcamRecorder.current && webcamRecorder.current.state !== "inactive") { + webcamRecorder.current.stop(); + } + webcamRecorder.current = null; + webcamStream.current?.getTracks().forEach((t) => t.stop()); + webcamStream.current = null; + pendingWebcamPathPromise.current = null; + if (nativeScreenRecording.current) { nativeScreenRecording.current = false; wgcRecording.current = false; setRecording(false); window.electronAPI?.setRecordingState(false); - void window.electronAPI.stopNativeScreenRecording(); + void (async () => { + try { + const result = await window.electronAPI.stopNativeScreenRecording(); + if (result?.path) { + await window.electronAPI.deleteRecordingFile(result.path); + } + } catch { + // Best-effort cleanup + } + })(); return; } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 9d3a9fa..1e7eced 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -17,7 +17,27 @@ "hideHudFromVideo": "Hide HUD from recording", "showHudInVideo": "Show HUD in recording", "hideHud": "Hide HUD", - "closeApp": "Close App" + "closeApp": "Close App", + "screens": "Screens", + "windows": "Windows", + "noSourcesFound": "No sources found", + "microphone": "Microphone", + "turnOffMicrophone": "Turn Off Microphone", + "selectMicToEnable": "Select a microphone to enable", + "noMicrophonesFound": "No microphones found", + "webcam": "Webcam", + "turnOffWebcam": "Turn Off Webcam", + "selectWebcamToEnable": "Select a webcam to enable", + "noWebcamsFound": "No webcams found", + "recordingsFolder": "Recordings Folder", + "language": "Language", + "paused": "PAUSED", + "rec": "REC", + "resume": "Resume", + "pause": "Pause", + "stop": "Stop", + "cancel": "Cancel", + "more": "More" }, "sourceSelector": { "loadingSources": "Loading sources...", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index d8a12ba..e4d50f3 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -17,7 +17,27 @@ "hideHudFromVideo": "Ocultar HUD en la grabación", "showHudInVideo": "Mostrar HUD en la grabación", "hideHud": "Ocultar HUD", - "closeApp": "Cerrar aplicación" + "closeApp": "Cerrar aplicación", + "screens": "Pantallas", + "windows": "Ventanas", + "noSourcesFound": "No se encontraron fuentes", + "microphone": "Micrófono", + "turnOffMicrophone": "Desactivar micrófono", + "selectMicToEnable": "Selecciona un micrófono para activar", + "noMicrophonesFound": "No se encontraron micrófonos", + "webcam": "Cámara", + "turnOffWebcam": "Desactivar cámara", + "selectWebcamToEnable": "Selecciona una cámara para activar", + "noWebcamsFound": "No se encontraron cámaras", + "recordingsFolder": "Carpeta de grabaciones", + "language": "Idioma", + "paused": "PAUSADO", + "rec": "REC", + "resume": "Reanudar", + "pause": "Pausa", + "stop": "Detener", + "cancel": "Cancelar", + "more": "Más" }, "sourceSelector": { "loadingSources": "Cargando fuentes...", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 6c3d91d..72c9573 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -17,7 +17,27 @@ "hideHudFromVideo": "在录制中隐藏 HUD", "showHudInVideo": "在录制中显示 HUD", "hideHud": "隐藏 HUD", - "closeApp": "关闭应用" + "closeApp": "关闭应用", + "screens": "屏幕", + "windows": "窗口", + "noSourcesFound": "未找到源", + "microphone": "麦克风", + "turnOffMicrophone": "关闭麦克风", + "selectMicToEnable": "选择一个麦克风以启用", + "noMicrophonesFound": "未找到麦克风", + "webcam": "摄像头", + "turnOffWebcam": "关闭摄像头", + "selectWebcamToEnable": "选择一个摄像头以启用", + "noWebcamsFound": "未找到摄像头", + "recordingsFolder": "录制文件夹", + "language": "语言", + "paused": "已暂停", + "rec": "录制中", + "resume": "恢复", + "pause": "暂停", + "stop": "停止", + "cancel": "取消", + "more": "更多" }, "sourceSelector": { "loadingSources": "正在加载源...",