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;