From 19092647d1e4a8ed693a44ddb0c9d62f46c29fa2 Mon Sep 17 00:00:00 2001 From: alger Date: Wed, 4 Mar 2026 20:28:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BF=AB=E6=8D=B7=E9=94=AE=E6=95=B4?= =?UTF-8?q?=E4=BD=93=E9=87=8D=E6=9E=84=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + src/i18n/lang/en-US/settings.ts | 33 + src/i18n/lang/ja-JP/settings.ts | 33 + src/i18n/lang/ko-KR/settings.ts | 33 + src/i18n/lang/zh-CN/settings.ts | 33 + src/i18n/lang/zh-Hant/settings.ts | 33 + src/main/index.ts | 7 +- src/main/modules/config.ts | 6 +- src/main/modules/shortcuts.ts | 450 ++++- .../components/settings/ShortcutSettings.vue | 1458 ++++++++++++----- src/renderer/main.ts | 4 - src/renderer/utils/appShortcuts.ts | 250 ++- src/renderer/utils/shortcutKeyboard.ts | 118 ++ src/shared/shortcuts.ts | 368 +++++ tsconfig.node.json | 3 +- tsconfig.web.json | 1 + 16 files changed, 2168 insertions(+), 663 deletions(-) create mode 100644 src/renderer/utils/shortcutKeyboard.ts create mode 100644 src/shared/shortcuts.ts diff --git a/.gitignore b/.gitignore index 2bc1e26..1edb4af 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ android/app/release .cursor .windsurf .agent +.agents .claude .kiro CLAUDE.md diff --git a/src/i18n/lang/en-US/settings.ts b/src/i18n/lang/en-US/settings.ts index 5037890..8da679b 100644 --- a/src/i18n/lang/en-US/settings.ts +++ b/src/i18n/lang/en-US/settings.ts @@ -383,28 +383,61 @@ export default { title: 'Shortcut Settings', shortcut: 'Shortcut', shortcutDesc: 'Customize global shortcuts', + summaryReady: 'Shortcut configuration is ready to save', + summaryRecording: 'Recording a new shortcut combination', + summaryBlocked: 'Fix conflicts or invalid entries before saving', + platformHintMac: 'On macOS, CommandOrControl is displayed as Cmd', + platformHintWindows: 'On Windows, CommandOrControl is displayed as Ctrl', + platformHintLinux: 'On Linux, CommandOrControl is displayed as Ctrl', + platformHintGeneric: 'CommandOrControl is adapted per operating system', + enabledCount: 'Enabled', + recordingTip: 'Click a shortcut field, press combination. Esc cancels, Delete disables', shortcutConflict: 'Shortcut Conflict', inputPlaceholder: 'Click to input shortcut', + clickToRecord: 'Click then press a shortcut', + recording: 'Recording...', resetShortcuts: 'Reset', + restoreSingle: 'Restore', disableAll: 'Disable All', enableAll: 'Enable All', + groups: { + playback: 'Playback', + sound: 'Volume & Favorite', + window: 'Window' + }, togglePlay: 'Play/Pause', + togglePlayDesc: 'Toggle current playback state', prevPlay: 'Previous', + prevPlayDesc: 'Play the previous track', nextPlay: 'Next', + nextPlayDesc: 'Play the next track', volumeUp: 'Volume Up', + volumeUpDesc: 'Increase player volume', volumeDown: 'Volume Down', + volumeDownDesc: 'Decrease player volume', toggleFavorite: 'Favorite/Unfavorite', + toggleFavoriteDesc: 'Favorite or unfavorite current track', toggleWindow: 'Show/Hide Window', + toggleWindowDesc: 'Quickly show or hide the main window', scopeGlobal: 'Global', scopeApp: 'App Only', enabled: 'Enabled', disabled: 'Disabled', + issueInvalid: 'Invalid combo', + issueReserved: 'System reserved', + registrationWarningTitle: 'These shortcuts could not be registered', + registrationOccupied: 'Occupied by system or another app', + registrationInvalid: 'Invalid shortcut format', messages: { resetSuccess: 'Shortcuts reset successfully, please save', conflict: 'Shortcut conflict, please reset', saveSuccess: 'Shortcuts saved successfully', saveError: 'Failed to save shortcuts', + saveValidationError: 'Shortcut validation failed, please review and try again', + partialRegistered: 'Saved, but some global shortcuts were not registered', cancelEdit: 'Edit cancelled', + clearToDisable: 'Shortcut disabled', + invalidShortcut: 'Invalid shortcut, please use a valid combination', disableAll: 'All shortcuts disabled, please save to apply', enableAll: 'All shortcuts enabled, please save to apply' } diff --git a/src/i18n/lang/ja-JP/settings.ts b/src/i18n/lang/ja-JP/settings.ts index ed8f58f..eb512bb 100644 --- a/src/i18n/lang/ja-JP/settings.ts +++ b/src/i18n/lang/ja-JP/settings.ts @@ -382,28 +382,61 @@ export default { title: 'ショートカット設定', shortcut: 'ショートカット', shortcutDesc: 'ショートカットをカスタマイズ', + summaryReady: 'ショートカット設定は保存可能です', + summaryRecording: '新しいショートカットを記録中です', + summaryBlocked: '競合または無効な項目を修正してください', + platformHintMac: 'macOS では CommandOrControl は Cmd と表示されます', + platformHintWindows: 'Windows では CommandOrControl は Ctrl と表示されます', + platformHintLinux: 'Linux では CommandOrControl は Ctrl と表示されます', + platformHintGeneric: 'CommandOrControl はOSに応じて自動変換されます', + enabledCount: '有効', + recordingTip: '欄をクリックしてキー入力。Escでキャンセル、Deleteで無効化', shortcutConflict: 'ショートカットの競合', inputPlaceholder: 'クリックしてショートカットを入力', + clickToRecord: 'クリックしてキーを入力', + recording: '記録中...', resetShortcuts: 'デフォルトに戻す', + restoreSingle: '復元', disableAll: 'すべて無効', enableAll: 'すべて有効', + groups: { + playback: '再生操作', + sound: '音量とお気に入り', + window: 'ウィンドウ' + }, togglePlay: '再生/一時停止', + togglePlayDesc: '現在の再生状態を切り替えます', prevPlay: '前の曲', + prevPlayDesc: '前の曲に切り替えます', nextPlay: '次の曲', + nextPlayDesc: '次の曲に切り替えます', volumeUp: '音量を上げる', + volumeUpDesc: 'プレイヤー音量を上げます', volumeDown: '音量を下げる', + volumeDownDesc: 'プレイヤー音量を下げます', toggleFavorite: 'お気に入り/お気に入り解除', + toggleFavoriteDesc: '現在の曲をお気に入り切り替えします', toggleWindow: 'ウィンドウ表示/非表示', + toggleWindowDesc: 'メインウィンドウを表示/非表示にします', scopeGlobal: 'グローバル', scopeApp: 'アプリ内', enabled: '有効', disabled: '無効', + issueInvalid: '無効な組み合わせ', + issueReserved: 'システム予約', + registrationWarningTitle: '以下のショートカットは登録できませんでした', + registrationOccupied: 'システムまたは他アプリで使用中', + registrationInvalid: 'ショートカット形式が無効', messages: { resetSuccess: 'デフォルトのショートカットに戻しました。保存を忘れずに', conflict: '競合するショートカットがあります。再設定してください', saveSuccess: 'ショートカット設定を保存しました', saveError: 'ショートカットの保存に失敗しました。再試行してください', + saveValidationError: 'ショートカット検証に失敗しました。内容を確認してください', + partialRegistered: '保存しましたが、一部のグローバルショートカットは登録されませんでした', cancelEdit: '変更をキャンセルしました', + clearToDisable: 'このショートカットを無効にしました', + invalidShortcut: '無効なショートカットです。有効な組み合わせを入力してください', disableAll: 'すべてのショートカットを無効にしました。保存を忘れずに', enableAll: 'すべてのショートカットを有効にしました。保存を忘れずに' } diff --git a/src/i18n/lang/ko-KR/settings.ts b/src/i18n/lang/ko-KR/settings.ts index d142d59..8f2d537 100644 --- a/src/i18n/lang/ko-KR/settings.ts +++ b/src/i18n/lang/ko-KR/settings.ts @@ -383,28 +383,61 @@ export default { title: '단축키 설정', shortcut: '단축키', shortcutDesc: '단축키 사용자 정의', + summaryReady: '단축키 구성이 저장 가능한 상태입니다', + summaryRecording: '새 단축키 조합을 입력 중입니다', + summaryBlocked: '충돌 또는 잘못된 항목을 먼저 수정하세요', + platformHintMac: 'macOS에서는 CommandOrControl이 Cmd로 표시됩니다', + platformHintWindows: 'Windows에서는 CommandOrControl이 Ctrl로 표시됩니다', + platformHintLinux: 'Linux에서는 CommandOrControl이 Ctrl로 표시됩니다', + platformHintGeneric: 'CommandOrControl은 운영체제에 맞게 자동 변환됩니다', + enabledCount: '활성화됨', + recordingTip: '필드를 클릭 후 조합키 입력, Esc 취소, Delete 비활성화', shortcutConflict: '단축키 충돌', inputPlaceholder: '클릭하여 단축키 입력', + clickToRecord: '클릭 후 단축키 입력', + recording: '입력 중...', resetShortcuts: '기본값 복원', + restoreSingle: '복원', disableAll: '모두 비활성화', enableAll: '모두 활성화', + groups: { + playback: '재생 제어', + sound: '볼륨 및 즐겨찾기', + window: '창 제어' + }, togglePlay: '재생/일시정지', + togglePlayDesc: '현재 재생 상태를 전환합니다', prevPlay: '이전 곡', + prevPlayDesc: '이전 곡으로 이동합니다', nextPlay: '다음 곡', + nextPlayDesc: '다음 곡으로 이동합니다', volumeUp: '볼륨 증가', + volumeUpDesc: '플레이어 볼륨을 높입니다', volumeDown: '볼륨 감소', + volumeDownDesc: '플레이어 볼륨을 낮춥니다', toggleFavorite: '즐겨찾기/즐겨찾기 취소', + toggleFavoriteDesc: '현재 곡 즐겨찾기를 전환합니다', toggleWindow: '창 표시/숨기기', + toggleWindowDesc: '메인 창을 빠르게 표시/숨김합니다', scopeGlobal: '전역', scopeApp: '앱 내', enabled: '활성화', disabled: '비활성화', + issueInvalid: '잘못된 조합', + issueReserved: '시스템 예약', + registrationWarningTitle: '다음 단축키는 등록되지 않았습니다', + registrationOccupied: '시스템 또는 다른 앱에서 사용 중', + registrationInvalid: '단축키 형식이 잘못됨', messages: { resetSuccess: '기본 단축키로 복원되었습니다. 저장을 잊지 마세요', conflict: '충돌하는 단축키가 있습니다. 다시 설정하세요', saveSuccess: '단축키 설정이 저장되었습니다', saveError: '단축키 저장 실패, 다시 시도하세요', + saveValidationError: '단축키 검증에 실패했습니다. 설정을 확인하세요', + partialRegistered: '저장되었지만 일부 전역 단축키는 등록되지 않았습니다', cancelEdit: '수정이 취소되었습니다', + clearToDisable: '해당 단축키가 비활성화되었습니다', + invalidShortcut: '잘못된 단축키입니다. 유효한 조합을 입력하세요', disableAll: '모든 단축키가 비활성화되었습니다. 저장을 잊지 마세요', enableAll: '모든 단축키가 활성화되었습니다. 저장을 잊지 마세요' } diff --git a/src/i18n/lang/zh-CN/settings.ts b/src/i18n/lang/zh-CN/settings.ts index 8f57cc2..8d3c1c5 100644 --- a/src/i18n/lang/zh-CN/settings.ts +++ b/src/i18n/lang/zh-CN/settings.ts @@ -380,28 +380,61 @@ export default { title: '快捷键设置', shortcut: '快捷键', shortcutDesc: '自定义快捷键', + summaryReady: '当前快捷键配置可保存', + summaryRecording: '正在录制新的快捷键组合', + summaryBlocked: '存在冲突或无效项,请先修正', + platformHintMac: 'macOS 下 CommandOrControl 会显示为 Cmd', + platformHintWindows: 'Windows 下 CommandOrControl 会显示为 Ctrl', + platformHintLinux: 'Linux 下 CommandOrControl 会显示为 Ctrl', + platformHintGeneric: '不同系统下 CommandOrControl 会自动适配', + enabledCount: '已启用', + recordingTip: '点击快捷键框后按下组合键,Esc 取消,Delete 可禁用该项', shortcutConflict: '快捷键冲突', inputPlaceholder: '点击输入快捷键', + clickToRecord: '点击后按下组合键', + recording: '录制中...', resetShortcuts: '恢复默认', + restoreSingle: '恢复', disableAll: '全部禁用', enableAll: '全部启用', + groups: { + playback: '播放控制', + sound: '音量与收藏', + window: '窗口控制' + }, togglePlay: '播放/暂停', + togglePlayDesc: '切换当前歌曲播放状态', prevPlay: '上一首', + prevPlayDesc: '切换到上一首歌曲', nextPlay: '下一首', + nextPlayDesc: '切换到下一首歌曲', volumeUp: '音量增加', + volumeUpDesc: '提高播放器音量', volumeDown: '音量减少', + volumeDownDesc: '降低播放器音量', toggleFavorite: '收藏/取消收藏', + toggleFavoriteDesc: '收藏或取消当前歌曲', toggleWindow: '显示/隐藏窗口', + toggleWindowDesc: '快速显示或隐藏主窗口', scopeGlobal: '全局', scopeApp: '应用内', enabled: '启用', disabled: '禁用', + issueInvalid: '组合无效', + issueReserved: '系统保留', + registrationWarningTitle: '以下快捷键未能注册,请更换组合后重试', + registrationOccupied: '被系统或其他应用占用', + registrationInvalid: '键位格式无效', messages: { resetSuccess: '已恢复默认快捷键,请记得保存', conflict: '存在冲突的快捷键,请重新设置', saveSuccess: '快捷键设置已保存', saveError: '保存快捷键失败,请重试', + saveValidationError: '快捷键校验未通过,请检查后重试', + partialRegistered: '已保存,但部分全局快捷键未注册成功', cancelEdit: '已取消修改', + clearToDisable: '已禁用该快捷键', + invalidShortcut: '快捷键无效,请输入有效组合', disableAll: '已禁用所有快捷键,请记得保存', enableAll: '已启用所有快捷键,请记得保存' } diff --git a/src/i18n/lang/zh-Hant/settings.ts b/src/i18n/lang/zh-Hant/settings.ts index 3cd9e18..8dd857d 100644 --- a/src/i18n/lang/zh-Hant/settings.ts +++ b/src/i18n/lang/zh-Hant/settings.ts @@ -377,28 +377,61 @@ export default { title: '快捷鍵設定', shortcut: '快捷鍵', shortcutDesc: '自訂快捷鍵', + summaryReady: '目前快捷鍵設定可直接儲存', + summaryRecording: '正在錄製新的快捷鍵組合', + summaryBlocked: '存在衝突或無效項目,請先修正', + platformHintMac: 'macOS 下 CommandOrControl 會顯示為 Cmd', + platformHintWindows: 'Windows 下 CommandOrControl 會顯示為 Ctrl', + platformHintLinux: 'Linux 下 CommandOrControl 會顯示為 Ctrl', + platformHintGeneric: 'CommandOrControl 會依系統自動適配', + enabledCount: '已啟用', + recordingTip: '點擊快捷鍵欄位後輸入組合鍵,Esc 取消,Delete 可停用', shortcutConflict: '快捷鍵衝突', inputPlaceholder: '點擊輸入快捷鍵', + clickToRecord: '點擊後輸入快捷鍵', + recording: '錄製中...', resetShortcuts: '恢復預設', + restoreSingle: '恢復', disableAll: '全部停用', enableAll: '全部啟用', + groups: { + playback: '播放控制', + sound: '音量與收藏', + window: '視窗控制' + }, togglePlay: '播放/暫停', + togglePlayDesc: '切換目前歌曲播放狀態', prevPlay: '上一首', + prevPlayDesc: '切換到上一首歌曲', nextPlay: '下一首', + nextPlayDesc: '切換到下一首歌曲', volumeUp: '增加音量', + volumeUpDesc: '提高播放器音量', volumeDown: '減少音量', + volumeDownDesc: '降低播放器音量', toggleFavorite: '收藏/取消收藏', + toggleFavoriteDesc: '收藏或取消目前歌曲', toggleWindow: '顯示/隱藏視窗', + toggleWindowDesc: '快速顯示或隱藏主視窗', scopeGlobal: '全域', scopeApp: '應用程式內', enabled: '已啟用', disabled: '已停用', + issueInvalid: '組合無效', + issueReserved: '系統保留', + registrationWarningTitle: '以下快捷鍵未能註冊,請改用其他組合', + registrationOccupied: '被系統或其他應用程式占用', + registrationInvalid: '鍵位格式無效', messages: { resetSuccess: '已恢復預設快捷鍵,請記得儲存', conflict: '存在快捷鍵衝突,請重新設定', saveSuccess: '快捷鍵設定已儲存', saveError: '快捷鍵儲存失敗,請重試', + saveValidationError: '快捷鍵校驗未通過,請檢查後重試', + partialRegistered: '已儲存,但部分全域快捷鍵未註冊成功', cancelEdit: '已取消修改', + clearToDisable: '已停用該快捷鍵', + invalidShortcut: '快捷鍵無效,請輸入有效組合', disableAll: '已停用所有快捷鍵,請記得儲存', enableAll: '已啟用所有快捷鍵,請記得儲存' } diff --git a/src/main/index.ts b/src/main/index.ts index 8a88cff..f678a5e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,7 +13,7 @@ import { initializeLoginWindow } from './modules/loginWindow'; import { initLxMusicHttp } from './modules/lxMusicHttp'; import { initializeOtherApi } from './modules/otherApi'; import { initializeRemoteControl } from './modules/remoteControl'; -import { initializeShortcuts, registerShortcuts } from './modules/shortcuts'; +import { initializeShortcuts } from './modules/shortcuts'; import { initializeTray, updateCurrentSong, updatePlayState, updateTrayMenu } from './modules/tray'; import { setupUpdateHandlers } from './modules/update'; import { createMainWindow, initializeWindowManager, setAppQuitting } from './modules/window'; @@ -149,11 +149,6 @@ if (!isSingleInstance) { }); }); - // 监听快捷键更新 - ipcMain.on('update-shortcuts', () => { - registerShortcuts(mainWindow); - }); - // 监听语言切换 ipcMain.on('change-language', (_, locale: Language) => { // 更新主进程的语言设置 diff --git a/src/main/modules/config.ts b/src/main/modules/config.ts index 40cd8d9..884f56e 100644 --- a/src/main/modules/config.ts +++ b/src/main/modules/config.ts @@ -1,8 +1,8 @@ import { app, ipcMain } from 'electron'; import Store from 'electron-store'; +import { createDefaultShortcuts, type ShortcutsConfig } from '../../shared/shortcuts'; import set from '../set.json'; -import { defaultShortcuts } from './shortcuts'; type SetConfig = { isProxy: boolean; @@ -29,7 +29,7 @@ type SetConfig = { }; interface StoreType { set: SetConfig; - shortcuts: typeof defaultShortcuts; + shortcuts: ShortcutsConfig; } let store: Store; @@ -42,7 +42,7 @@ export function initializeConfig() { name: 'config', defaults: { set: set as SetConfig, - shortcuts: defaultShortcuts + shortcuts: createDefaultShortcuts() } }); diff --git a/src/main/modules/shortcuts.ts b/src/main/modules/shortcuts.ts index 728406a..b644964 100644 --- a/src/main/modules/shortcuts.ts +++ b/src/main/modules/shortcuts.ts @@ -1,122 +1,398 @@ -import { globalShortcut, ipcMain } from 'electron'; +import { type BrowserWindow, globalShortcut, ipcMain } from 'electron'; +import { + defaultShortcuts, + getReservedAccelerators, + getShortcutConflicts, + hasShortcutAction, + isModifierOnlyShortcut, + normalizeShortcutAccelerator, + normalizeShortcutsConfig, + type ShortcutAction, + shortcutActionOrder, + type ShortcutPlatform, + type ShortcutsConfig, + type ShortcutScope +} from '../../shared/shortcuts'; import { getStore } from './config'; -// 添加获取平台信息的 IPC 处理程序 -ipcMain.on('get-platform', (event) => { - event.returnValue = process.platform; -}); +type ShortcutRegistrationFailureReason = 'invalid' | 'occupied'; -// 定义快捷键配置接口 -export interface ShortcutConfig { +type ShortcutRegistrationFailure = { + action: ShortcutAction; key: string; - enabled: boolean; - scope: 'global' | 'app'; -} - -export interface ShortcutsConfig { - [key: string]: ShortcutConfig; -} - -// 定义默认快捷键 -export const defaultShortcuts: ShortcutsConfig = { - togglePlay: { key: 'CommandOrControl+Alt+P', enabled: true, scope: 'global' }, - prevPlay: { key: 'Alt+Left', enabled: true, scope: 'global' }, - nextPlay: { key: 'Alt+Right', enabled: true, scope: 'global' }, - volumeUp: { key: 'Alt+Up', enabled: true, scope: 'app' }, - volumeDown: { key: 'Alt+Down', enabled: true, scope: 'app' }, - toggleFavorite: { key: 'CommandOrControl+Alt+L', enabled: true, scope: 'app' }, - toggleWindow: { key: 'CommandOrControl+Alt+Shift+M', enabled: true, scope: 'global' } + reason: ShortcutRegistrationFailureReason; }; -let mainWindowRef: Electron.BrowserWindow | null = null; +type ShortcutRegistrationResult = { + success: boolean; + failed: ShortcutRegistrationFailure[]; +}; -// 注册快捷键 -export function registerShortcuts( - mainWindow: Electron.BrowserWindow, - shortcutsConfig?: ShortcutsConfig -) { - mainWindowRef = mainWindow; +type ShortcutValidationReason = 'invalid' | 'conflict' | 'reserved'; + +type ShortcutValidationIssue = { + action: ShortcutAction; + key: string; + scope: ShortcutScope; + reason: ShortcutValidationReason; + conflictWith?: ShortcutAction; +}; + +type ShortcutValidationResult = { + shortcuts: ShortcutsConfig; + hasBlockingIssue: boolean; + issues: ShortcutValidationIssue[]; +}; + +type ShortcutSaveResult = { + ok: boolean; + validation: ShortcutValidationResult; + registration: ShortcutRegistrationResult; +}; + +let mainWindowRef: BrowserWindow | null = null; +let shortcutsEnabled = true; +let shortcutIpcReady = false; + +const managedGlobalShortcuts = new Map(); + +function currentPlatform(): ShortcutPlatform { + if ( + process.platform === 'darwin' || + process.platform === 'win32' || + process.platform === 'linux' + ) { + return process.platform; + } + return 'linux'; +} + +function hasAvailableMainWindow(): boolean { + return Boolean(mainWindowRef && !mainWindowRef.isDestroyed()); +} + +function isShortcutsConfigEqual(left: ShortcutsConfig, right: ShortcutsConfig): boolean { + return shortcutActionOrder.every((action) => { + const leftConfig = left[action]; + const rightConfig = right[action]; + return ( + leftConfig.key === rightConfig.key && + leftConfig.enabled === rightConfig.enabled && + leftConfig.scope === rightConfig.scope + ); + }); +} + +function getStoredShortcuts(): ShortcutsConfig { const store = getStore(); - const shortcuts = - shortcutsConfig || (store.get('shortcuts') as ShortcutsConfig) || defaultShortcuts; + const rawShortcuts = store.get('shortcuts'); + const normalizedShortcuts = normalizeShortcutsConfig(rawShortcuts); - // 注销所有已注册的快捷键 - globalShortcut.unregisterAll(); + const serializedRaw = JSON.stringify(rawShortcuts ?? null); + const serializedNormalized = JSON.stringify(normalizedShortcuts); - // 对旧格式数据进行兼容处理 - if (shortcuts && typeof shortcuts.togglePlay === 'string') { - // 将 shortcuts 强制转换为 unknown,再转为 Record - const oldShortcuts = { ...shortcuts } as unknown as Record; - const newShortcuts: ShortcutsConfig = {}; + if (serializedRaw !== serializedNormalized) { + store.set('shortcuts', normalizedShortcuts); + } - Object.entries(oldShortcuts).forEach(([key, value]) => { - newShortcuts[key] = { - key: value, - enabled: true, - scope: ['volumeUp', 'volumeDown', 'toggleFavorite'].includes(key) ? 'app' : 'global' - }; - }); + return normalizedShortcuts; +} - store.set('shortcuts', newShortcuts); - registerShortcuts(mainWindow, newShortcuts); +function persistShortcuts(shortcuts: ShortcutsConfig) { + const store = getStore(); + const currentShortcuts = normalizeShortcutsConfig(store.get('shortcuts')); + + if (!isShortcutsConfigEqual(currentShortcuts, shortcuts)) { + store.set('shortcuts', shortcuts); + } +} + +function emitShortcutsChanged( + shortcuts: ShortcutsConfig, + registration: ShortcutRegistrationResult +): void { + if (!hasAvailableMainWindow()) { return; } - // 注册全局快捷键 - Object.entries(shortcuts).forEach(([action, config]) => { - const { key, enabled, scope } = config as ShortcutConfig; + mainWindowRef!.webContents.send('update-app-shortcuts', shortcuts); + mainWindowRef!.webContents.send('shortcuts-updated', shortcuts, registration); +} - // 只注册启用且作用域为全局的快捷键 - if (!enabled || scope !== 'global') return; +function unregisterManagedGlobalShortcuts() { + managedGlobalShortcuts.forEach((accelerator) => { + try { + globalShortcut.unregister(accelerator); + } catch (error) { + console.error(`[Shortcuts] 注销快捷键失败: ${accelerator}`, error); + } + }); + managedGlobalShortcuts.clear(); +} + +function handleShortcutAction(action: ShortcutAction) { + if (!hasAvailableMainWindow()) { + return; + } + + const mainWindow = mainWindowRef!; + + if (action === 'toggleWindow') { + if (mainWindow.isVisible() && mainWindow.isFocused()) { + mainWindow.hide(); + return; + } + + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.show(); + mainWindow.focus(); + return; + } + + mainWindow.webContents.send('global-shortcut', action); +} + +function registerManagedGlobalShortcuts(shortcuts: ShortcutsConfig): ShortcutRegistrationResult { + unregisterManagedGlobalShortcuts(); + + const failed: ShortcutRegistrationFailure[] = []; + + if (!shortcutsEnabled) { + return { + success: true, + failed + }; + } + + shortcutActionOrder.forEach((action) => { + const config = shortcuts[action]; + if (!config.enabled || config.scope !== 'global') { + return; + } + + const accelerator = normalizeShortcutAccelerator(config.key); + if (!accelerator || isModifierOnlyShortcut(accelerator)) { + failed.push({ + action, + key: config.key, + reason: 'invalid' + }); + return; + } try { - switch (action) { - case 'toggleWindow': - globalShortcut.register(key, () => { - if (mainWindow.isVisible()) { - mainWindow.hide(); - } else { - mainWindow.show(); - } - }); - break; - default: - globalShortcut.register(key, () => { - mainWindow.webContents.send('global-shortcut', action); - }); - break; + const registered = globalShortcut.register(accelerator, () => { + handleShortcutAction(action); + }); + + if (!registered) { + failed.push({ + action, + key: accelerator, + reason: 'occupied' + }); + return; } + + managedGlobalShortcuts.set(action, accelerator); } catch (error) { - console.error(`注册快捷键 ${key} 失败:`, error); + console.error(`[Shortcuts] 注册快捷键失败: ${accelerator}`, error); + failed.push({ + action, + key: accelerator, + reason: 'invalid' + }); } }); - // 通知渲染进程更新应用内快捷键 - mainWindow.webContents.send('update-app-shortcuts', shortcuts); + return { + success: failed.length === 0, + failed + }; } -// 初始化快捷键 -export function initializeShortcuts(mainWindow: Electron.BrowserWindow) { - mainWindowRef = mainWindow; - registerShortcuts(mainWindow); +function validateShortcuts(rawShortcuts: unknown): ShortcutValidationResult { + const shortcuts = normalizeShortcutsConfig(rawShortcuts); + const issues: ShortcutValidationIssue[] = []; + const issueKeys = new Set(); + + const rawShortcutMap = + rawShortcuts && typeof rawShortcuts === 'object' + ? (rawShortcuts as Record) + : {}; + + const pushIssue = (issue: ShortcutValidationIssue) => { + const issueKey = `${issue.reason}:${issue.action}:${issue.scope}:${issue.key}:${issue.conflictWith ?? ''}`; + if (issueKeys.has(issueKey)) { + return; + } + issueKeys.add(issueKey); + issues.push(issue); + }; + + shortcutActionOrder.forEach((action) => { + const rawActionConfig = rawShortcutMap[action]; + if (!rawActionConfig) { + return; + } + + const rawKey = + typeof rawActionConfig === 'string' + ? rawActionConfig + : typeof rawActionConfig === 'object' && rawActionConfig !== null + ? (rawActionConfig as { key?: unknown }).key + : null; + + if (typeof rawKey !== 'string') { + return; + } + + const normalizedKey = normalizeShortcutAccelerator(rawKey); + if (!normalizedKey || isModifierOnlyShortcut(rawKey)) { + pushIssue({ + action, + key: rawKey, + scope: shortcuts[action].scope, + reason: 'invalid' + }); + } + }); + + const conflicts = getShortcutConflicts(shortcuts); + conflicts.forEach((conflict) => { + conflict.actions.forEach((action, index) => { + const conflictWith = conflict.actions[(index + 1) % conflict.actions.length]; + pushIssue({ + action, + key: conflict.key, + scope: conflict.scope, + reason: 'conflict', + conflictWith + }); + }); + }); + + const reservedAccelerators = new Set(getReservedAccelerators(currentPlatform())); + shortcutActionOrder.forEach((action) => { + const config = shortcuts[action]; + if (!config.enabled || config.scope !== 'global') { + return; + } + + const accelerator = normalizeShortcutAccelerator(config.key); + if (accelerator && reservedAccelerators.has(accelerator)) { + pushIssue({ + action, + key: accelerator, + scope: config.scope, + reason: 'reserved' + }); + } + }); + + return { + shortcuts, + hasBlockingIssue: issues.length > 0, + issues + }; +} + +function applyShortcuts(shortcuts: ShortcutsConfig): ShortcutRegistrationResult { + const registration = registerManagedGlobalShortcuts(shortcuts); + emitShortcutsChanged(shortcuts, registration); + return registration; +} + +function saveShortcuts(rawShortcuts: unknown): ShortcutSaveResult { + const validation = validateShortcuts(rawShortcuts); + + if (validation.hasBlockingIssue) { + return { + ok: false, + validation, + registration: { + success: false, + failed: [] + } + }; + } + + persistShortcuts(validation.shortcuts); + const registration = applyShortcuts(validation.shortcuts); + + return { + ok: true, + validation, + registration + }; +} + +function setupShortcutIpcHandlers() { + if (shortcutIpcReady) { + return; + } + + shortcutIpcReady = true; + + ipcMain.on('get-platform', (event) => { + event.returnValue = process.platform; + }); - // 监听禁用快捷键事件 ipcMain.on('disable-shortcuts', () => { - globalShortcut.unregisterAll(); + shortcutsEnabled = false; + unregisterManagedGlobalShortcuts(); }); - // 监听启用快捷键事件 ipcMain.on('enable-shortcuts', () => { - if (mainWindowRef) { - registerShortcuts(mainWindowRef); - } + shortcutsEnabled = true; + const shortcuts = getStoredShortcuts(); + applyShortcuts(shortcuts); }); - // 监听快捷键更新事件 - ipcMain.on('update-shortcuts', (_, shortcutsConfig: ShortcutsConfig) => { - if (mainWindowRef) { - registerShortcuts(mainWindowRef, shortcutsConfig); - } + ipcMain.on('update-shortcuts', (_, shortcutsConfig: unknown) => { + saveShortcuts(shortcutsConfig); + }); + + ipcMain.handle('shortcuts:get-config', () => { + return getStoredShortcuts(); + }); + + ipcMain.handle('shortcuts:validate', (_, shortcutsConfig: unknown) => { + return validateShortcuts(shortcutsConfig); + }); + + ipcMain.handle('shortcuts:save', (_, shortcutsConfig: unknown) => { + return saveShortcuts(shortcutsConfig); }); } + +export function registerShortcuts(mainWindow: BrowserWindow, shortcutsConfig?: ShortcutsConfig) { + mainWindowRef = mainWindow; + + const shortcuts = shortcutsConfig + ? normalizeShortcutsConfig(shortcutsConfig) + : getStoredShortcuts(); + + if (shortcutsConfig) { + persistShortcuts(shortcuts); + } + + return applyShortcuts(shortcuts); +} + +export function initializeShortcuts(mainWindow: BrowserWindow) { + mainWindowRef = mainWindow; + setupShortcutIpcHandlers(); + + const shortcuts = getStoredShortcuts(); + applyShortcuts(shortcuts); +} + +export function isShortcutActionSupported(action: string): action is ShortcutAction { + return hasShortcutAction(action); +} + +export { defaultShortcuts }; diff --git a/src/renderer/components/settings/ShortcutSettings.vue b/src/renderer/components/settings/ShortcutSettings.vue index 8de7f68..f658c15 100644 --- a/src/renderer/components/settings/ShortcutSettings.vue +++ b/src/renderer/components/settings/ShortcutSettings.vue @@ -1,480 +1,1098 @@ - diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 7673efd..bbfd0b6 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -11,7 +11,6 @@ import pinia from '@/store'; import App from './App.vue'; import directives from './directive'; -import { initAppShortcuts } from './utils/appShortcuts'; const app = createApp(App); @@ -23,6 +22,3 @@ app.use(pinia); app.use(router); app.use(i18n as any); app.mount('#app'); - -// 初始化应用内快捷键 -initAppShortcuts(); diff --git a/src/renderer/utils/appShortcuts.ts b/src/renderer/utils/appShortcuts.ts index 90c719e..491a19f 100644 --- a/src/renderer/utils/appShortcuts.ts +++ b/src/renderer/utils/appShortcuts.ts @@ -3,68 +3,61 @@ import { onMounted, onUnmounted } from 'vue'; import i18n from '@/../i18n/renderer'; import { usePlayerStore, useSettingsStore } from '@/store'; +import { + hasShortcutAction, + normalizeShortcutAccelerator, + normalizeShortcutsConfig, + type ShortcutAction, + shortcutActionOrder, + type ShortcutsConfig +} from '../../shared/shortcuts'; import { isElectron } from '.'; +import { isEditableTarget, keyboardEventToAccelerator } from './shortcutKeyboard'; import { showShortcutToast } from './shortcutToast'; -// 添加一个简单的防抖机制 -let actionTimeout: NodeJS.Timeout | null = null; -const ACTION_DELAY = 300; // 毫秒 +const ACTION_DELAY = 260; -// 添加一个操作锁,记录最后一次操作的时间和动作 -let lastActionInfo = { - action: '', - timestamp: 0 -}; - -interface ShortcutConfig { - key: string; - enabled: boolean; - scope: 'global' | 'app'; -} - -interface ShortcutsConfig { - [key: string]: ShortcutConfig; -} +const actionTimestamps = new Map(); const { t } = i18n.global; -// 全局存储快捷键配置 -let appShortcuts: ShortcutsConfig = {}; +let appShortcuts: ShortcutsConfig = normalizeShortcutsConfig(null); +let appShortcutsSuspended = false; +let appShortcutsInitialized = false; + +const onGlobalShortcut = (_event: unknown, action: string) => { + if (!hasShortcutAction(action)) { + return; + } + + void handleShortcutAction(action); +}; + +const onUpdateAppShortcuts = (_event: unknown, shortcuts: unknown) => { + updateAppShortcuts(shortcuts); +}; + +function shouldSkipAction(action: ShortcutAction): boolean { + const now = Date.now(); + const lastTimestamp = actionTimestamps.get(action) ?? 0; + + if (now - lastTimestamp < ACTION_DELAY) { + return true; + } + + actionTimestamps.set(action, now); + return false; +} /** * 处理快捷键动作 * @param action 快捷键动作 */ -export async function handleShortcutAction(action: string) { - const now = Date.now(); - - // 如果存在未完成的动作,则忽略当前请求 - if (actionTimeout) { - console.log('[AppShortcuts] 忽略快速连续的动作请求:', action); +export async function handleShortcutAction(action: ShortcutAction) { + if (shouldSkipAction(action)) { return; } - // 检查是否是同一个动作的重复触发(300ms内) - if (lastActionInfo.action === action && now - lastActionInfo.timestamp < ACTION_DELAY) { - console.log( - `[AppShortcuts] 忽略重复的 ${action} 动作,距上次仅 ${now - lastActionInfo.timestamp}ms` - ); - return; - } - - // 更新最后一次操作信息 - lastActionInfo = { - action, - timestamp: now - }; - - // 设置防抖锁 - actionTimeout = setTimeout(() => { - actionTimeout = null; - }, ACTION_DELAY); - - console.log(`[AppShortcuts] 执行动作: ${action}, 时间戳: ${now}`); - const playerStore = usePlayerStore(); const settingsStore = useSettingsStore(); @@ -81,11 +74,9 @@ export async function handleShortcutAction(action: string) { if (playerStore.play) { await playerStore.handlePause(); showToast(t('player.playBar.pause'), 'ri-pause-circle-line'); - } else { - if (playerStore.playMusic?.id) { - await playerStore.setPlay({ ...playerStore.playMusic }); - showToast(t('player.playBar.play'), 'ri-play-circle-line'); - } + } else if (playerStore.playMusic?.id) { + await playerStore.setPlay({ ...playerStore.playMusic }); + showToast(t('player.playBar.play'), 'ri-play-circle-line'); } break; case 'prevPlay': @@ -115,16 +106,19 @@ export async function handleShortcutAction(action: string) { } break; case 'toggleFavorite': { - const isFavorite = playerStore.favoriteList.includes(Number(playerStore.playMusic.id)); - const numericId = Number(playerStore.playMusic.id); - console.log(`[AppShortcuts] toggleFavorite 当前状态: ${isFavorite}, ID: ${numericId}`); - if (isFavorite) { - playerStore.removeFromFavorite(numericId); - console.log(`[AppShortcuts] 已从收藏中移除: ${numericId}`); - } else { - playerStore.addToFavorite(numericId); - console.log(`[AppShortcuts] 已添加到收藏: ${numericId}`); + if (!playerStore.playMusic?.id) { + return; } + + const currentSongId = Number(playerStore.playMusic.id); + const isFavorite = playerStore.favoriteList.includes(currentSongId); + + if (isFavorite) { + playerStore.removeFromFavorite(currentSongId); + } else { + playerStore.addToFavorite(currentSongId); + } + showToast( isFavorite ? t('player.playBar.unFavorite', { name: playerStore.playMusic.name }) @@ -134,119 +128,91 @@ export async function handleShortcutAction(action: string) { break; } default: - console.log('未知的快捷键动作:', action); break; } } catch (error) { - console.error(`执行快捷键动作 ${action} 时出错:`, error); - } finally { - // 确保在出错时也能清除超时 - clearTimeout(actionTimeout); - actionTimeout = null; - console.log( - `[AppShortcuts] 动作完成: ${action}, 时间戳: ${Date.now()}, 耗时: ${Date.now() - now}ms` - ); + console.error(`[AppShortcuts] 执行动作失败: ${action}`, error); } } -/** - * 检查按键是否匹配快捷键 - * @param e KeyboardEvent - * @param shortcutKey 快捷键字符串 - * @returns 是否匹配 - */ -function matchShortcut(e: KeyboardEvent, shortcutKey: string): boolean { - const keys = shortcutKey.split('+'); - const pressedKey = e.key.length === 1 ? e.key.toUpperCase() : e.key; - - // 检查修饰键 - const hasCommandOrControl = keys.includes('CommandOrControl'); - const hasAlt = keys.includes('Alt'); - const hasShift = keys.includes('Shift'); - - // 检查主键 - let mainKey = keys.find((k) => !['CommandOrControl', 'Alt', 'Shift'].includes(k)); - if (!mainKey) return false; - - // 处理特殊键 - if (mainKey === 'Left' && pressedKey === 'ArrowLeft') mainKey = 'ArrowLeft'; - if (mainKey === 'Right' && pressedKey === 'ArrowRight') mainKey = 'ArrowRight'; - if (mainKey === 'Up' && pressedKey === 'ArrowUp') mainKey = 'ArrowUp'; - if (mainKey === 'Down' && pressedKey === 'ArrowDown') mainKey = 'ArrowDown'; - - // 检查是否所有条件都匹配 - return ( - hasCommandOrControl === (e.ctrlKey || e.metaKey) && - hasAlt === e.altKey && - hasShift === e.shiftKey && - mainKey === pressedKey - ); -} - /** * 全局键盘事件处理函数 - * @param e KeyboardEvent */ -function handleKeyDown(e: KeyboardEvent) { - // 如果在输入框中则不处理快捷键 - if (['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) { +function handleKeyDown(event: KeyboardEvent) { + if (appShortcutsSuspended) { return; } - Object.entries(appShortcuts).forEach(([action, config]) => { - if (config.enabled && config.scope === 'app' && matchShortcut(e, config.key)) { - e.preventDefault(); - handleShortcutAction(action); + if (isEditableTarget(event.target)) { + return; + } + + const accelerator = keyboardEventToAccelerator(event); + if (!accelerator) { + return; + } + + for (const action of shortcutActionOrder) { + const config = appShortcuts[action]; + if (!config.enabled || config.scope !== 'app') { + continue; } - }); + + const shortcutKey = normalizeShortcutAccelerator(config.key); + if (!shortcutKey || shortcutKey !== accelerator) { + continue; + } + + event.preventDefault(); + void handleShortcutAction(action); + break; + } } /** * 更新应用内快捷键 - * @param shortcuts 快捷键配置 */ -export function updateAppShortcuts(shortcuts: ShortcutsConfig) { - appShortcuts = shortcuts; +export function updateAppShortcuts(shortcuts: unknown) { + appShortcuts = normalizeShortcutsConfig(shortcuts); +} + +export function setAppShortcutsSuspended(suspended: boolean) { + appShortcutsSuspended = suspended; } /** * 初始化应用内快捷键 */ export function initAppShortcuts() { - if (isElectron) { - // 监听全局快捷键事件 - window.electron.ipcRenderer.on('global-shortcut', async (_, action: string) => { - handleShortcutAction(action); - }); - - // 监听应用内快捷键更新 - window.electron.ipcRenderer.on('update-app-shortcuts', (_, shortcuts: ShortcutsConfig) => { - updateAppShortcuts(shortcuts); - }); - - // 获取初始快捷键配置 - const storedShortcuts = window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts'); - if (storedShortcuts) { - updateAppShortcuts(storedShortcuts); - } - - // 添加键盘事件监听 - document.addEventListener('keydown', handleKeyDown); + if (!isElectron || appShortcutsInitialized) { + return; } + + appShortcutsInitialized = true; + + window.electron.ipcRenderer.on('global-shortcut', onGlobalShortcut); + window.electron.ipcRenderer.on('update-app-shortcuts', onUpdateAppShortcuts); + + const storedShortcuts = window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts'); + updateAppShortcuts(storedShortcuts); + + document.addEventListener('keydown', handleKeyDown); } /** * 清理应用内快捷键 */ export function cleanupAppShortcuts() { - if (isElectron) { - // 移除全局事件监听 - window.electron.ipcRenderer.removeAllListeners('global-shortcut'); - window.electron.ipcRenderer.removeAllListeners('update-app-shortcuts'); - - // 移除键盘事件监听 - document.removeEventListener('keydown', handleKeyDown); + if (!isElectron || !appShortcutsInitialized) { + return; } + + appShortcutsInitialized = false; + + window.electron.ipcRenderer.removeListener('global-shortcut', onGlobalShortcut); + window.electron.ipcRenderer.removeListener('update-app-shortcuts', onUpdateAppShortcuts); + + document.removeEventListener('keydown', handleKeyDown); } /** diff --git a/src/renderer/utils/shortcutKeyboard.ts b/src/renderer/utils/shortcutKeyboard.ts new file mode 100644 index 0000000..e40d48a --- /dev/null +++ b/src/renderer/utils/shortcutKeyboard.ts @@ -0,0 +1,118 @@ +import { normalizeShortcutAccelerator } from '../../shared/shortcuts'; + +const modifierOnlyKeys = new Set(['Control', 'Meta', 'Alt', 'Shift']); + +const keyAliases: Record = { + ArrowLeft: 'Left', + ArrowRight: 'Right', + ArrowUp: 'Up', + ArrowDown: 'Down', + Escape: 'Escape', + Enter: 'Enter', + Tab: 'Tab', + Backspace: 'Backspace', + Delete: 'Delete', + Insert: 'Insert', + Home: 'Home', + End: 'End', + PageUp: 'PageUp', + PageDown: 'PageDown', + ' ': 'Space', + Spacebar: 'Space', + MediaPlayPause: 'MediaPlayPause', + MediaNextTrack: 'MediaNextTrack', + MediaPreviousTrack: 'MediaPreviousTrack', + MediaStop: 'MediaStop' +}; + +function resolveMainKey(event: KeyboardEvent): string | null { + if (event.isComposing || modifierOnlyKeys.has(event.key)) { + return null; + } + + const { code, key } = event; + + if (/^Key[A-Z]$/.test(code)) { + return code.slice(3); + } + + if (/^Digit[0-9]$/.test(code)) { + return code.slice(5); + } + + if (/^Numpad[0-9]$/.test(code)) { + return code.slice(6); + } + + if (/^F([1-9]|1\d|2[0-4])$/.test(code)) { + return code; + } + + if (code === 'NumpadAdd') { + return 'Plus'; + } + + if (code === 'NumpadSubtract' || code === 'Minus') { + return 'Minus'; + } + + if (code === 'Equal' && event.shiftKey) { + return 'Plus'; + } + + if (key === '+' || key === '=') { + return 'Plus'; + } + + if (key === '-' || key === '_') { + return 'Minus'; + } + + if (keyAliases[key]) { + return keyAliases[key]; + } + + if (/^[A-Za-z0-9]$/.test(key)) { + return key.toUpperCase(); + } + + return null; +} + +export function keyboardEventToAccelerator(event: KeyboardEvent): string | null { + const mainKey = resolveMainKey(event); + if (!mainKey) { + return null; + } + + const parts: string[] = []; + + if (event.ctrlKey || event.metaKey) { + parts.push('CommandOrControl'); + } + if (event.altKey) { + parts.push('Alt'); + } + if (event.shiftKey) { + parts.push('Shift'); + } + + return normalizeShortcutAccelerator([...parts, mainKey].join('+')); +} + +export function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + if (target.isContentEditable) { + return true; + } + + const tagName = target.tagName; + if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') { + return true; + } + + return Boolean(target.closest('[contenteditable="true"]')); +} diff --git a/src/shared/shortcuts.ts b/src/shared/shortcuts.ts new file mode 100644 index 0000000..bb01744 --- /dev/null +++ b/src/shared/shortcuts.ts @@ -0,0 +1,368 @@ +export const shortcutActionOrder = [ + 'togglePlay', + 'prevPlay', + 'nextPlay', + 'volumeUp', + 'volumeDown', + 'toggleFavorite', + 'toggleWindow' +] as const; + +export type ShortcutAction = (typeof shortcutActionOrder)[number]; +export type ShortcutScope = 'global' | 'app'; + +export type ShortcutConfig = { + key: string; + enabled: boolean; + scope: ShortcutScope; +}; + +export type ShortcutsConfig = Record; + +export type ShortcutPlatform = 'darwin' | 'win32' | 'linux' | 'web'; + +export type ShortcutConflict = { + key: string; + scope: ShortcutScope; + actions: ShortcutAction[]; +}; + +type ShortcutModifier = 'CommandOrControl' | 'Alt' | 'Shift'; + +const shortcutModifierOrder: ShortcutModifier[] = ['CommandOrControl', 'Alt', 'Shift']; + +const shortcutScopeDefaults: Record = { + togglePlay: 'global', + prevPlay: 'global', + nextPlay: 'global', + volumeUp: 'app', + volumeDown: 'app', + toggleFavorite: 'app', + toggleWindow: 'global' +}; + +const shortcutKeyDefaults: Record = { + togglePlay: 'CommandOrControl+Alt+P', + prevPlay: 'CommandOrControl+Alt+Left', + nextPlay: 'CommandOrControl+Alt+Right', + volumeUp: 'CommandOrControl+Alt+Up', + volumeDown: 'CommandOrControl+Alt+Down', + toggleFavorite: 'CommandOrControl+Alt+L', + toggleWindow: 'CommandOrControl+Alt+Shift+M' +}; + +const modifierAliases: Record = { + commandorcontrol: 'CommandOrControl', + cmdorctrl: 'CommandOrControl', + cmd: 'CommandOrControl', + command: 'CommandOrControl', + control: 'CommandOrControl', + ctrl: 'CommandOrControl', + meta: 'CommandOrControl', + super: 'CommandOrControl', + win: 'CommandOrControl', + windows: 'CommandOrControl', + alt: 'Alt', + option: 'Alt', + shift: 'Shift' +}; + +const namedKeyAliases: Record = { + left: 'Left', + arrowleft: 'Left', + right: 'Right', + arrowright: 'Right', + up: 'Up', + arrowup: 'Up', + down: 'Down', + arrowdown: 'Down', + esc: 'Escape', + escape: 'Escape', + enter: 'Enter', + return: 'Enter', + tab: 'Tab', + space: 'Space', + spacebar: 'Space', + ' ': 'Space', + backspace: 'Backspace', + delete: 'Delete', + del: 'Delete', + insert: 'Insert', + ins: 'Insert', + home: 'Home', + end: 'End', + pageup: 'PageUp', + pagedown: 'PageDown', + plus: 'Plus', + '+': 'Plus', + equal: 'Plus', + '=': 'Plus', + minus: 'Minus', + '-': 'Minus', + mediaplaypause: 'MediaPlayPause', + mediaplay: 'MediaPlayPause', + medianexttrack: 'MediaNextTrack', + mediaprevioustrack: 'MediaPreviousTrack', + mediastop: 'MediaStop' +}; + +const allowedNamedKeys = new Set([ + 'Left', + 'Right', + 'Up', + 'Down', + 'Escape', + 'Enter', + 'Tab', + 'Space', + 'Backspace', + 'Delete', + 'Insert', + 'Home', + 'End', + 'PageUp', + 'PageDown', + 'Plus', + 'Minus', + 'MediaPlayPause', + 'MediaNextTrack', + 'MediaPreviousTrack', + 'MediaStop' +]); + +const functionKeyRegExp = /^F([1-9]|1\d|2[0-4])$/i; + +const shortcutActionGroups: Record<'playback' | 'sound' | 'window', ShortcutAction[]> = { + playback: ['togglePlay', 'prevPlay', 'nextPlay'], + sound: ['volumeUp', 'volumeDown', 'toggleFavorite'], + window: ['toggleWindow'] +}; + +export const shortcutGroups = shortcutActionGroups; + +const sharedReservedAccelerators = ['CommandOrControl+Shift+I', 'F5']; + +const platformReservedAccelerators: Record, string[]> = { + darwin: ['CommandOrControl+Q', 'CommandOrControl+W', 'CommandOrControl+H'], + win32: ['Alt+F4'], + linux: ['Alt+F4'] +}; + +function normalizeModifierToken(token: string): ShortcutModifier | null { + return modifierAliases[token.trim().toLowerCase()] ?? null; +} + +function normalizeKeyToken(token: string): string | null { + const normalizedToken = token.trim(); + if (!normalizedToken) { + return null; + } + + if (normalizedToken.length === 1 && /[A-Za-z0-9]/.test(normalizedToken)) { + return normalizedToken.toUpperCase(); + } + + const functionKeyMatch = normalizedToken.match(functionKeyRegExp); + if (functionKeyMatch) { + return `F${functionKeyMatch[1]}`; + } + + const aliasKey = namedKeyAliases[normalizedToken.toLowerCase()]; + if (aliasKey) { + return aliasKey; + } + + if (allowedNamedKeys.has(normalizedToken)) { + return normalizedToken; + } + + return null; +} + +function createDefaultShortcutConfig(action: ShortcutAction): ShortcutConfig { + return { + key: shortcutKeyDefaults[action], + enabled: true, + scope: shortcutScopeDefaults[action] + }; +} + +export function createDefaultShortcuts(): ShortcutsConfig { + return shortcutActionOrder.reduce((result, action) => { + result[action] = createDefaultShortcutConfig(action); + return result; + }, {} as ShortcutsConfig); +} + +export const defaultShortcuts = createDefaultShortcuts(); + +export function normalizeShortcutAccelerator(raw: string): string | null { + if (!raw || typeof raw !== 'string') { + return null; + } + + const segments = raw + .split('+') + .map((item) => item.trim()) + .filter(Boolean); + + if (segments.length === 0) { + return null; + } + + const modifiers = new Set(); + let mainKey: string | null = null; + + for (const segment of segments) { + const modifier = normalizeModifierToken(segment); + if (modifier) { + modifiers.add(modifier); + continue; + } + + const normalizedKey = normalizeKeyToken(segment); + if (!normalizedKey || mainKey) { + return null; + } + mainKey = normalizedKey; + } + + if (!mainKey) { + return null; + } + + const orderedModifiers = shortcutModifierOrder.filter((modifier) => modifiers.has(modifier)); + return [...orderedModifiers, mainKey].join('+'); +} + +function normalizeShortcutScope(scope: unknown, action: ShortcutAction): ShortcutScope { + return scope === 'global' || scope === 'app' ? scope : shortcutScopeDefaults[action]; +} + +function normalizeShortcutConfig(action: ShortcutAction, value: unknown): ShortcutConfig { + if (typeof value === 'string') { + const normalizedKey = normalizeShortcutAccelerator(value); + return { + key: normalizedKey ?? shortcutKeyDefaults[action], + enabled: true, + scope: shortcutScopeDefaults[action] + }; + } + + if (!value || typeof value !== 'object') { + return createDefaultShortcutConfig(action); + } + + const config = value as Partial; + const normalizedKey = normalizeShortcutAccelerator(config.key ?? ''); + + return { + key: normalizedKey ?? shortcutKeyDefaults[action], + enabled: config.enabled !== false, + scope: normalizeShortcutScope(config.scope, action) + }; +} + +export function normalizeShortcutsConfig(input: unknown): ShortcutsConfig { + const result = createDefaultShortcuts(); + + if (!input || typeof input !== 'object') { + return result; + } + + const rawConfig = input as Record; + + shortcutActionOrder.forEach((action) => { + result[action] = normalizeShortcutConfig(action, rawConfig[action]); + }); + + return result; +} + +export function getShortcutConflicts(shortcuts: ShortcutsConfig): ShortcutConflict[] { + const shortcutBuckets = new Map(); + + shortcutActionOrder.forEach((action) => { + const config = shortcuts[action]; + if (!config.enabled) { + return; + } + + const normalizedKey = normalizeShortcutAccelerator(config.key); + if (!normalizedKey) { + return; + } + + const bucketKey = `${config.scope}::${normalizedKey}`; + const actions = shortcutBuckets.get(bucketKey) ?? []; + actions.push(action); + shortcutBuckets.set(bucketKey, actions); + }); + + const conflicts: ShortcutConflict[] = []; + + shortcutBuckets.forEach((actions, key) => { + if (actions.length < 2) { + return; + } + + const [scope, accelerator] = key.split('::') as [ShortcutScope, string]; + conflicts.push({ + key: accelerator, + scope, + actions + }); + }); + + return conflicts; +} + +export function getReservedAccelerators(platform: ShortcutPlatform): string[] { + if (platform === 'web') { + return [...sharedReservedAccelerators]; + } + + return [...sharedReservedAccelerators, ...platformReservedAccelerators[platform]]; +} + +export function formatShortcutForDisplay(shortcut: string, platform: ShortcutPlatform): string { + const accelerator = normalizeShortcutAccelerator(shortcut) ?? shortcut; + const isMac = platform === 'darwin'; + + return accelerator + .split('+') + .map((segment) => { + if (segment === 'CommandOrControl') { + return isMac ? 'Cmd' : 'Ctrl'; + } + if (segment === 'Alt') { + return isMac ? 'Option' : 'Alt'; + } + if (segment === 'Shift') { + return 'Shift'; + } + if (segment === 'Plus') { + return '+'; + } + if (segment === 'Minus') { + return '-'; + } + return segment; + }) + .join(' + '); +} + +export function hasShortcutAction(action: string): action is ShortcutAction { + return shortcutActionOrder.includes(action as ShortcutAction); +} + +export function isModifierOnlyShortcut(shortcut: string): boolean { + const normalized = normalizeShortcutAccelerator(shortcut); + if (!normalized) { + return true; + } + + const segments = normalized.split('+'); + const mainKey = segments[segments.length - 1]; + return shortcutModifierOrder.includes(mainKey as ShortcutModifier); +} diff --git a/tsconfig.node.json b/tsconfig.node.json index be3e6fc..b8abce7 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -4,6 +4,7 @@ "electron.vite.config.*", "src/main/**/*", "src/preload/**/*", + "src/shared/**/*", "src/i18n/**/*" ], "compilerOptions": { @@ -27,4 +28,4 @@ "src/i18n/*" ] } -} \ No newline at end of file +} diff --git a/tsconfig.web.json b/tsconfig.web.json index c964fc6..3761df4 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -4,6 +4,7 @@ "src/preload/*.d.ts", "src/renderer/**/*", "src/renderer/**/*.vue", + "src/shared/**/*", "src/i18n/**/*", "src/main/modules/config.ts", "src/main/modules/shortcuts.ts"