diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index c0cf0cdb9..2dfa0d02d 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -72,6 +72,7 @@ type RendererMarketplaceReviewStatus = type RendererMarketplaceSearchResult = import("./extensions/extensionTypes").MarketplaceSearchResult; type RendererRecordingSessionData = import("./ipc/types").RecordingSessionData; +type RendererLaunchShortcutAction = import("./ipc/shortcutTypes").LaunchShortcutAction; interface RendererFfmpegAudioMuxMetrics { tempVideoWriteMs?: number; @@ -558,7 +559,7 @@ interface Window { startDelayMsByPath?: Record; error?: string; }>; - setRecordingState: (recording: boolean) => Promise; + setRecordingState: (recording: boolean, paused?: boolean) => Promise; getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; @@ -579,10 +580,13 @@ interface Window { cursors: Record; error?: string; }>; - onStopRecordingFromTray: (callback: () => void) => () => void; - onRecordingStateChanged: ( - callback: (state: { recording: boolean; sourceName: string }) => void, - ) => () => void; + onStopRecordingFromTray: (callback: () => void) => () => void; + onTrayRecordingCommand: ( + callback: (command: "start" | "pause" | "resume" | "stop") => void, + ) => () => void; + onRecordingStateChanged: ( + callback: (state: { recording: boolean; paused: boolean; sourceName: string }) => void, + ) => () => void; onRecordingSessionChanged: ( callback: (session: RendererRecordingSessionData | null) => void, ) => () => void; @@ -829,6 +833,19 @@ interface Window { }>; getShortcuts: () => Promise | null>; saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; + registerLaunchGlobalShortcuts: (config: unknown) => Promise<{ + success: boolean; + unsupported?: boolean; + failedRegistrations?: Array<{ + action: RendererLaunchShortcutAction; + accelerator: string; + }>; + error?: string; + }>; + unregisterLaunchGlobalShortcuts: () => Promise<{ success: boolean; error?: string }>; + onLaunchShortcutTriggered: ( + callback: (action: RendererLaunchShortcutAction) => void, + ) => () => void; getAppSetting: (key: string) => unknown; setAppSetting: (key: string, value: unknown) => boolean; setHasUnsavedChanges: (hasChanges: boolean) => void; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index f6e4dc029..e1ea9b2c3 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -55,7 +55,7 @@ export function registerIpcHandlers( createSourceSelectorWindow: () => BrowserWindow, _getMainWindow: () => BrowserWindow | null, getSourceSelectorWindow: () => BrowserWindow | null, - onRecordingStateChange?: (recording: boolean, sourceName: string) => void, + onRecordingStateChange?: (recording: boolean, paused: boolean, sourceName: string) => void, ) { registerSourceHandlers({ createEditorWindow, diff --git a/electron/ipc/recording/mac.ts b/electron/ipc/recording/mac.ts index 01951345d..d05b36109 100644 --- a/electron/ipc/recording/mac.ts +++ b/electron/ipc/recording/mac.ts @@ -168,6 +168,7 @@ export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStr if (!window.isDestroyed()) { window.webContents.send("recording-state-changed", { recording: false, + paused: false, sourceName, }); } diff --git a/electron/ipc/recording/windows.ts b/electron/ipc/recording/windows.ts index 262d26daa..99eb7fbf6 100644 --- a/electron/ipc/recording/windows.ts +++ b/electron/ipc/recording/windows.ts @@ -187,6 +187,7 @@ export function attachWindowsCaptureLifecycle(proc: ChildProcessWithoutNullStrea if (!window.isDestroyed()) { window.webContents.send("recording-state-changed", { recording: false, + paused: false, sourceName, }); } diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index b13453c74..1f0fae46d 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -391,7 +391,7 @@ async function resolveExistingPath(...candidates: Array void, + onRecordingStateChange?: (recording: boolean, paused: boolean, sourceName: string) => void, ) { ipcMain.handle( "start-native-screen-recording", @@ -1810,7 +1810,7 @@ export function registerRecordingHandlers( } }); - ipcMain.handle("set-recording-state", (_, recording: boolean) => { + ipcMain.handle("set-recording-state", (_, recording: boolean, paused = false) => { if (recording) { stopCursorCapture(); stopInteractionCapture(); @@ -1844,13 +1844,14 @@ export function registerRecordingHandlers( if (!window.isDestroyed()) { window.webContents.send("recording-state-changed", { recording, + paused, sourceName: source.name, }); } }); if (onRecordingStateChange) { - onRecordingStateChange(recording, source.name); + onRecordingStateChange(recording, paused, source.name); } }); diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index 5b4498a87..2c65b515d 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -1,14 +1,24 @@ import { readFileSync, writeFileSync } from "node:fs"; import fs from "node:fs/promises"; -import { app, ipcMain } from "electron"; +import { app, globalShortcut, ipcMain } from "electron"; import { hideCursor } from "../../cursorHider"; -import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows"; +import { + closeCountdownWindow, + createCountdownWindow, + getCountdownWindow, + getHudOverlayWindow, +} from "../../windows"; import { APP_SETTINGS_FILE, COUNTDOWN_SETTINGS_FILE, RECORDINGS_SETTINGS_FILE, SHORTCUTS_FILE, } from "../constants"; +import { + isLaunchShortcutAction, + type LaunchShortcutAction, + type ShortcutBinding, +} from "../shortcutTypes"; import { countdownCancelled, countdownInProgress, @@ -64,7 +74,70 @@ function hasAppSetting(store: Record, key: string): boolean { return Reflect.getOwnPropertyDescriptor(store, key) !== undefined; } +let launchShortcutRegisteredAccelerators: string[] = []; +let launchShortcutWillQuitCleanupRegistered = false; + +const ELECTRON_KEY_MAP: Record = { + arrowup: "Up", + arrowdown: "Down", + arrowleft: "Left", + arrowright: "Right", + escape: "Escape", + backspace: "Backspace", + delete: "Delete", + enter: "Enter", + tab: "Tab", +}; + +function toElectronAccelerator(binding: ShortcutBinding): string | null { + const rawKey = binding.key; + if (rawKey === " ") { + const parts: string[] = []; + if (binding.ctrl) parts.push("CommandOrControl"); + if (binding.shift) parts.push("Shift"); + if (binding.alt) parts.push("Alt"); + parts.push("Space"); + return parts.join("+"); + } + + const key = rawKey?.trim().toLowerCase(); + if (!key) { + return null; + } + + const mappedKey = + ELECTRON_KEY_MAP[key] ?? + (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1)); + const parts: string[] = []; + if (binding.ctrl) parts.push("CommandOrControl"); + if (binding.shift) parts.push("Shift"); + if (binding.alt) parts.push("Alt"); + parts.push(mappedKey); + return parts.join("+"); +} + +function unregisterLaunchGlobalShortcuts() { + for (const accelerator of launchShortcutRegisteredAccelerators) { + globalShortcut.unregister(accelerator); + } + launchShortcutRegisteredAccelerators = []; +} + +function notifyLaunchShortcutTriggered(action: LaunchShortcutAction) { + const hud = getHudOverlayWindow(); + if (!hud || hud.isDestroyed()) { + return; + } + + hud.webContents.send("launch-shortcut-triggered", action); +} + export function registerSettingsHandlers() { + if (!launchShortcutWillQuitCleanupRegistered) { + launchShortcutWillQuitCleanupRegistered = true; + app.on("will-quit", unregisterLaunchGlobalShortcuts); + } + ipcMain.handle("app:getVersion", () => { return app.getVersion(); }); @@ -139,6 +212,64 @@ export function registerSettingsHandlers() { } }); + ipcMain.handle("register-launch-global-shortcuts", async (_, config: unknown) => { + try { + unregisterLaunchGlobalShortcuts(); + + if (process.platform !== "darwin") { + return { success: true, unsupported: true }; + } + + if (!config || typeof config !== "object") { + return { success: true }; + } + + const failedRegistrations: Array<{ + action: LaunchShortcutAction; + accelerator: string; + }> = []; + + for (const [action, binding] of Object.entries( + config as Record, + )) { + if (!isLaunchShortcutAction(action)) { + console.warn("Ignoring unknown launch shortcut action in config:", action); + continue; + } + + const accelerator = toElectronAccelerator(binding); + if (!accelerator) { + continue; + } + + const registered = globalShortcut.register(accelerator, () => { + notifyLaunchShortcutTriggered(action); + }); + + if (registered) { + launchShortcutRegisteredAccelerators.push(accelerator); + } else { + const failedRegistration = { action, accelerator }; + failedRegistrations.push(failedRegistration); + console.warn("Failed to register launch global shortcut:", failedRegistration); + } + } + + return { success: true, failedRegistrations }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("unregister-launch-global-shortcuts", async () => { + try { + unregisterLaunchGlobalShortcuts(); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + // --------------------------------------------------------------------------- // Countdown timer before recording // --------------------------------------------------------------------------- diff --git a/electron/ipc/shortcutTypes.ts b/electron/ipc/shortcutTypes.ts new file mode 100644 index 000000000..f2d5c8bc8 --- /dev/null +++ b/electron/ipc/shortcutTypes.ts @@ -0,0 +1,20 @@ +export const LAUNCH_SHORTCUT_ACTIONS = [ + "startRecording", + "stopRecording", + "pauseRecording", + "resumeRecording", + "muteMicrophone", +] as const; + +export type LaunchShortcutAction = (typeof LAUNCH_SHORTCUT_ACTIONS)[number]; + +export function isLaunchShortcutAction(action: string): action is LaunchShortcutAction { + return (LAUNCH_SHORTCUT_ACTIONS as readonly string[]).includes(action); +} + +export type ShortcutBinding = { + key: string; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; +}; diff --git a/electron/main.ts b/electron/main.ts index 68829e431..03c39f539 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -139,6 +139,7 @@ let sourceSelectorWindow: BrowserWindow | null = null; let tray: Tray | null = null; let trayContextMenu: Menu | null = null; let selectedSourceName = ""; +let isRecordingPaused = false; let editorHasUnsavedChanges = false; let isForceClosing = false; let isCreatingMainWindow = false; @@ -242,6 +243,33 @@ function showHudOverlayFromTray() { return true; } +function sendTrayRecordingCommand(command: "start" | "pause" | "resume" | "stop") { + let targetWindow = getHudOverlayWindow() ?? mainWindow; + if (!targetWindow || targetWindow.isDestroyed()) { + createWindow(); + targetWindow = getHudOverlayWindow() ?? mainWindow; + } + + if (!targetWindow || targetWindow.isDestroyed()) { + return; + } + + const sendCommand = () => { + if (!targetWindow || targetWindow.isDestroyed()) { + return; + } + + targetWindow.webContents.send("tray-recording-command", command); + }; + + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once("did-finish-load", sendCommand); + return; + } + + sendCommand(); +} + ipcMain.on("set-has-unsaved-changes", (_event, hasChanges: boolean) => { editorHasUnsavedChanges = hasChanges; }); @@ -414,7 +442,7 @@ function setupApplicationMenu() { submenu: [ { role: "undo" }, { role: "redo" }, - { type: "separator" }, + { type: "separator" as const }, { role: "cut" }, { role: "copy" }, { role: "paste" }, @@ -427,7 +455,7 @@ function setupApplicationMenu() { { role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" }, - { type: "separator" }, + { type: "separator" as const }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, @@ -710,8 +738,10 @@ ipcMain.handle("check-for-app-updates", async () => { function updateTrayMenu(recording: boolean = false) { if (!tray) return; const trayIcon = recording ? getRecordingTrayIcon() : getDefaultTrayIcon(); - const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "Recordly"; - const menuTemplate = recording + const trayToolTip = recording + ? `${isRecordingPaused ? "Paused" : "Recording"}: ${selectedSourceName}` + : "Recordly"; + const menuTemplate: Electron.MenuItemConstructorOptions[] = recording ? [ { label: "Show Controls", @@ -721,12 +751,23 @@ function updateTrayMenu(recording: boolean = false) { } }, }, + { + label: isRecordingPaused ? "Resume Recording" : "Pause Recording", + click: () => { + sendTrayRecordingCommand(isRecordingPaused ? "resume" : "pause"); + }, + }, { label: "Stop Recording", click: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("stop-recording-from-tray"); - } + sendTrayRecordingCommand("stop"); + }, + }, + { type: "separator" }, + { + label: "Quit", + click: () => { + app.quit(); }, }, ] @@ -739,6 +780,16 @@ function updateTrayMenu(recording: boolean = false) { } }, }, + { + label: "Start Recording", + click: () => { + if (!showHudOverlayFromTray()) { + focusOrCreateMainWindow(); + } + sendTrayRecordingCommand("start"); + }, + }, + { type: "separator" }, { label: "Quit", click: () => { @@ -960,8 +1011,9 @@ app.whenReady().then(async () => { createSourceSelectorWindowWrapper, () => mainWindow, () => sourceSelectorWindow, - (recording: boolean, sourceName: string) => { + (recording: boolean, paused: boolean, sourceName: string) => { selectedSourceName = sourceName; + isRecordingPaused = recording ? paused : false; setHudOverlayRecordingActive(recording); if (!tray) createTray(); updateTrayMenu(recording); diff --git a/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor b/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor index e0e478f11..475db2880 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor and b/electron/native/bin/darwin-arm64/recordly-native-cursor-monitor differ diff --git a/electron/native/bin/darwin-arm64/recordly-system-cursors b/electron/native/bin/darwin-arm64/recordly-system-cursors index f4b41ab66..a51c8e1a7 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-system-cursors and b/electron/native/bin/darwin-arm64/recordly-system-cursors differ diff --git a/electron/native/bin/darwin-arm64/recordly-window-list b/electron/native/bin/darwin-arm64/recordly-window-list index 76a7dab4a..d80287162 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-window-list and b/electron/native/bin/darwin-arm64/recordly-window-list differ diff --git a/electron/native/bin/darwin-x64/recordly-native-cursor-monitor b/electron/native/bin/darwin-x64/recordly-native-cursor-monitor index d577b1a6f..a2bd93ecd 100755 Binary files a/electron/native/bin/darwin-x64/recordly-native-cursor-monitor and b/electron/native/bin/darwin-x64/recordly-native-cursor-monitor differ diff --git a/electron/native/bin/darwin-x64/recordly-system-cursors b/electron/native/bin/darwin-x64/recordly-system-cursors index 545613624..d78520557 100755 Binary files a/electron/native/bin/darwin-x64/recordly-system-cursors and b/electron/native/bin/darwin-x64/recordly-system-cursors differ diff --git a/electron/native/bin/darwin-x64/recordly-window-list b/electron/native/bin/darwin-x64/recordly-window-list index e165257ae..85d6ef659 100755 Binary files a/electron/native/bin/darwin-x64/recordly-window-list and b/electron/native/bin/darwin-x64/recordly-window-list differ diff --git a/electron/preload.ts b/electron/preload.ts index 384682a17..fe1025cc9 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; +import type { LaunchShortcutAction } from "./ipc/shortcutTypes"; import type { RecordingSessionData } from "./ipc/types"; type NativeVideoExportWriteResult = { success: boolean; error?: string }; @@ -567,8 +568,8 @@ contextBridge.exposeInMainWorld("electronAPI", { getRecordedVideoPath: () => { return ipcRenderer.invoke("get-recorded-video-path"); }, - setRecordingState: (recording: boolean) => { - return ipcRenderer.invoke("set-recording-state", recording); + setRecordingState: (recording: boolean, paused?: boolean) => { + return ipcRenderer.invoke("set-recording-state", recording, paused); }, setCursorScale: (scale: number) => { return ipcRenderer.invoke("set-cursor-scale", scale); @@ -587,12 +588,22 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("stop-recording-from-tray", listener); return () => ipcRenderer.removeListener("stop-recording-from-tray", listener); }, + onTrayRecordingCommand: ( + callback: (command: "start" | "pause" | "resume" | "stop") => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + command: "start" | "pause" | "resume" | "stop", + ) => callback(command); + ipcRenderer.on("tray-recording-command", listener); + return () => ipcRenderer.removeListener("tray-recording-command", listener); + }, onRecordingStateChanged: ( - callback: (state: { recording: boolean; sourceName: string }) => void, + callback: (state: { recording: boolean; paused: boolean; sourceName: string }) => void, ) => { const listener = ( _event: Electron.IpcRendererEvent, - payload: { recording: boolean; sourceName: string }, + payload: { recording: boolean; paused: boolean; sourceName: string }, ) => callback(payload); ipcRenderer.on("recording-state-changed", listener); return () => ipcRenderer.removeListener("recording-state-changed", listener); @@ -896,6 +907,19 @@ contextBridge.exposeInMainWorld("electronAPI", { saveShortcuts: (shortcuts: unknown) => { return ipcRenderer.invoke("save-shortcuts", shortcuts); }, + registerLaunchGlobalShortcuts: (config: unknown) => { + return ipcRenderer.invoke("register-launch-global-shortcuts", config); + }, + unregisterLaunchGlobalShortcuts: () => { + return ipcRenderer.invoke("unregister-launch-global-shortcuts"); + }, + onLaunchShortcutTriggered: (callback: (action: LaunchShortcutAction) => void) => { + const listener = (_event: Electron.IpcRendererEvent, action: LaunchShortcutAction) => { + callback(action); + }; + ipcRenderer.on("launch-shortcut-triggered", listener); + return () => ipcRenderer.removeListener("launch-shortcut-triggered", listener); + }, getAppSetting: (key: string) => { const result = ipcRenderer.sendSync("app-settings:get", key) as { success?: boolean; diff --git a/src/App.tsx b/src/App.tsx index 9e1f4e4c5..060b1f2ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,7 +47,7 @@ export default function App() { loadAllCustomFonts().catch((error) => { console.error("Failed to load custom fonts:", error); }); - }, []); + }, [isMacOS]); useEffect(() => { document.title = @@ -59,10 +59,10 @@ export default function App() { switch (windowType) { case "hud-overlay": return ( - <> + - + ); case "source-selector": return ; diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 3638e7563..bfc745170 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -170,6 +170,16 @@ color: var(--launch-text); } +.ddItem:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ddItem:disabled:hover { + background: transparent; + color: var(--launch-text-muted); +} + .ddItemSelected { background: var(--launch-selected); color: var(--launch-accent); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 09cca63ef..afff3b1a3 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -12,9 +12,10 @@ import { XIcon, } from "@phosphor-icons/react"; import { AnimatePresence, motion } from "motion/react"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { RxDragHandleDots2 } from "react-icons/rx"; import { Separator } from "@/components/ui/separator"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; import { useScopedT } from "../../contexts/I18nContext"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; @@ -24,6 +25,7 @@ import { HudInteractionContext } from "./contexts/HudInteractionContext"; import { canToggleFloatingWebcamPreview } from "./floatingWebcamPreview"; import { useHudBarDrag } from "./hooks/useHudBarDrag"; import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState"; +import { useLaunchShortcuts } from "./hooks/useLaunchShortcuts"; import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions"; import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState"; import { useRecordingTimer } from "./hooks/useRecordingTimer"; @@ -55,6 +57,7 @@ export function LaunchWindow() { function LaunchWindowContent() { const t = useScopedT("launch"); const { openId, requestClose, requestOpen } = useLaunchPopoverCoordinator(); + const { launchShortcuts, isMac } = useShortcuts(); const { recording, @@ -206,12 +209,16 @@ function LaunchWindowContent() { ease: [0.22, 1, 0.36, 1] as const, }; + const toggleMicrophoneMute = useCallback(() => { + setMicrophoneEnabled(!microphoneEnabled); + }, [microphoneEnabled, setMicrophoneEnabled]); + const recordingControls = ( setMicrophoneEnabled(!microphoneEnabled)} + onToggleMicrophone={toggleMicrophoneMute} onPauseResume={paused ? resumeRecording : pauseRecording} onStopRecording={toggleRecording} onHideHud={() => window.electronAPI?.hudOverlayHide?.()} @@ -220,6 +227,21 @@ function LaunchWindowContent() { /> ); + useLaunchShortcuts({ + launchShortcuts, + isMac, + recording, + paused, + countdownActive, + hasSelectedSource, + platform, + toggleRecording, + pauseRecording, + resumeRecording, + toggleMicrophoneMute, + openSources: () => requestOpen("sources"), + }); + const idleControls = ( <> {platform !== "linux" && ( @@ -374,6 +396,9 @@ function LaunchWindowContent() { { void toggleHudCaptureProtection(); }} @@ -388,6 +413,21 @@ function LaunchWindowContent() { requestOpen("projects"); }); }} + onStartOrOpenSources={() => { + if (hasSelectedSource || platform === "linux") { + void toggleRecording(); + return; + } + + beginInteractiveHudAction(); + requestOpen("sources"); + }} + onStopRecording={() => { + void toggleRecording(); + }} + onPauseRecording={pauseRecording} + onResumeRecording={resumeRecording} + onToggleMicrophoneMute={toggleMicrophoneMute} showDevUpdatePreview={SHOW_DEV_UPDATE_PREVIEW} onPreviewUpdateUi={() => { if (openId) requestClose(openId); diff --git a/src/components/launch/hooks/useLaunchShortcuts.ts b/src/components/launch/hooks/useLaunchShortcuts.ts new file mode 100644 index 000000000..b3653bd3b --- /dev/null +++ b/src/components/launch/hooks/useLaunchShortcuts.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect } from "react"; +import { + LAUNCH_SHORTCUT_ACTIONS, + type LaunchShortcutAction, + type LaunchShortcutsConfig, + matchesShortcut, +} from "@/lib/shortcuts"; + +interface UseLaunchShortcutsParams { + launchShortcuts: LaunchShortcutsConfig; + isMac: boolean; + recording: boolean; + paused: boolean; + countdownActive: boolean; + hasSelectedSource: boolean; + platform: string | null; + toggleRecording: () => void | Promise; + pauseRecording: () => void; + resumeRecording: () => void; + toggleMicrophoneMute: () => void; + openSources: () => void; +} + +export function useLaunchShortcuts({ + launchShortcuts, + isMac, + recording, + paused, + countdownActive, + hasSelectedSource, + platform, + toggleRecording, + pauseRecording, + resumeRecording, + toggleMicrophoneMute, + openSources, +}: UseLaunchShortcutsParams) { + const runLaunchShortcut = useCallback( + (action: LaunchShortcutAction) => { + switch (action) { + case "startRecording": + if (!recording && !countdownActive) { + if (hasSelectedSource || platform === "linux") { + void toggleRecording(); + } else { + openSources(); + } + } + return; + case "stopRecording": + if (recording) { + void toggleRecording(); + } + return; + case "pauseRecording": + if (recording && !paused) { + pauseRecording(); + } + return; + case "resumeRecording": + if (recording && paused) { + resumeRecording(); + } + return; + case "muteMicrophone": + toggleMicrophoneMute(); + return; + } + }, + [ + recording, + countdownActive, + hasSelectedSource, + platform, + toggleRecording, + openSources, + paused, + pauseRecording, + resumeRecording, + toggleMicrophoneMute, + ], + ); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.repeat) { + return; + } + + for (const action of LAUNCH_SHORTCUT_ACTIONS) { + if (!matchesShortcut(event, launchShortcuts[action], isMac)) { + continue; + } + + event.preventDefault(); + event.stopPropagation(); + runLaunchShortcut(action); + break; + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [isMac, launchShortcuts, runLaunchShortcut]); + + useEffect(() => { + const unsubscribe = window.electronAPI?.onLaunchShortcutTriggered?.((action) => { + runLaunchShortcut(action); + }); + return () => unsubscribe?.(); + }, [runLaunchShortcut]); +} diff --git a/src/components/launch/hooks/useLaunchWindowActions.test.ts b/src/components/launch/hooks/useLaunchWindowActions.test.ts new file mode 100644 index 000000000..dbf39a4a8 --- /dev/null +++ b/src/components/launch/hooks/useLaunchWindowActions.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { getDefaultLaunchSource } from "./useLaunchWindowActions"; +import type { DesktopSource } from "../popovers/launchPopoverTypes"; + +describe("getDefaultLaunchSource", () => { + it("prefers the first available screen source", () => { + const sources: DesktopSource[] = [ + { + id: "window:1", + name: "Browser", + thumbnail: null, + display_id: "1", + appIcon: null, + sourceType: "window", + }, + { + id: "screen:2", + name: "Screen 2", + thumbnail: null, + display_id: "2", + appIcon: null, + sourceType: "screen", + }, + ]; + + expect(getDefaultLaunchSource(sources)?.id).toBe("screen:2"); + }); + + it("returns null when no screens are available", () => { + const sources: DesktopSource[] = [ + { + id: "window:1", + name: "Browser", + thumbnail: null, + display_id: "1", + appIcon: null, + sourceType: "window", + }, + ]; + + expect(getDefaultLaunchSource(sources)).toBeNull(); + }); +}); diff --git a/src/components/launch/hooks/useLaunchWindowActions.ts b/src/components/launch/hooks/useLaunchWindowActions.ts index 90acfe577..bed326a8f 100644 --- a/src/components/launch/hooks/useLaunchWindowActions.ts +++ b/src/components/launch/hooks/useLaunchWindowActions.ts @@ -1,12 +1,56 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { ProjectLibraryEntry } from "@/components/video-editor/ProjectBrowserDialog"; -import type { DesktopSource } from "../popovers/launchPopoverTypes"; +import { isScreenSource, type DesktopSource } from "../popovers/launchPopoverTypes"; + +export function getDefaultLaunchSource(sources: DesktopSource[]) { + return sources.find(isScreenSource) ?? null; +} export function useLaunchWindowActions() { const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); + useEffect(() => { + let cancelled = false; + + const selectDefaultScreenSource = async () => { + try { + const currentSource = await window.electronAPI.getSelectedSource(); + if (currentSource) { + return; + } + + const sources = (await window.electronAPI.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 160, height: 90 }, + fetchWindowIcons: true, + })) as DesktopSource[]; + const defaultSource = getDefaultLaunchSource(sources); + if (cancelled || !defaultSource) { + return; + } + + const latestSource = await window.electronAPI.getSelectedSource(); + if (cancelled || latestSource) { + return; + } + + await window.electronAPI.selectSource(defaultSource); + setSelectedSource(defaultSource.name); + setHasSelectedSource(true); + } catch (error) { + console.error("Failed to select default launch source:", error); + } + }; + + void selectDefaultScreenSource(); + + return () => { + cancelled = true; + }; + }, []); + const handleSourceSelect = useCallback(async (source: DesktopSource) => { await window.electronAPI.selectSource(source); setSelectedSource(source.name); diff --git a/src/components/launch/hooks/useLaunchWindowSystemState.ts b/src/components/launch/hooks/useLaunchWindowSystemState.ts index 56793fa36..d89bfaa55 100644 --- a/src/components/launch/hooks/useLaunchWindowSystemState.ts +++ b/src/components/launch/hooks/useLaunchWindowSystemState.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from "react"; +const SHOULD_PREPARE_PERMISSIONS_ON_STARTUP = !import.meta.env.DEV; + export function useLaunchWindowSystemState( preparePermissions: (args: { startup?: boolean }) => Promise, ) { @@ -66,6 +68,10 @@ export function useLaunchWindowSystemState( }, []); useEffect(() => { + if (!SHOULD_PREPARE_PERMISSIONS_ON_STARTUP) { + return; + } + void preparePermissions({ startup: true }); }, [preparePermissions]); diff --git a/src/components/launch/popovers/MorePopover.test.tsx b/src/components/launch/popovers/MorePopover.test.tsx new file mode 100644 index 000000000..bfe21a15e --- /dev/null +++ b/src/components/launch/popovers/MorePopover.test.tsx @@ -0,0 +1,211 @@ +import { + Children, + type ComponentProps, + type ReactElement, + type ReactNode, + isValidElement, +} from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MorePopover } from "./MorePopover"; + +const requestClose = vi.fn(); +const requestOpen = vi.fn(); +const setLocale = vi.fn(); +const setPreference = vi.fn(); + +const shortcutsState = { + launchShortcuts: { + startRecording: { key: "r", ctrl: true, shift: true }, + stopRecording: { key: "s", ctrl: true, shift: true }, + pauseRecording: { key: "p", ctrl: true, shift: true }, + resumeRecording: { key: "p", ctrl: true, shift: true, alt: true }, + muteMicrophone: { key: "m", ctrl: true, shift: true }, + }, + isMac: false, +}; + +const translations: Record = { + "recording.startRecording": "Start Recording", + "recording.pause": "Pause", + "recording.resume": "Resume", + "recording.stop": "Stop", + "recording.toggleMicrophoneMute": "Mute / Unmute Microphone", + "recording.recordingsFolder": "Recordings Path", + "recording.openVideoFile": "Open video file", + "recording.openProject": "Open project", + "recording.language": "Language", + "recording.appearance": "Appearance", + "common.light": "Light", + "common.dark": "Dark", + "common.system": "System", +}; + +vi.mock("@/contexts/ShortcutsContext", () => ({ + useShortcuts: () => shortcutsState, +})); + +vi.mock("@/contexts/I18nContext", () => ({ + useI18n: () => ({ locale: "en", setLocale }), + useScopedT: () => (key: string, fallback?: string) => translations[key] ?? fallback ?? key, +})); + +vi.mock("@/contexts/ThemeContext", () => ({ + useTheme: () => ({ preference: "system", setPreference }), +})); + +vi.mock("./LaunchPopoverCoordinator", () => ({ + useLaunchPopoverCoordinator: () => ({ + isOpen: () => true, + requestOpen, + requestClose, + }), +})); + +vi.mock("./PopoverScaffold", () => ({ + DropdownItem: ({ + onClick, + children, + trailing, + disabled, + }: { + onClick: () => void; + children: ReactNode; + trailing?: ReactNode; + disabled?: boolean; + }) => ( + + ), + HudPopover: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +function createProps(overrides: Partial> = {}) { + return { + trigger: , + supportsHudCaptureProtection: false, + hideHudFromCapture: false, + recording: false, + paused: false, + countdownActive: false, + onToggleHudCaptureProtection: vi.fn(), + onChooseRecordingsDirectory: vi.fn(), + onOpenVideoFile: vi.fn(), + onOpenProjectBrowser: vi.fn(), + onStartOrOpenSources: vi.fn(), + onStopRecording: vi.fn(), + onPauseRecording: vi.fn(), + onResumeRecording: vi.fn(), + onToggleMicrophoneMute: vi.fn(), + showDevUpdatePreview: false, + onPreviewUpdateUi: vi.fn(), + appVersion: null, + ...overrides, + }; +} + +function expandNode(node: ReactNode): ReactNode { + if (Array.isArray(node)) { + return node.map((child) => expandNode(child)); + } + + if (!isValidElement(node)) { + return node; + } + + if (typeof node.type === "function") { + return expandNode(node.type(node.props)); + } + + const children = Children.map(node.props.children, (child) => expandNode(child)); + return { ...node, props: { ...node.props, children } }; +} + +function collectButtons(node: ReactNode): ReactElement[] { + if (Array.isArray(node)) { + return node.flatMap((child) => collectButtons(child)); + } + + if (!isValidElement(node)) { + return []; + } + + const childButtons = collectButtons(node.props.children); + return node.type === "button" ? [node, ...childButtons] : childButtons; +} + +function extractText(node: ReactNode): string { + if (Array.isArray(node)) { + return node.map((child) => extractText(child)).join(""); + } + + if (!isValidElement(node)) { + return typeof node === "string" || typeof node === "number" ? String(node) : ""; + } + + return extractText(node.props.children); +} + +function renderButtons(props: Partial> = {}) { + const tree = expandNode(); + return collectButtons(tree); +} + +function findButton(buttons: ReactElement[], text: string) { + return buttons.find((button) => extractText(button).includes(text)); +} + +describe("MorePopover", () => { + beforeEach(() => { + requestClose.mockReset(); + requestOpen.mockReset(); + setLocale.mockReset(); + setPreference.mockReset(); + shortcutsState.isMac = false; + }); + + it("shows the start recording shortcut in idle state", () => { + const buttons = renderButtons(); + const startButton = findButton(buttons, "Start Recording"); + + expect(startButton).toBeDefined(); + expect(extractText(startButton)).toContain("Ctrl + Shift + R"); + }); + + it("shows recording actions with state-aware shortcuts", () => { + const buttons = renderButtons({ recording: true, paused: false }); + + expect(extractText(findButton(buttons, "Pause"))).toContain("Ctrl + Shift + P"); + expect(extractText(findButton(buttons, "Stop"))).toContain("Ctrl + Shift + S"); + expect(extractText(findButton(buttons, "Mute / Unmute Microphone"))).toContain( + "Ctrl + Shift + M", + ); + }); + + it("shows the resume shortcut when recording is paused", () => { + const buttons = renderButtons({ recording: true, paused: true }); + + expect(extractText(findButton(buttons, "Resume"))).toContain("Ctrl + Shift + Alt + P"); + }); + + it("formats shortcuts for macOS display", () => { + shortcutsState.isMac = true; + const buttons = renderButtons(); + + expect(extractText(findButton(buttons, "Start Recording"))).toContain("⌘ + ⇧ + R"); + }); + + it("closes the popover and runs the selected action", () => { + const onStartOrOpenSources = vi.fn(); + const buttons = renderButtons({ onStartOrOpenSources }); + const startButton = findButton(buttons, "Start Recording"); + + expect(startButton).toBeDefined(); + + startButton?.props.onClick(); + + expect(requestClose).toHaveBeenCalledWith("more"); + expect(onStartOrOpenSources).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/launch/popovers/MorePopover.tsx b/src/components/launch/popovers/MorePopover.tsx index 9a5a52905..c173c4701 100644 --- a/src/components/launch/popovers/MorePopover.tsx +++ b/src/components/launch/popovers/MorePopover.tsx @@ -1,20 +1,25 @@ import { + ArrowClockwiseIcon, + DesktopIcon, EyeIcon, EyeSlashIcon, FolderOpenIcon, + MicrophoneIcon, + MoonIcon, + PauseIcon, + PlayIcon, + SquareIcon, + SunIcon, TranslateIcon, VideoCameraIcon, - ArrowClockwiseIcon, - SunIcon, - MoonIcon, - DesktopIcon, } from "@phosphor-icons/react"; import type { ReactElement } from "react"; -import { useI18n } from "@/contexts/I18nContext"; -import { useScopedT } from "@/contexts/I18nContext"; +import { useI18n, useScopedT } from "@/contexts/I18nContext"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; import { useTheme } from "@/contexts/ThemeContext"; import type { AppLocale } from "@/i18n/config"; import { SUPPORTED_LOCALES } from "@/i18n/config"; +import { formatBinding } from "@/lib/shortcuts"; import styles from "../LaunchWindow.module.css"; import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; import { DropdownItem, HudPopover } from "./PopoverScaffold"; @@ -37,10 +42,18 @@ export function MorePopover({ trigger, supportsHudCaptureProtection, hideHudFromCapture, + recording, + paused, + countdownActive, onToggleHudCaptureProtection, onChooseRecordingsDirectory, onOpenVideoFile, onOpenProjectBrowser, + onStartOrOpenSources, + onStopRecording, + onPauseRecording, + onResumeRecording, + onToggleMicrophoneMute, showDevUpdatePreview, onPreviewUpdateUi, appVersion, @@ -48,10 +61,18 @@ export function MorePopover({ trigger: ReactElement; supportsHudCaptureProtection: boolean; hideHudFromCapture: boolean; + recording: boolean; + paused: boolean; + countdownActive: boolean; onToggleHudCaptureProtection: () => void; onChooseRecordingsDirectory: () => void; onOpenVideoFile: () => void; onOpenProjectBrowser: () => void; + onStartOrOpenSources: () => void; + onStopRecording: () => void; + onPauseRecording: () => void; + onResumeRecording: () => void; + onToggleMicrophoneMute: () => void; showDevUpdatePreview: boolean; onPreviewUpdateUi: () => void; appVersion: string | null; @@ -59,15 +80,30 @@ export function MorePopover({ const t = useScopedT("launch"); const { locale, setLocale } = useI18n(); const { preference, setPreference } = useTheme(); + const { launchShortcuts, isMac } = useShortcuts(); const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); const open = isOpen(POPOVER_ID); + const closePopover = () => requestClose(POPOVER_ID); + + const launchShortcutLabels = { + startRecording: formatBinding(launchShortcuts.startRecording, isMac), + stopRecording: formatBinding(launchShortcuts.stopRecording, isMac), + pauseRecording: formatBinding(launchShortcuts.pauseRecording, isMac), + resumeRecording: formatBinding(launchShortcuts.resumeRecording, isMac), + muteMicrophone: formatBinding(launchShortcuts.muteMicrophone, isMac), + } as const; + + const runMenuAction = (action: () => void) => { + closePopover(); + action(); + }; return ( { if (!nextOpen) { - requestClose(POPOVER_ID); + closePopover(); return; } requestOpen(POPOVER_ID); @@ -75,6 +111,51 @@ export function MorePopover({ trigger={trigger} align="end" > + {recording ? ( + <> + + ) : ( + + ) + } + onClick={() => runMenuAction(paused ? onResumeRecording : onPauseRecording)} + trailing={ + paused + ? launchShortcutLabels.resumeRecording + : launchShortcutLabels.pauseRecording + } + > + {paused ? t("recording.resume") : t("recording.pause")} + + } + onClick={() => runMenuAction(onStopRecording)} + trailing={launchShortcutLabels.stopRecording} + > + {t("recording.stop")} + + } + onClick={() => runMenuAction(onToggleMicrophoneMute)} + trailing={launchShortcutLabels.muteMicrophone} + > + {t("recording.toggleMicrophoneMute", "Mute / Unmute Microphone")} + + + ) : ( + } + onClick={() => runMenuAction(onStartOrOpenSources)} + disabled={countdownActive} + trailing={launchShortcutLabels.startRecording} + > + {t("recording.startRecording", "Start Recording")} + + )} +
{supportsHudCaptureProtection && ( : } @@ -89,7 +170,7 @@ export function MorePopover({ } onClick={() => { - requestClose(POPOVER_ID); + closePopover(); onChooseRecordingsDirectory(); }} > @@ -98,7 +179,7 @@ export function MorePopover({ } onClick={() => { - requestClose(POPOVER_ID); + closePopover(); onOpenVideoFile(); }} > @@ -107,7 +188,7 @@ export function MorePopover({ } onClick={() => { - requestClose(POPOVER_ID); + closePopover(); onOpenProjectBrowser(); }} > @@ -117,7 +198,7 @@ export function MorePopover({ } onClick={() => { - requestClose(POPOVER_ID); + closePopover(); onPreviewUpdateUi(); }} > @@ -132,7 +213,7 @@ export function MorePopover({ selected={preference === "light"} onClick={() => { setPreference("light"); - requestClose(POPOVER_ID); + closePopover(); }} > {t("common.light", "Light")} @@ -142,7 +223,7 @@ export function MorePopover({ selected={preference === "dark"} onClick={() => { setPreference("dark"); - requestClose(POPOVER_ID); + closePopover(); }} > {t("common.dark", "Dark")} @@ -152,7 +233,7 @@ export function MorePopover({ selected={preference === "system"} onClick={() => { setPreference("system"); - requestClose(POPOVER_ID); + closePopover(); }} > {t("common.system", "System")} @@ -167,7 +248,7 @@ export function MorePopover({ selected={locale === code} onClick={() => { setLocale(code as AppLocale); - requestClose(POPOVER_ID); + closePopover(); }} > {LOCALE_LABELS[code] ?? code} diff --git a/src/components/launch/popovers/PopoverScaffold.tsx b/src/components/launch/popovers/PopoverScaffold.tsx index be349192b..57ca97d68 100644 --- a/src/components/launch/popovers/PopoverScaffold.tsx +++ b/src/components/launch/popovers/PopoverScaffold.tsx @@ -11,12 +11,14 @@ import { useHudInteraction } from "../contexts/HudInteractionContext"; export function DropdownItem({ onClick, selected, + disabled, icon, children, trailing, }: { onClick: () => void; selected?: boolean; + disabled?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode; @@ -26,10 +28,13 @@ export function DropdownItem({ type="button" className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`} onClick={onClick} + disabled={disabled} > {icon} - {children} - {trailing} + {children} + {trailing ? ( + {trailing} + ) : null} ); } diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index c359c1f11..4b814b45b 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -11,10 +11,17 @@ import { } from "@/components/ui/dialog"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { + DEFAULT_LAUNCH_SHORTCUTS, DEFAULT_SHORTCUTS, FIXED_SHORTCUTS, findConflict, + findLaunchConflict, formatBinding, + LAUNCH_SHORTCUT_ACTIONS, + LAUNCH_SHORTCUT_LABELS, + type LaunchShortcutAction, + type LaunchShortcutConflict, + type LaunchShortcutsConfig, SHORTCUT_ACTIONS, SHORTCUT_LABELS, type ShortcutAction, @@ -26,36 +33,60 @@ import { useScopedT } from "../../contexts/I18nContext"; const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]); +type CaptureTarget = + | { scope: "local"; action: ShortcutAction } + | { scope: "global"; action: LaunchShortcutAction }; + +type ShortcutConflictState = + | { + scope: "local"; + forAction: ShortcutAction; + pending: ShortcutBinding; + conflictWith: ShortcutConflict; + } + | { + scope: "global"; + forAction: LaunchShortcutAction; + pending: ShortcutBinding; + conflictWith: LaunchShortcutConflict; + }; + export function ShortcutsConfigDialog() { const t = useScopedT("dialogs"); - const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } = - useShortcuts(); + const { + shortcuts, + launchShortcuts, + isMac, + isConfigOpen, + closeConfig, + setShortcuts, + setLaunchShortcuts, + persistShortcuts, + } = useShortcuts(); const [draft, setDraft] = useState(shortcuts); - const [captureFor, setCaptureFor] = useState(null); - const [conflict, setConflict] = useState<{ - forAction: ShortcutAction; - pending: ShortcutBinding; - conflictWith: ShortcutConflict; - } | null>(null); + const [launchDraft, setLaunchDraft] = useState(launchShortcuts); + const [captureTarget, setCaptureTarget] = useState(null); + const [conflict, setConflict] = useState(null); useEffect(() => { if (isConfigOpen) { setDraft(shortcuts); - setCaptureFor(null); + setLaunchDraft(launchShortcuts); + setCaptureTarget(null); setConflict(null); } - }, [isConfigOpen, shortcuts]); + }, [isConfigOpen, shortcuts, launchShortcuts]); useEffect(() => { - if (!captureFor) return; + if (!captureTarget) return; const handleCapture = (e: KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); if (e.key === "Escape") { - setCaptureFor(null); + setCaptureTarget(null); return; } @@ -68,8 +99,29 @@ export function ShortcutsConfigDialog() { ...(e.altKey ? { alt: true } : {}), }; - const found = findConflict(binding, captureFor, draft); - setCaptureFor(null); + if (captureTarget.scope === "global") { + const found = findLaunchConflict(binding, captureTarget.action, launchDraft); + setCaptureTarget(null); + + if (found) { + setConflict({ + scope: "global", + forAction: captureTarget.action, + pending: binding, + conflictWith: found, + }); + return; + } + + setLaunchDraft((prev: LaunchShortcutsConfig) => ({ + ...prev, + [captureTarget.action]: binding, + })); + return; + } + + const found = findConflict(binding, captureTarget.action, draft); + setCaptureTarget(null); if (found?.type === "fixed") { toast.error(t("shortcutsConfig.reserved", undefined, { label: found.label })); @@ -77,25 +129,46 @@ export function ShortcutsConfigDialog() { } if (found?.type === "configurable") { - setConflict({ forAction: captureFor, pending: binding, conflictWith: found }); + setConflict({ + scope: "local", + forAction: captureTarget.action, + pending: binding, + conflictWith: found, + }); return; } - setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding })); + setDraft((prev: ShortcutsConfig) => ({ + ...prev, + [captureTarget.action]: binding, + })); }; window.addEventListener("keydown", handleCapture, { capture: true }); return () => window.removeEventListener("keydown", handleCapture, { capture: true }); - }, [captureFor, draft, t]); + }, [captureTarget, draft, launchDraft, t]); const handleSwap = useCallback(() => { if (!conflict || conflict.conflictWith.type !== "configurable") return; - const { forAction, pending, conflictWith } = conflict; - setDraft((prev: ShortcutsConfig) => ({ - ...prev, - [forAction]: pending, - [conflictWith.action]: prev[forAction], - })); + if (conflict.scope === "global") { + const forAction = conflict.forAction; + const pending = conflict.pending; + const conflictWithAction = conflict.conflictWith.action; + setLaunchDraft((prev: LaunchShortcutsConfig) => ({ + ...prev, + [forAction]: pending, + [conflictWithAction]: prev[forAction], + })); + } else { + const forAction = conflict.forAction; + const pending = conflict.pending; + const conflictWithAction = conflict.conflictWith.action; + setDraft((prev: ShortcutsConfig) => ({ + ...prev, + [forAction]: pending, + [conflictWithAction]: prev[forAction], + })); + } setConflict(null); }, [conflict]); @@ -103,22 +176,31 @@ export function ShortcutsConfigDialog() { const handleSave = useCallback(async () => { setShortcuts(draft); - await persistShortcuts(draft); + setLaunchShortcuts(launchDraft); + await persistShortcuts(draft, launchDraft); toast.success(t("shortcutsConfig.saved")); closeConfig(); - }, [draft, setShortcuts, persistShortcuts, closeConfig, t]); + }, [draft, launchDraft, setShortcuts, setLaunchShortcuts, persistShortcuts, closeConfig, t]); const handleReset = useCallback(() => { setDraft({ ...DEFAULT_SHORTCUTS }); + setLaunchDraft({ ...DEFAULT_LAUNCH_SHORTCUTS }); toast.info(t("shortcutsConfig.resetNotice")); }, [t]); const handleClose = useCallback(() => { - setCaptureFor(null); + setCaptureTarget(null); setConflict(null); closeConfig(); }, [closeConfig]); + const toggleCaptureTarget = useCallback((target: CaptureTarget) => { + setConflict(null); + setCaptureTarget((current) => + current?.scope === target.scope && current.action === target.action ? null : target, + ); + }, []); + return ( - + @@ -136,11 +218,13 @@ export function ShortcutsConfigDialog() {

- {t("shortcutsConfig.configurable")} + {t("shortcutsConfig.localShortcuts")}

{SHORTCUT_ACTIONS.map((action) => { - const isCapturing = captureFor === action; - const hasConflict = conflict?.forAction === action; + const isCapturing = + captureTarget?.scope === "local" && captureTarget.action === action; + const hasConflict = + conflict?.scope === "local" && conflict.forAction === action; return (
@@ -149,10 +233,9 @@ export function ShortcutsConfigDialog() { + +
+
+ )} +
+ ); + })} +
+ +
+

+ {t("shortcutsConfig.globalShortcuts")} +

+

+ {t("shortcutsConfig.globalDescription")} +

+ {LAUNCH_SHORTCUT_ACTIONS.map((action) => { + const isCapturing = + captureTarget?.scope === "global" && captureTarget.action === action; + const hasConflict = + conflict?.scope === "global" && conflict.forAction === action; + return ( +
+
+ + {LAUNCH_SHORTCUT_LABELS[action]} + + +
+ {hasConflict && conflict?.scope === "global" && ( +
+ + {t("shortcutsConfig.alreadyUsedBy", undefined, { + action: LAUNCH_SHORTCUT_LABELS[ conflict.conflictWith.action ], })} diff --git a/src/contexts/ShortcutsContext.tsx b/src/contexts/ShortcutsContext.tsx index 8b80818af..39380e767 100644 --- a/src/contexts/ShortcutsContext.tsx +++ b/src/contexts/ShortcutsContext.tsx @@ -7,14 +7,26 @@ import { useMemo, useState, } from "react"; -import { DEFAULT_SHORTCUTS, mergeWithDefaults, type ShortcutsConfig } from "@/lib/shortcuts"; +import { + DEFAULT_LAUNCH_SHORTCUTS, + DEFAULT_SHORTCUTS, + type LaunchShortcutsConfig, + type PersistedShortcutsPayload, + resolvePersistedShortcuts, + type ShortcutsConfig, +} from "@/lib/shortcuts"; import { isMac as getIsMac } from "@/utils/platformUtils"; interface ShortcutsContextValue { shortcuts: ShortcutsConfig; + launchShortcuts: LaunchShortcutsConfig; isMac: boolean; setShortcuts: (config: ShortcutsConfig) => void; - persistShortcuts: (config?: ShortcutsConfig) => Promise; + setLaunchShortcuts: (config: LaunchShortcutsConfig) => void; + persistShortcuts: ( + config?: ShortcutsConfig, + launchConfig?: LaunchShortcutsConfig, + ) => Promise; isConfigOpen: boolean; openConfig: () => void; closeConfig: () => void; @@ -30,6 +42,8 @@ export function useShortcuts(): ShortcutsContextValue { export function ShortcutsProvider({ children }: { children: ReactNode }) { const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); + const [launchShortcuts, setLaunchShortcuts] = + useState(DEFAULT_LAUNCH_SHORTCUTS); const [isMac, setIsMac] = useState(false); const [isConfigOpen, setIsConfigOpen] = useState(false); @@ -40,21 +54,49 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) { void (async () => { try { - const saved = await window.electronAPI?.getShortcuts?.(); - if (saved) { - setShortcuts(mergeWithDefaults(saved as Partial)); - } + const saved = + (await window.electronAPI?.getShortcuts?.()) as PersistedShortcutsPayload | null; + const resolved = resolvePersistedShortcuts(saved); + setShortcuts(resolved.editor); + setLaunchShortcuts(resolved.launch); } catch { return undefined; } })(); }, []); + useEffect(() => { + if (!isMac) { + return undefined; + } + + void window.electronAPI?.registerLaunchGlobalShortcuts?.(launchShortcuts).then((result) => { + if (!result?.success) { + console.warn("Failed to register launch global shortcuts:", result?.error); + return; + } + + if (result.failedRegistrations && result.failedRegistrations.length > 0) { + console.warn( + "Some launch global shortcuts could not be registered:", + result.failedRegistrations, + ); + } + }); + + return () => { + void window.electronAPI?.unregisterLaunchGlobalShortcuts?.(); + }; + }, [isMac, launchShortcuts]); + const persistShortcuts = useCallback( - async (config?: ShortcutsConfig) => { - await window.electronAPI?.saveShortcuts?.(config ?? shortcuts); + async (config?: ShortcutsConfig, launchConfig?: LaunchShortcutsConfig) => { + await window.electronAPI?.saveShortcuts?.({ + editor: config ?? shortcuts, + launch: launchConfig ?? launchShortcuts, + }); }, - [shortcuts], + [shortcuts, launchShortcuts], ); const openConfig = useCallback(() => setIsConfigOpen(true), []); @@ -63,14 +105,24 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ shortcuts, + launchShortcuts, isMac, setShortcuts, + setLaunchShortcuts, persistShortcuts, isConfigOpen, openConfig, closeConfig, }), - [shortcuts, isMac, persistShortcuts, isConfigOpen, openConfig, closeConfig], + [ + shortcuts, + launchShortcuts, + isMac, + persistShortcuts, + isConfigOpen, + openConfig, + closeConfig, + ], ); return {children}; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index bb1f4e3da..c3eb5648b 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1171,7 +1171,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { `[PERF:RENDERER] IPC: stopNativeScreenRecording: COMPLETED in ${(performance.now() - ipcStopStart).toFixed(2)}ms`, ); - await window.electronAPI?.setRecordingState(false); + await window.electronAPI?.setRecordingState(false, false); if (!result.success || !result.path) { console.error( @@ -1290,7 +1290,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recorder.stop(); setRecording(false); setFinalizing(true); - window.electronAPI?.setRecordingState(false); + window.electronAPI?.setRecordingState(false, false); } }); @@ -1377,6 +1377,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const removeRecordingStateListener = window.electronAPI?.onRecordingStateChanged?.( (state) => { setRecording(state.recording); + setPaused(state.paused); }, ); @@ -1384,9 +1385,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { (state) => { void (async () => { setRecording(false); + setPaused(false); nativeScreenRecording.current = false; cleanupCapturedMedia(); - await window.electronAPI.setRecordingState(false); + await window.electronAPI.setRecordingState(false, false); if (state.reason !== "window-unavailable") { try { @@ -1649,7 +1651,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setRecording(true); try { - await window.electronAPI?.setRecordingState(true); + await window.electronAPI?.setRecordingState(true, false); } catch (stateError) { console.warn( "Failed to notify main process that native recording started:", @@ -2021,7 +2023,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recorder.start(RECORDER_TIMESLICE_MS); setRecording(true); try { - await window.electronAPI?.setRecordingState(true); + await window.electronAPI?.setRecordingState(true, false); } catch (stateError) { console.warn("Failed to notify main process that recording started:", stateError); } @@ -2034,7 +2036,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); setRecording(false); try { - await window.electronAPI?.setRecordingState(false); + await window.electronAPI?.setRecordingState(false, false); } catch (stateError) { console.warn("Failed to reset main-process recording state:", stateError); } finally { @@ -2068,6 +2070,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const boundaryMs = Date.now(); markRecordingPaused(boundaryMs); setPaused(true); + void window.electronAPI.setRecordingState(true, true); try { await window.electronAPI.pauseCursorCapture(boundaryMs); } catch (error) { @@ -2085,6 +2088,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const boundaryMs = Date.now(); markRecordingPaused(boundaryMs); setPaused(true); + void window.electronAPI.setRecordingState(true, true); try { await window.electronAPI.pauseCursorCapture(boundaryMs); } catch (error) { @@ -2114,6 +2118,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const boundaryMs = Date.now(); markRecordingResumed(boundaryMs); setPaused(false); + void window.electronAPI.setRecordingState(true, false); try { await window.electronAPI.resumeCursorCapture(boundaryMs); } catch (error) { @@ -2131,6 +2136,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const boundaryMs = Date.now(); markRecordingResumed(boundaryMs); setPaused(false); + void window.electronAPI.setRecordingState(true, false); try { await window.electronAPI.resumeCursorCapture(boundaryMs); } catch (error) { @@ -2162,7 +2168,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { nativeScreenRecording.current = false; nativeWindowsRecording.current = false; setRecording(false); - window.electronAPI?.setRecordingState(false); + window.electronAPI?.setRecordingState(false, false); void (async () => { try { const result = await window.electronAPI.stopNativeScreenRecording(); @@ -2183,7 +2189,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { mediaRecorder.current.stop(); } setRecording(false); - window.electronAPI?.setRecordingState(false); + window.electronAPI?.setRecordingState(false, false); } }, [cleanupCapturedMedia, markRecordingResumed, recording]); @@ -2213,6 +2219,29 @@ export function useScreenRecorder(): UseScreenRecorderReturn { startRecording(); }; + useEffect(() => { + if (!window.electronAPI?.onTrayRecordingCommand) { + return; + } + + return window.electronAPI.onTrayRecordingCommand((command) => { + switch (command) { + case "start": + void toggleRecording(); + break; + case "pause": + pauseRecording(); + break; + case "resume": + resumeRecording(); + break; + case "stop": + stopRecording.current(); + break; + } + }); + }, [pauseRecording, resumeRecording, toggleRecording]); + return { recording, paused, diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index 97d931a20..336d63914 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -45,6 +45,9 @@ "shortcutsConfig": { "title": "Keyboard Shortcuts", "configurable": "Configurable", + "localShortcuts": "Local shortcuts", + "globalShortcuts": "Global recording shortcuts", + "globalDescription": "Available on macOS even when Recordly is in the background.", "fixed": "Fixed", "pressEscToCancel": "Press Esc to cancel", "clickToChange": "Click to change", diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 9dfcbe038..e6953525b 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -10,6 +10,7 @@ "countdownDelay": "Countdown delay", "noDelay": "No delay", "record": "Record", + "startRecording": "Start Recording", "recordingFolder": "Recordings Path: {{path}}", "chooseRecordingsFolder": "Choose recordings path", "folderPath": "Path: /{{name}}/", @@ -25,6 +26,7 @@ "window": "Window", "noSourcesFound": "No sources found", "microphone": "Microphone", + "toggleMicrophoneMute": "Mute / Unmute Microphone", "turnOffMicrophone": "Turn Off Microphone", "selectMicToEnable": "Select a microphone to enable", "noMicrophonesFound": "No microphones found", diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json index e6203e667..06611cd94 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -45,6 +45,9 @@ "shortcutsConfig": { "title": "Atajos de teclado", "configurable": "Configurable", + "localShortcuts": "Local shortcuts", + "globalShortcuts": "Global recording shortcuts", + "globalDescription": "Available on macOS even when Recordly is in the background.", "fixed": "Fijo", "pressEscToCancel": "Presiona Esc para cancelar", "clickToChange": "Haz clic para cambiar", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index a458844a2..c504345da 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -10,6 +10,7 @@ "countdownDelay": "Retraso de cuenta regresiva", "noDelay": "Sin retraso", "record": "Grabar", + "startRecording": "Iniciar grabacion", "recordingFolder": "Carpeta de grabaciones: {{path}}", "chooseRecordingsFolder": "Elegir carpeta de grabaciones", "folderPath": "Ruta: /{{name}}/", @@ -25,6 +26,7 @@ "window": "Ventana", "noSourcesFound": "No se encontraron fuentes", "microphone": "Micrófono", + "toggleMicrophoneMute": "Silenciar / reactivar microfono", "turnOffMicrophone": "Desactivar micrófono", "selectMicToEnable": "Selecciona un micrófono para activar", "noMicrophonesFound": "No se encontraron micrófonos", diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json index d13fe5eba..1e53c278c 100644 --- a/src/i18n/locales/fr/dialogs.json +++ b/src/i18n/locales/fr/dialogs.json @@ -45,6 +45,9 @@ "shortcutsConfig": { "title": "Raccourcis clavier", "configurable": "Configurable", + "localShortcuts": "Local shortcuts", + "globalShortcuts": "Global recording shortcuts", + "globalDescription": "Available on macOS even when Recordly is in the background.", "fixed": "Fixe", "pressEscToCancel": "Appuyez sur Échap pour annuler", "clickToChange": "Cliquez pour modifier", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index df2e43b2b..84c0ed4ba 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -10,6 +10,7 @@ "countdownDelay": "Délai du compte à rebours", "noDelay": "Aucun délai", "record": "Enregistrer", + "startRecording": "Demarrer l'enregistrement", "recordingFolder": "Chemin des enregistrements : {{path}}", "chooseRecordingsFolder": "Choisir le chemin des enregistrements", "folderPath": "Chemin : /{{name}}/", @@ -25,6 +26,7 @@ "window": "Fenêtre", "noSourcesFound": "Aucune source trouvée", "microphone": "Microphone", + "toggleMicrophoneMute": "Couper / reactiver le microphone", "turnOffMicrophone": "Désactiver le microphone", "selectMicToEnable": "Sélectionnez un microphone à activer", "noMicrophonesFound": "Aucun microphone trouvé", diff --git a/src/i18n/locales/it/dialogs.json b/src/i18n/locales/it/dialogs.json index 4681c0bc9..3cc77bc72 100644 --- a/src/i18n/locales/it/dialogs.json +++ b/src/i18n/locales/it/dialogs.json @@ -45,6 +45,9 @@ "shortcutsConfig": { "title": "Scorciatoie da tastiera", "configurable": "Configurabile", + "localShortcuts": "Local shortcuts", + "globalShortcuts": "Global recording shortcuts", + "globalDescription": "Available on macOS even when Recordly is in the background.", "fixed": "Fisso", "pressEscToCancel": "Premi Esc per annullare", "clickToChange": "Clicca per modificare", diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index c27abc3ad..d0eb73091 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -10,6 +10,7 @@ "countdownDelay": "Ritardo conto alla rovescia", "noDelay": "Nessun ritardo", "record": "Registra", + "startRecording": "Avvia registrazione", "recordingFolder": "Percorso registrazioni: {{path}}", "chooseRecordingsFolder": "Scegli percorso registrazioni", "folderPath": "Percorso: /{{name}}/", @@ -25,6 +26,7 @@ "window": "Finestra", "noSourcesFound": "Nessuna sorgente trovata", "microphone": "Microfono", + "toggleMicrophoneMute": "Disattiva / riattiva microfono", "turnOffMicrophone": "Spegni microfono", "selectMicToEnable": "Seleziona un microfono da abilitare", "noMicrophonesFound": "Nessun microfono trovato", diff --git a/src/i18n/locales/ko/dialogs.json b/src/i18n/locales/ko/dialogs.json index e63fa57fa..5061f61b5 100644 --- a/src/i18n/locales/ko/dialogs.json +++ b/src/i18n/locales/ko/dialogs.json @@ -45,6 +45,9 @@ "shortcutsConfig": { "title": "키보드 단축키", "configurable": "변경 가능", + "localShortcuts": "Local shortcuts", + "globalShortcuts": "Global recording shortcuts", + "globalDescription": "Available on macOS even when Recordly is in the background.", "fixed": "고정", "pressEscToCancel": "Esc를 눌러 취소", "clickToChange": "클릭해서 변경", diff --git a/src/i18n/locales/ko/launch.json b/src/i18n/locales/ko/launch.json index 345000399..eca50d77d 100644 --- a/src/i18n/locales/ko/launch.json +++ b/src/i18n/locales/ko/launch.json @@ -10,6 +10,7 @@ "countdownDelay": "카운트다운 지연", "noDelay": "지연 없음", "record": "녹화", + "startRecording": "녹화 시작", "recordingFolder": "녹화 폴더: {{path}}", "chooseRecordingsFolder": "녹화 폴더 선택", "folderPath": "경로: /{{name}}/", @@ -25,6 +26,7 @@ "window": "창", "noSourcesFound": "사용 가능한 소스를 찾을 수 없습니다", "microphone": "마이크", + "toggleMicrophoneMute": "마이크 음소거 / 음소거 해제", "turnOffMicrophone": "마이크 끄기", "selectMicToEnable": "사용할 마이크를 선택하세요", "noMicrophonesFound": "마이크를 찾을 수 없습니다", diff --git a/src/i18n/locales/nl/dialogs.json b/src/i18n/locales/nl/dialogs.json index 32ca08e0a..7bf96964f 100644 --- a/src/i18n/locales/nl/dialogs.json +++ b/src/i18n/locales/nl/dialogs.json @@ -45,6 +45,9 @@ "shortcutsConfig": { "title": "Sneltoetsen", "configurable": "Aanpasbaar", + "localShortcuts": "Local shortcuts", + "globalShortcuts": "Global recording shortcuts", + "globalDescription": "Available on macOS even when Recordly is in the background.", "fixed": "Vast", "pressEscToCancel": "Druk op Esc om te annuleren", "clickToChange": "Klik om te wijzigen", diff --git a/src/i18n/locales/nl/launch.json b/src/i18n/locales/nl/launch.json index d3d5870c9..7ef3a7720 100644 --- a/src/i18n/locales/nl/launch.json +++ b/src/i18n/locales/nl/launch.json @@ -10,6 +10,7 @@ "countdownDelay": "Aftelvertraging", "noDelay": "Geen vertraging", "record": "Opnemen", + "startRecording": "Opname starten", "recordingFolder": "Opnamepad: {{path}}", "chooseRecordingsFolder": "Kies opnamepad", "folderPath": "Pad: /{{name}}/", @@ -25,6 +26,7 @@ "window": "Venster", "noSourcesFound": "Geen bronnen gevonden", "microphone": "Microfoon", + "toggleMicrophoneMute": "Microfoon dempen / dempen opheffen", "turnOffMicrophone": "Microfoon uitschakelen", "selectMicToEnable": "Selecteer een microfoon om in te schakelen", "noMicrophonesFound": "Geen microfoons gevonden", diff --git a/src/i18n/locales/pt-BR/dialogs.json b/src/i18n/locales/pt-BR/dialogs.json index f5f772063..479a52fb5 100644 --- a/src/i18n/locales/pt-BR/dialogs.json +++ b/src/i18n/locales/pt-BR/dialogs.json @@ -45,6 +45,9 @@ "shortcutsConfig": { "title": "Atalhos de teclado", "configurable": "Configurável", + "localShortcuts": "Local shortcuts", + "globalShortcuts": "Global recording shortcuts", + "globalDescription": "Available on macOS even when Recordly is in the background.", "fixed": "Fixo", "pressEscToCancel": "Pressione Esc para cancelar", "clickToChange": "Clique para alterar", diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 8d19ac7db..d0749b589 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -10,6 +10,7 @@ "countdownDelay": "Atraso da contagem regressiva", "noDelay": "Sem atraso", "record": "Gravar", + "startRecording": "Iniciar gravacao", "recordingFolder": "Caminho das gravações: {{path}}", "chooseRecordingsFolder": "Escolher caminho das gravações", "folderPath": "Caminho: /{{name}}/", @@ -25,6 +26,7 @@ "window": "Janela", "noSourcesFound": "Nenhuma fonte encontrada", "microphone": "Microfone", + "toggleMicrophoneMute": "Silenciar / reativar microfone", "turnOffMicrophone": "Desligar microfone", "selectMicToEnable": "Selecione um microfone para ativar", "noMicrophonesFound": "Nenhum microfone encontrado", diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json index da6ed8ee6..92406353a 100644 --- a/src/i18n/locales/ru/dialogs.json +++ b/src/i18n/locales/ru/dialogs.json @@ -45,6 +45,9 @@ "shortcutsConfig": { "title": "Сочетания клавиш", "configurable": "Настраиваемые", + "localShortcuts": "Local shortcuts", + "globalShortcuts": "Global recording shortcuts", + "globalDescription": "Available on macOS even when Recordly is in the background.", "fixed": "Фиксированные", "pressEscToCancel": "Нажмите Esc для отмены", "clickToChange": "Нажмите, чтобы изменить", @@ -59,4 +62,4 @@ "cancel": "Отмена", "save": "Сохранить" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json index 9642d2e96..4e5272a2a 100644 --- a/src/i18n/locales/zh-CN/dialogs.json +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -45,6 +45,9 @@ "shortcutsConfig": { "title": "键盘快捷键", "configurable": "可配置", + "localShortcuts": "Local shortcuts", + "globalShortcuts": "Global recording shortcuts", + "globalDescription": "Available on macOS even when Recordly is in the background.", "fixed": "固定", "pressEscToCancel": "按 Esc 取消", "clickToChange": "点击以更改", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 164c02afe..3cfa58ad9 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -10,6 +10,7 @@ "countdownDelay": "倒计时延迟", "noDelay": "无延迟", "record": "录制", + "startRecording": "开始录制", "recordingFolder": "录制文件夹:{{path}}", "chooseRecordingsFolder": "选择录制文件夹", "folderPath": "路径:/{{name}}/", @@ -25,6 +26,7 @@ "window": "窗口", "noSourcesFound": "未找到源", "microphone": "麦克风", + "toggleMicrophoneMute": "麦克风静音 / 取消静音", "turnOffMicrophone": "关闭麦克风", "selectMicToEnable": "选择一个麦克风以启用", "noMicrophonesFound": "未找到麦克风", diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json index 828b70ee3..3986f02a1 100644 --- a/src/i18n/locales/zh-TW/dialogs.json +++ b/src/i18n/locales/zh-TW/dialogs.json @@ -45,6 +45,9 @@ "shortcutsConfig": { "title": "鍵盤快捷鍵", "configurable": "可自訂", + "localShortcuts": "Local shortcuts", + "globalShortcuts": "Global recording shortcuts", + "globalDescription": "Available on macOS even when Recordly is in the background.", "fixed": "固定", "pressEscToCancel": "按下 Esc 以取消", "clickToChange": "點一下以更改", @@ -59,4 +62,4 @@ "cancel": "取消", "save": "儲存" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 12ec494d0..04e874646 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -10,6 +10,7 @@ "countdownDelay": "倒數延遲", "noDelay": "無延遲", "record": "錄製", + "startRecording": "開始錄製", "recordingFolder": "錄影儲存路徑:{{path}}", "chooseRecordingsFolder": "選擇錄影儲存路徑", "folderPath": "路徑:/{{name}}/", @@ -25,6 +26,7 @@ "window": "視窗", "noSourcesFound": "找不到可錄製的來源", "microphone": "麥克風", + "toggleMicrophoneMute": "麥克風靜音 / 取消靜音", "turnOffMicrophone": "關閉麥克風", "selectMicToEnable": "選擇要啟用的麥克風", "noMicrophonesFound": "找不到麥克風", diff --git a/src/lib/shortcuts.test.ts b/src/lib/shortcuts.test.ts new file mode 100644 index 000000000..f01ebb16d --- /dev/null +++ b/src/lib/shortcuts.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_LAUNCH_SHORTCUTS, + DEFAULT_SHORTCUTS, + resolvePersistedShortcuts, +} from "./shortcuts"; + +describe("resolvePersistedShortcuts", () => { + it("returns editor and launch defaults when no shortcuts are saved", () => { + expect(resolvePersistedShortcuts(null)).toEqual({ + editor: DEFAULT_SHORTCUTS, + launch: DEFAULT_LAUNCH_SHORTCUTS, + }); + }); + + it("keeps legacy editor-only shortcut files compatible", () => { + expect(resolvePersistedShortcuts({ addZoom: { key: "x" } })).toEqual({ + editor: { ...DEFAULT_SHORTCUTS, addZoom: { key: "x" } }, + launch: DEFAULT_LAUNCH_SHORTCUTS, + }); + }); + + it("merges structured editor and launch shortcut files with defaults", () => { + expect( + resolvePersistedShortcuts({ + editor: { splitClip: { key: "b" } }, + launch: { startRecording: { key: "r", ctrl: true, alt: true } }, + }), + ).toEqual({ + editor: { ...DEFAULT_SHORTCUTS, splitClip: { key: "b" } }, + launch: { + ...DEFAULT_LAUNCH_SHORTCUTS, + startRecording: { key: "r", ctrl: true, alt: true }, + }, + }); + }); +}); diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index d43846930..710e4e25c 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -9,6 +9,16 @@ export const SHORTCUT_ACTIONS = [ export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number]; +export const LAUNCH_SHORTCUT_ACTIONS = [ + "startRecording", + "stopRecording", + "pauseRecording", + "resumeRecording", + "muteMicrophone", +] as const; + +export type LaunchShortcutAction = (typeof LAUNCH_SHORTCUT_ACTIONS)[number]; + export interface ShortcutBinding { key: string; /** Maps to Cmd on macOS, Ctrl on Windows/Linux */ @@ -18,6 +28,7 @@ export interface ShortcutBinding { } export type ShortcutsConfig = Record; +export type LaunchShortcutsConfig = Record; export interface FixedShortcut { label: string; @@ -44,6 +55,7 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [ export type ShortcutConflict = | { type: "configurable"; action: ShortcutAction } | { type: "fixed"; label: string }; +export type LaunchShortcutConflict = { type: "configurable"; action: LaunchShortcutAction }; export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean { return ( @@ -72,6 +84,19 @@ export function findConflict( return null; } +export function findLaunchConflict( + binding: ShortcutBinding, + forAction: LaunchShortcutAction, + config: LaunchShortcutsConfig, +): LaunchShortcutConflict | null { + for (const action of LAUNCH_SHORTCUT_ACTIONS) { + if (action !== forAction && bindingsEqual(config[action], binding)) { + return { type: "configurable", action }; + } + } + return null; +} + export const DEFAULT_SHORTCUTS: ShortcutsConfig = { addZoom: { key: "z" }, splitClip: { key: "c" }, @@ -81,6 +106,14 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = { playPause: { key: " " }, }; +export const DEFAULT_LAUNCH_SHORTCUTS: LaunchShortcutsConfig = { + startRecording: { key: "r", ctrl: true, shift: true }, + stopRecording: { key: "s", ctrl: true, shift: true }, + pauseRecording: { key: "p", ctrl: true, shift: true }, + resumeRecording: { key: "p", ctrl: true, shift: true, alt: true }, + muteMicrophone: { key: "m", ctrl: true, shift: true }, +}; + export const SHORTCUT_LABELS: Record = { addZoom: "Add Zoom", splitClip: "Split Clip", @@ -90,6 +123,14 @@ export const SHORTCUT_LABELS: Record = { playPause: "Play / Pause", }; +export const LAUNCH_SHORTCUT_LABELS: Record = { + startRecording: "Start Recording", + stopRecording: "Stop Recording", + pauseRecording: "Pause Recording", + resumeRecording: "Resume Recording", + muteMicrophone: "Mute / Unmute Microphone", +}; + export function matchesShortcut( e: KeyboardEvent, binding: ShortcutBinding, @@ -134,3 +175,51 @@ export function mergeWithDefaults(partial: Partial): ShortcutsC } return merged; } + +export function mergeLaunchWithDefaults( + partial: Partial, +): LaunchShortcutsConfig { + const merged = { ...DEFAULT_LAUNCH_SHORTCUTS }; + for (const action of LAUNCH_SHORTCUT_ACTIONS) { + if (partial[action]) { + merged[action] = partial[action] as ShortcutBinding; + } + } + return merged; +} + +export type PersistedShortcutsPayload = + | Partial + | { + editor?: Partial; + launch?: Partial; + }; + +export function resolvePersistedShortcuts(payload: PersistedShortcutsPayload | null | undefined): { + editor: ShortcutsConfig; + launch: LaunchShortcutsConfig; +} { + if (!payload || typeof payload !== "object") { + return { + editor: { ...DEFAULT_SHORTCUTS }, + launch: { ...DEFAULT_LAUNCH_SHORTCUTS }, + }; + } + + const maybeStructured = payload as { + editor?: Partial; + launch?: Partial; + }; + + if ("editor" in maybeStructured || "launch" in maybeStructured) { + return { + editor: mergeWithDefaults(maybeStructured.editor ?? {}), + launch: mergeLaunchWithDefaults(maybeStructured.launch ?? {}), + }; + } + + return { + editor: mergeWithDefaults(payload as Partial), + launch: { ...DEFAULT_LAUNCH_SHORTCUTS }, + }; +}