diff --git a/main.js b/main.js
index 6aa853eb..0812c738 100644
--- a/main.js
+++ b/main.js
@@ -612,6 +612,35 @@ async function startApp() {
}
});
+ // Set up meeting mode hotkey
+ const meetingHotkeyCallback = () => {
+ if (hotkeyManager.isInListeningMode()) return;
+ if (meetingDetectionEngine?.isInManualMeetingMode()) {
+ debugLogger.info("Meeting hotkey pressed, ending manual meeting mode", {}, "meeting");
+ meetingDetectionEngine.endManualMeetingMode();
+ } else {
+ debugLogger.info("Meeting hotkey pressed, triggering force meeting mode", {}, "meeting");
+ meetingDetectionEngine?.forceMeetingMode();
+ }
+ };
+
+ const savedMeetingKey = environmentManager.getMeetingKey?.() || "Control+Shift+M";
+ debugLogger.info("Registering meeting hotkey", { hotkey: savedMeetingKey }, "meeting");
+ const meetingResult = hotkeyManager.registerSlot("meeting", savedMeetingKey, meetingHotkeyCallback);
+ debugLogger.info("Meeting hotkey registration result", { success: meetingResult.success, hotkey: savedMeetingKey }, "meeting");
+
+ ipcMain.on("meeting-hotkey-changed", (_event, hotkey) => {
+ debugLogger.info("Meeting hotkey changed", { hotkey }, "meeting");
+ if (hotkey) {
+ const result = hotkeyManager.registerSlot("meeting", hotkey, meetingHotkeyCallback);
+ debugLogger.info("Meeting hotkey re-registration result", { success: result.success, hotkey }, "meeting");
+ environmentManager.saveMeetingKey(hotkey);
+ } else {
+ hotkeyManager.unregisterSlot("meeting");
+ environmentManager.saveMeetingKey("");
+ }
+ });
+
// Phase 2: Initialize remaining managers after windows are visible
initializeDeferredManagers();
diff --git a/preload.js b/preload.js
index a17e5b70..ad2ff5ca 100644
--- a/preload.js
+++ b/preload.js
@@ -533,6 +533,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Notify main process of activation mode changes (for Windows Push-to-Talk)
notifyActivationModeChanged: (mode) => ipcRenderer.send("activation-mode-changed", mode),
notifyHotkeyChanged: (hotkey) => ipcRenderer.send("hotkey-changed", hotkey),
+ notifyMeetingHotkeyChanged: (hotkey) => ipcRenderer.send("meeting-hotkey-changed", hotkey),
// Floating icon auto-hide
notifyFloatingIconAutoHideChanged: (enabled) =>
@@ -649,6 +650,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
meetingNotificationReady: () => ipcRenderer.invoke("meeting-notification-ready"),
meetingNotificationRespond: (detectionId, action) =>
ipcRenderer.invoke("meeting-notification-respond", detectionId, action),
+ forceMeetingMode: () => ipcRenderer.invoke("force-meeting-mode"),
+ endManualMeetingMode: () => ipcRenderer.invoke("end-manual-meeting-mode"),
onNavigateToMeetingNote: registerListener(
"navigate-to-meeting-note",
(callback) => (_event, data) => callback(data)
diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx
index 1a9d25bc..ec3308f4 100644
--- a/src/components/SettingsPage.tsx
+++ b/src/components/SettingsPage.tsx
@@ -681,6 +681,8 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage
customReasoningApiKey,
setCustomReasoningApiKey,
setDictationKey,
+ meetingKey,
+ setMeetingKey,
autoLearnCorrections,
setAutoLearnCorrections,
updateTranscriptionSettings,
@@ -858,6 +860,16 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage
showAlert: showAlertDialog,
});
+ const { registerHotkey: registerMeetingHotkey, isRegistering: isMeetingHotkeyRegistering } =
+ useHotkeyRegistration({
+ onSuccess: (registeredHotkey) => {
+ setMeetingKey(registeredHotkey);
+ },
+ showSuccessToast: false,
+ showErrorToast: true,
+ showAlert: showAlertDialog,
+ });
+
const validateHotkeyForInput = useCallback(
(hotkey: string) => getValidationMessage(hotkey, getPlatform()),
[]
@@ -2642,6 +2654,26 @@ EOF`,
)}
+
+ {/* Meeting Mode Hotkey */}
+
+
+
+
+ {
+ await registerMeetingHotkey(newHotkey);
+ }}
+ disabled={isMeetingHotkeyRegistering}
+ validate={validateHotkeyForInput}
+ />
+
+
+
);
diff --git a/src/helpers/environment.js b/src/helpers/environment.js
index d11f24f8..1ec6a684 100644
--- a/src/helpers/environment.js
+++ b/src/helpers/environment.js
@@ -21,6 +21,7 @@ const PERSISTED_KEYS = [
"LLAMA_VULKAN_ENABLED",
"DICTATION_KEY",
"AGENT_KEY",
+ "MEETING_KEY",
"ACTIVATION_MODE",
"FLOATING_ICON_AUTO_HIDE",
"START_MINIMIZED",
@@ -143,6 +144,16 @@ class EnvironmentManager {
return result;
}
+ getMeetingKey() {
+ return this._getKey("MEETING_KEY");
+ }
+
+ saveMeetingKey(key) {
+ const result = this._saveKey("MEETING_KEY", key);
+ this.saveAllKeysToEnvFile().catch(() => {});
+ return result;
+ }
+
getActivationMode() {
const mode = this._getKey("ACTIVATION_MODE");
return mode === "push" ? "push" : "tap";
diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js
index 4dcdceb9..660cc663 100644
--- a/src/helpers/ipcHandlers.js
+++ b/src/helpers/ipcHandlers.js
@@ -3915,6 +3915,24 @@ class IPCHandlers {
this.windowManager?.showNotificationWindow();
});
+ ipcMain.handle("force-meeting-mode", async () => {
+ try {
+ await this.meetingDetectionEngine.forceMeetingMode();
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+ });
+
+ ipcMain.handle("end-manual-meeting-mode", async () => {
+ try {
+ this.meetingDetectionEngine.endManualMeetingMode();
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+ });
+
ipcMain.handle("get-desktop-sources", async (_event, types) => {
try {
const { desktopCapturer } = require("electron");
diff --git a/src/helpers/meetingDetectionEngine.js b/src/helpers/meetingDetectionEngine.js
index 36d65a88..0b86ab10 100644
--- a/src/helpers/meetingDetectionEngine.js
+++ b/src/helpers/meetingDetectionEngine.js
@@ -19,6 +19,7 @@ class MeetingDetectionEngine {
this.activeDetections = new Map();
this.preferences = { processDetection: true, audioDetection: true };
this._userRecording = false;
+ this._manualMeetingMode = false;
this._notificationQueue = [];
this._postRecordingCooldown = null;
this._bindListeners();
@@ -55,6 +56,15 @@ class MeetingDetectionEngine {
return;
}
+ if (this._manualMeetingMode) {
+ debugLogger.info(
+ "Suppressing detection — manual meeting mode active",
+ { detectionId },
+ "meeting"
+ );
+ return;
+ }
+
const calendarState = this.googleCalendarManager?.getActiveMeetingState?.();
if (calendarState) {
if (calendarState.activeMeeting) {
@@ -199,6 +209,53 @@ class MeetingDetectionEngine {
}
}
+ async forceMeetingMode() {
+ if (this._manualMeetingMode) {
+ debugLogger.info("Already in manual meeting mode, ignoring", {}, "meeting");
+ return;
+ }
+
+ debugLogger.info("Force meeting mode triggered", {}, "meeting");
+ this._manualMeetingMode = true;
+
+ const event = {
+ id: `manual-${Date.now()}`,
+ calendar_id: "__manual__",
+ summary: "New note",
+ start_time: new Date().toISOString(),
+ end_time: new Date(Date.now() + 3600000).toISOString(),
+ is_all_day: 0,
+ status: "confirmed",
+ hangout_link: null,
+ conference_data: null,
+ organizer_email: null,
+ attendees_count: 0,
+ };
+
+ const noteResult = this.databaseManager.saveNote(event.summary, "", "meeting");
+ const meetingsFolder = this.databaseManager.getMeetingsFolder();
+
+ if (noteResult?.note?.id && meetingsFolder?.id) {
+ await this.windowManager.createControlPanelWindow();
+ this.windowManager.snapControlPanelToMeetingMode();
+ this.windowManager.sendToControlPanel("navigate-to-meeting-note", {
+ noteId: noteResult.note.id,
+ folderId: meetingsFolder.id,
+ event,
+ });
+ }
+ }
+
+ endManualMeetingMode() {
+ if (!this._manualMeetingMode) return;
+ debugLogger.info("Manual meeting mode ended", {}, "meeting");
+ this._manualMeetingMode = false;
+ }
+
+ isInManualMeetingMode() {
+ return this._manualMeetingMode;
+ }
+
handleNotificationTimeout() {
for (const [detectionId, detection] of this.activeDetections) {
if (!detection.dismissed) {
@@ -306,6 +363,7 @@ class MeetingDetectionEngine {
this.meetingProcessDetector.stop();
this.audioActivityDetector.stop();
this.activeDetections.clear();
+ this._manualMeetingMode = false;
if (this._postRecordingCooldown) {
clearTimeout(this._postRecordingCooldown);
this._postRecordingCooldown = null;
diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts
index 56d0c62f..9d441a55 100644
--- a/src/hooks/useSettings.ts
+++ b/src/hooks/useSettings.ts
@@ -32,6 +32,7 @@ export interface ReasoningSettings {
export interface HotkeySettings {
dictationKey: string;
+ meetingKey: string;
activationMode: "tap" | "push";
}
@@ -188,6 +189,7 @@ function useSettingsInternal() {
groqApiKey: store.groqApiKey,
mistralApiKey: store.mistralApiKey,
dictationKey: store.dictationKey,
+ meetingKey: store.meetingKey,
theme: store.theme,
setUseLocalWhisper: store.setUseLocalWhisper,
setWhisperModel: store.setWhisperModel,
@@ -218,6 +220,7 @@ function useSettingsInternal() {
customReasoningApiKey: store.customReasoningApiKey,
setCustomReasoningApiKey: store.setCustomReasoningApiKey,
setDictationKey: store.setDictationKey,
+ setMeetingKey: store.setMeetingKey,
setTheme: store.setTheme,
activationMode: store.activationMode,
setActivationMode: store.setActivationMode,
diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json
index 3faba237..257c56ff 100644
--- a/src/locales/de/translation.json
+++ b/src/locales/de/translation.json
@@ -1130,6 +1130,10 @@
"resetToDefault": "Auf {{hotkey}} zurücksetzen",
"title": "Diktat-Hotkey"
},
+ "meetingHotkey": {
+ "title": "Meeting-Modus-Hotkey",
+ "description": "Meeting-Modus manuell starten und automatische Benachrichtigungen unterdrücken"
+ },
"microphone": {
"description": "Wählen Sie das Eingabegerät für das Diktat",
"title": "Mikrofon"
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index bd41ccf0..2885c50c 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -1190,6 +1190,10 @@
"resetToDefault": "Reset to {{hotkey}}",
"title": "Dictation Hotkey"
},
+ "meetingHotkey": {
+ "title": "Meeting Mode Hotkey",
+ "description": "Manually start meeting mode and suppress automatic meeting notifications"
+ },
"microphone": {
"description": "Select which input device to use for dictation",
"title": "Microphone"
diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json
index f6a59dac..69849e1d 100644
--- a/src/locales/es/translation.json
+++ b/src/locales/es/translation.json
@@ -1130,6 +1130,10 @@
"resetToDefault": "Restablecer a {{hotkey}}",
"title": "Atajo de dictado"
},
+ "meetingHotkey": {
+ "title": "Hotkey modo reunión",
+ "description": "Iniciar manualmente el modo reunión y suprimir las notificaciones automáticas"
+ },
"microphone": {
"description": "Selecciona qué dispositivo de entrada usar para dictado",
"title": "Micrófono"
diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json
index 35d546d9..9310e2c1 100644
--- a/src/locales/fr/translation.json
+++ b/src/locales/fr/translation.json
@@ -1130,6 +1130,10 @@
"resetToDefault": "Réinitialiser à {{hotkey}}",
"title": "Raccourci de dictée"
},
+ "meetingHotkey": {
+ "title": "Raccourci mode réunion",
+ "description": "Démarrer manuellement le mode réunion et supprimer les notifications automatiques"
+ },
"microphone": {
"description": "Sélectionnez le périphérique d'entrée à utiliser pour la dictée",
"title": "Microphone"
diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json
index 5df69dcd..3af20530 100644
--- a/src/locales/it/translation.json
+++ b/src/locales/it/translation.json
@@ -1130,6 +1130,10 @@
"resetToDefault": "Ripristina a {{hotkey}}",
"title": "Scorciatoia di dettatura"
},
+ "meetingHotkey": {
+ "title": "Hotkey modalità meeting",
+ "description": "Avvia manualmente la modalità meeting e sopprimi le notifiche automatiche"
+ },
"microphone": {
"description": "Seleziona il dispositivo di ingresso da usare per la dettatura",
"title": "Microfono"
diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json
index cdaee849..4fa009b9 100644
--- a/src/locales/ja/translation.json
+++ b/src/locales/ja/translation.json
@@ -1130,6 +1130,10 @@
"resetToDefault": "{{hotkey}}にリセット",
"title": "ディクテーションホットキー"
},
+ "meetingHotkey": {
+ "title": "ミーティングモードホットキー",
+ "description": "ミーティングモードを手動で開始し、自動通知を抑制する"
+ },
"microphone": {
"description": "ディクテーションに使用する入力デバイスを選択",
"title": "マイク"
diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json
index 043fdb27..1c1ff577 100644
--- a/src/locales/pt/translation.json
+++ b/src/locales/pt/translation.json
@@ -1102,6 +1102,10 @@
"resetToDefault": "Redefinir para {{hotkey}}",
"title": "Atalho de ditado"
},
+ "meetingHotkey": {
+ "title": "Hotkey modo reunião",
+ "description": "Iniciar manualmente o modo reunião e suprimir notificações automáticas"
+ },
"microphone": {
"description": "Selecione qual dispositivo de entrada usar para ditado",
"title": "Microfone"
diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json
index dc2ed367..6130f322 100644
--- a/src/locales/ru/translation.json
+++ b/src/locales/ru/translation.json
@@ -1130,6 +1130,10 @@
"resetToDefault": "Сбросить на {{hotkey}}",
"title": "Горячая клавиша диктовки"
},
+ "meetingHotkey": {
+ "title": "Горячая клавиша режима встречи",
+ "description": "Вручную запустить режим встречи и подавить автоматические уведомления"
+ },
"microphone": {
"description": "Выберите устройство ввода для диктовки",
"title": "Микрофон"
diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json
index 2b4430c7..5f7b15b8 100644
--- a/src/locales/zh-CN/translation.json
+++ b/src/locales/zh-CN/translation.json
@@ -1130,6 +1130,10 @@
"resetToDefault": "重置为 {{hotkey}}",
"title": "听写快捷键"
},
+ "meetingHotkey": {
+ "title": "会议模式快捷键",
+ "description": "手动启动会议模式并抑制自动通知"
+ },
"microphone": {
"description": "选择用于听写的输入设备",
"title": "麦克风"
diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json
index 977e62fa..b555a2c3 100644
--- a/src/locales/zh-TW/translation.json
+++ b/src/locales/zh-TW/translation.json
@@ -1130,6 +1130,10 @@
"resetToDefault": "重置為 {{hotkey}}",
"title": "語音輸入快捷鍵"
},
+ "meetingHotkey": {
+ "title": "會議模式快捷鍵",
+ "description": "手動啟動會議模式並抑制自動通知"
+ },
"microphone": {
"description": "選擇用於語音輸入的輸入裝置",
"title": "麥克風"
diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts
index 5c043ce8..80f0a860 100644
--- a/src/stores/settingsStore.ts
+++ b/src/stores/settingsStore.ts
@@ -135,6 +135,7 @@ export interface SettingsState
setCustomReasoningApiKey: (key: string) => void;
setDictationKey: (key: string) => void;
+ setMeetingKey: (key: string) => void;
setActivationMode: (mode: "tap" | "push") => void;
setPreferBuiltInMic: (value: boolean) => void;
@@ -256,6 +257,7 @@ export const useSettingsStore = create()((set, get) => ({
customReasoningApiKey: readString("customReasoningApiKey", ""),
dictationKey: readString("dictationKey", ""),
+ meetingKey: readString("meetingKey", "Control+Shift+M"),
activationMode: (readString("activationMode", "tap") === "push" ? "push" : "tap") as
| "tap"
| "push",
@@ -413,6 +415,13 @@ export const useSettingsStore = create()((set, get) => ({
window.electronAPI?.saveDictationKey?.(key);
}
},
+ setMeetingKey: (key: string) => {
+ if (isBrowser) localStorage.setItem("meetingKey", key);
+ set({ meetingKey: key });
+ if (isBrowser) {
+ window.electronAPI?.notifyMeetingHotkeyChanged?.(key);
+ }
+ },
setActivationMode: (mode: "tap" | "push") => {
// Linux has no native key listener for push-to-talk — force tap
diff --git a/src/types/electron.ts b/src/types/electron.ts
index ec2404dd..85362dca 100644
--- a/src/types/electron.ts
+++ b/src/types/electron.ts
@@ -743,6 +743,7 @@ declare global {
// Windows Push-to-Talk notifications
notifyActivationModeChanged?: (mode: "tap" | "push") => void;
notifyHotkeyChanged?: (hotkey: string) => void;
+ notifyMeetingHotkeyChanged?: (hotkey: string) => void;
notifyFloatingIconAutoHideChanged?: (enabled: boolean) => void;
onFloatingIconAutoHideChanged?: (callback: (enabled: boolean) => void) => () => void;
notifyStartMinimizedChanged?: (enabled: boolean) => void;
@@ -1177,6 +1178,8 @@ declare global {
detectionId: string,
action: string
) => Promise<{ success: boolean }>;
+ forceMeetingMode?: () => Promise<{ success: boolean }>;
+ endManualMeetingMode?: () => Promise<{ success: boolean }>;
onNavigateToMeetingNote?: (
callback: (data: { noteId: number; folderId: number; event: any }) => void
) => () => void;