Skip to content

Commit 5ae79dc

Browse files
author
nietowl
committed
Add global keyboard shortcuts for start, pause, and stop recording.
Lets users control recording without clicking the HUD (fixes #428).
1 parent 8b9b106 commit 5ae79dc

12 files changed

Lines changed: 448 additions & 15 deletions

File tree

‎electron/electron-env.d.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,25 @@ interface Window {
608608
error?: string;
609609
}>;
610610
onStopRecordingFromTray: (callback: () => void) => () => void;
611+
onRecordingShortcut: (
612+
callback: (payload: { action: "toggle" | "pauseResume" | "stop" }) => void,
613+
) => () => void;
614+
getRecordingShortcuts: () => Promise<{
615+
toggle: string;
616+
pauseResume: string;
617+
stop: string;
618+
}>;
619+
saveRecordingShortcuts: (shortcuts: unknown) => Promise<{
620+
success: boolean;
621+
config: { toggle: string; pauseResume: string; stop: string };
622+
failed: Array<"toggle" | "pauseResume" | "stop">;
623+
error?: string;
624+
}>;
625+
resetRecordingShortcuts: () => Promise<{
626+
success: boolean;
627+
config: { toggle: string; pauseResume: string; stop: string };
628+
failed: Array<"toggle" | "pauseResume" | "stop">;
629+
}>;
611630
onRecordingStateChanged: (
612631
callback: (state: { recording: boolean; sourceName: string }) => void,
613632
) => () => void;

‎electron/ipc/constants.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const PROJECT_THUMBNAIL_SUFFIX = ".preview.png";
88
export const RECENT_PROJECTS_FILE = path.join(USER_DATA_PATH, "recent-projects.json");
99
export const MAX_RECENT_PROJECTS = 16;
1010
export const SHORTCUTS_FILE = path.join(USER_DATA_PATH, "shortcuts.json");
11+
export const RECORDING_SHORTCUTS_FILE = path.join(USER_DATA_PATH, "recording-shortcuts.json");
1112
export const RECORDINGS_SETTINGS_FILE = path.join(USER_DATA_PATH, "recordings-settings.json");
1213
export const COUNTDOWN_SETTINGS_FILE = path.join(USER_DATA_PATH, "countdown-settings.json");
1314
export const APP_SETTINGS_FILE = path.join(USER_DATA_PATH, "app-settings.json");

‎electron/ipc/handlers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { registerExportHandlers } from "./register/export";
66
import { registerPermissionHandlers } from "./register/permissions";
77
import { registerProjectHandlers } from "./register/project";
88
import { registerRecordingHandlers } from "./register/recording";
9+
import { registerRecordingShortcutHandlers } from "./register/recordingShortcuts";
910
import { registerSettingsHandlers } from "./register/settings";
1011
import { registerSourceHandlers } from "./register/sources";
1112
import {
@@ -64,6 +65,7 @@ export function registerIpcHandlers(
6465
getSourceSelectorWindow,
6566
});
6667
registerRecordingHandlers(onRecordingStateChange);
68+
registerRecordingShortcutHandlers();
6769
registerPermissionHandlers();
6870
registerAnnouncementHandlers();
6971
registerAssetHandlers();
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import fs from "node:fs/promises";
2+
import { BrowserWindow, globalShortcut, ipcMain } from "electron";
3+
import {
4+
DEFAULT_RECORDING_SHORTCUTS,
5+
mergeRecordingShortcuts,
6+
RECORDING_SHORTCUT_ACTIONS,
7+
type RecordingShortcutAction,
8+
type RecordingShortcutsConfig,
9+
} from "../../../src/lib/recordingShortcuts";
10+
import { RECORDING_SHORTCUTS_FILE } from "../constants";
11+
import { parseJsonWithByteOrderMark } from "../utils";
12+
13+
let registeredAccelerators: string[] = [];
14+
let currentConfig: RecordingShortcutsConfig = { ...DEFAULT_RECORDING_SHORTCUTS };
15+
16+
function broadcastRecordingShortcut(action: RecordingShortcutAction) {
17+
for (const window of BrowserWindow.getAllWindows()) {
18+
if (!window.isDestroyed()) {
19+
window.webContents.send("recording-shortcut", { action });
20+
}
21+
}
22+
}
23+
24+
function unregisterAll() {
25+
for (const accelerator of registeredAccelerators) {
26+
try {
27+
globalShortcut.unregister(accelerator);
28+
} catch (error) {
29+
console.warn(`[recording-shortcuts] Failed to unregister ${accelerator}:`, error);
30+
}
31+
}
32+
registeredAccelerators = [];
33+
}
34+
35+
function registerFromConfig(config: RecordingShortcutsConfig): {
36+
success: boolean;
37+
config: RecordingShortcutsConfig;
38+
failed: RecordingShortcutAction[];
39+
} {
40+
unregisterAll();
41+
currentConfig = config;
42+
const failed: RecordingShortcutAction[] = [];
43+
const used = new Set<string>();
44+
45+
for (const action of RECORDING_SHORTCUT_ACTIONS) {
46+
const accelerator = config[action];
47+
if (used.has(accelerator)) {
48+
console.warn(
49+
`[recording-shortcuts] Duplicate accelerator "${accelerator}" for ${action}`,
50+
);
51+
failed.push(action);
52+
continue;
53+
}
54+
used.add(accelerator);
55+
56+
try {
57+
const ok = globalShortcut.register(accelerator, () => {
58+
broadcastRecordingShortcut(action);
59+
});
60+
if (!ok) {
61+
console.warn(
62+
`[recording-shortcuts] Could not register "${accelerator}" for ${action} (may be taken by the OS)`,
63+
);
64+
failed.push(action);
65+
continue;
66+
}
67+
registeredAccelerators.push(accelerator);
68+
} catch (error) {
69+
console.warn(
70+
`[recording-shortcuts] Error registering "${accelerator}" for ${action}:`,
71+
error,
72+
);
73+
failed.push(action);
74+
}
75+
}
76+
77+
return { success: failed.length === 0, config: currentConfig, failed };
78+
}
79+
80+
async function loadConfigFromDisk(): Promise<RecordingShortcutsConfig> {
81+
try {
82+
const data = await fs.readFile(RECORDING_SHORTCUTS_FILE, "utf-8");
83+
return mergeRecordingShortcuts(
84+
parseJsonWithByteOrderMark<Partial<RecordingShortcutsConfig>>(data),
85+
);
86+
} catch {
87+
return { ...DEFAULT_RECORDING_SHORTCUTS };
88+
}
89+
}
90+
91+
export function getRecordingShortcutsConfig(): RecordingShortcutsConfig {
92+
return currentConfig;
93+
}
94+
95+
export function unregisterRecordingShortcuts() {
96+
unregisterAll();
97+
}
98+
99+
export async function initRecordingShortcuts() {
100+
const config = await loadConfigFromDisk();
101+
return registerFromConfig(config);
102+
}
103+
104+
export function registerRecordingShortcutHandlers() {
105+
ipcMain.handle("get-recording-shortcuts", async () => {
106+
return currentConfig;
107+
});
108+
109+
ipcMain.handle("save-recording-shortcuts", async (_, shortcuts: unknown) => {
110+
try {
111+
const next = mergeRecordingShortcuts(
112+
shortcuts as Partial<RecordingShortcutsConfig> | null,
113+
);
114+
await fs.writeFile(RECORDING_SHORTCUTS_FILE, JSON.stringify(next, null, 2), "utf-8");
115+
const result = registerFromConfig(next);
116+
return {
117+
success: result.success,
118+
config: result.config,
119+
failed: result.failed,
120+
};
121+
} catch (error) {
122+
console.error("[recording-shortcuts] Failed to save:", error);
123+
return {
124+
success: false,
125+
config: currentConfig,
126+
failed: [...RECORDING_SHORTCUT_ACTIONS],
127+
error: String(error),
128+
};
129+
}
130+
});
131+
132+
ipcMain.handle("reset-recording-shortcuts", async () => {
133+
try {
134+
await fs.writeFile(
135+
RECORDING_SHORTCUTS_FILE,
136+
JSON.stringify(DEFAULT_RECORDING_SHORTCUTS, null, 2),
137+
"utf-8",
138+
);
139+
} catch {
140+
/* ignore write failure; still re-register defaults in memory */
141+
}
142+
const result = registerFromConfig({ ...DEFAULT_RECORDING_SHORTCUTS });
143+
return { success: result.success, config: result.config, failed: result.failed };
144+
});
145+
}

‎electron/main.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import {
2525
killWindowsCaptureProcess,
2626
registerIpcHandlers,
2727
} from "./ipc/handlers";
28+
import {
29+
initRecordingShortcuts,
30+
unregisterRecordingShortcuts,
31+
} from "./ipc/register/recordingShortcuts";
2832
import { ensureMediaServer } from "./mediaServer";
2933
import { hardenWebContentsNavigation, shouldHardenWebContentsType } from "./navigationPolicy";
3034
import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy";
@@ -716,11 +720,25 @@ function updateTrayMenu(recording: boolean = false) {
716720
}
717721
},
718722
},
723+
{
724+
label: "Pause / Resume",
725+
click: () => {
726+
for (const window of BrowserWindow.getAllWindows()) {
727+
if (!window.isDestroyed()) {
728+
window.webContents.send("recording-shortcut", {
729+
action: "pauseResume",
730+
});
731+
}
732+
}
733+
},
734+
},
719735
{
720736
label: "Stop Recording",
721737
click: () => {
722-
if (mainWindow && !mainWindow.isDestroyed()) {
723-
mainWindow.webContents.send("stop-recording-from-tray");
738+
for (const window of BrowserWindow.getAllWindows()) {
739+
if (!window.isDestroyed()) {
740+
window.webContents.send("recording-shortcut", { action: "stop" });
741+
}
724742
}
725743
},
726744
},
@@ -865,6 +883,7 @@ function createSourceSelectorWindowWrapper() {
865883
// explicitly with Cmd + Q.
866884
app.on("before-quit", () => {
867885
isAppQuitting = true;
886+
unregisterRecordingShortcuts();
868887
killWindowsCaptureProcess();
869888
showCursor();
870889
cleanupNativeVideoExportSessions();
@@ -1016,6 +1035,10 @@ app.whenReady().then(async () => {
10161035
},
10171036
);
10181037

1038+
void initRecordingShortcuts().catch((error) => {
1039+
console.warn("[recording-shortcuts] Failed to initialize:", error);
1040+
});
1041+
10191042
if (IS_SMOKE_EXPORT || process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT) {
10201043
await logSmokeExportGpuDiagnostics();
10211044
if (IS_SMOKE_EXPORT) {

‎electron/preload.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,25 @@ contextBridge.exposeInMainWorld("electronAPI", {
622622
ipcRenderer.on("stop-recording-from-tray", listener);
623623
return () => ipcRenderer.removeListener("stop-recording-from-tray", listener);
624624
},
625+
onRecordingShortcut: (
626+
callback: (payload: { action: "toggle" | "pauseResume" | "stop" }) => void,
627+
) => {
628+
const listener = (
629+
_event: Electron.IpcRendererEvent,
630+
payload: { action: "toggle" | "pauseResume" | "stop" },
631+
) => callback(payload);
632+
ipcRenderer.on("recording-shortcut", listener);
633+
return () => ipcRenderer.removeListener("recording-shortcut", listener);
634+
},
635+
getRecordingShortcuts: () => {
636+
return ipcRenderer.invoke("get-recording-shortcuts");
637+
},
638+
saveRecordingShortcuts: (shortcuts: unknown) => {
639+
return ipcRenderer.invoke("save-recording-shortcuts", shortcuts);
640+
},
641+
resetRecordingShortcuts: () => {
642+
return ipcRenderer.invoke("reset-recording-shortcuts");
643+
},
625644
onRecordingStateChanged: (
626645
callback: (state: { recording: boolean; sourceName: string }) => void,
627646
) => {

‎src/components/launch/RecordingControls.tsx‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ import {
66
PlayIcon,
77
XIcon,
88
} from "@phosphor-icons/react";
9-
import { useMemo } from "react";
10-
import { useScopedT } from "@/contexts/I18nContext";
9+
import { useEffect, useMemo, useState } from "react";
1110
import { Button } from "@/components/ui/button";
1211
import { Separator } from "@/components/ui/separator";
12+
import { useScopedT } from "@/contexts/I18nContext";
13+
import {
14+
DEFAULT_RECORDING_SHORTCUTS,
15+
formatRecordingAccelerator,
16+
type RecordingShortcutsConfig,
17+
} from "@/lib/recordingShortcuts";
1318
import styles from "./LaunchWindow.module.css";
1419

1520
interface RecordingControlsProps {
@@ -36,6 +41,27 @@ export const RecordingControls = ({
3641
formatTime,
3742
}: RecordingControlsProps) => {
3843
const t = useScopedT("launch");
44+
const [shortcuts, setShortcuts] = useState<RecordingShortcutsConfig>(
45+
DEFAULT_RECORDING_SHORTCUTS,
46+
);
47+
const isMac =
48+
typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform);
49+
50+
useEffect(() => {
51+
let cancelled = false;
52+
void window.electronAPI?.getRecordingShortcuts?.().then((config) => {
53+
if (!cancelled && config) {
54+
setShortcuts({ ...DEFAULT_RECORDING_SHORTCUTS, ...config });
55+
}
56+
});
57+
return () => {
58+
cancelled = true;
59+
};
60+
}, []);
61+
62+
const pauseLabel = paused ? t("recording.resume") : t("recording.pause");
63+
const pauseShortcut = formatRecordingAccelerator(shortcuts.pauseResume, isMac);
64+
const stopShortcut = formatRecordingAccelerator(shortcuts.stop, isMac);
3965

4066
const memoizedControls = useMemo(() => {
4167
return (
@@ -90,8 +116,8 @@ export const RecordingControls = ({
90116
size="icon"
91117
iconSize="lg"
92118
onClick={onPauseResume}
93-
title={paused ? t("recording.resume") : t("recording.pause")}
94-
aria-label={paused ? t("recording.resume") : t("recording.pause")}
119+
title={`${pauseLabel} (${pauseShortcut})`}
120+
aria-label={`${pauseLabel} (${pauseShortcut})`}
95121
className={paused ? styles.ibGreen : ""}
96122
>
97123
{paused ? (
@@ -104,8 +130,8 @@ export const RecordingControls = ({
104130
<button
105131
type="button"
106132
onClick={onStopRecording}
107-
title={t("recording.stop")}
108-
aria-label={t("recording.stop")}
133+
title={`${t("recording.stop")} (${stopShortcut})`}
134+
aria-label={`${t("recording.stop")} (${stopShortcut})`}
109135
className={`${styles.recBtn} ${styles.electronNoDrag}`}
110136
>
111137
<span className={styles.stopSquare} />
@@ -145,6 +171,9 @@ export const RecordingControls = ({
145171
onCancelRecording,
146172
formatTime,
147173
t,
174+
pauseLabel,
175+
pauseShortcut,
176+
stopShortcut,
148177
]);
149178

150179
return memoizedControls;

0 commit comments

Comments
 (0)