Repository navigation
feat: 快捷键 - #13
feat: 快捷键#13
Conversation
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
There was a problem hiding this comment.
Pull request overview
该 PR 为 SPlayer-Next 引入“快捷键”系统:主进程负责全局快捷键注册与持久化,渲染端负责应用内快捷键匹配、动作派发与设置页录入 UI,从而实现可配置的 in-app/global 快捷键能力,并在设置中提供管理入口。
Changes:
- 新增主进程全局快捷键服务(globalShortcut 注册/冲突上报/触发广播)与对应 IPC(
hotkey:*)。 - 新增渲染端快捷键管理器(in-app keydown 匹配 + 主进程触发回调 dispatch)与快捷键录入/设置 UI。
- 扩展 shared 层类型与默认值(hotkeys 配置写入
SystemConfig/默认 settings),并补充中英文 i18n 文案与设置 schema 入口。
Reviewed changes
Copilot reviewed 21 out of 23 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/config.ts | 新增运行环境/平台判断工具(isMac/isWin/isElectron 等)供快捷键显示/解析使用 |
| src/stores/hotkey.ts | 新增快捷键独立 Pinia store,通过 IPC 拉取/更新配置与冲突列表 |
| src/settings/schema.ts | 设置页新增“快捷键”分类与自定义组件入口 |
| src/main.ts | 启动时初始化 hotkey store,并安装渲染端快捷键管理器 |
| src/i18n/locales/zh-CN.json | 新增快捷键相关中文文案与设置分组/分区标题 |
| src/i18n/locales/en-US.json | 新增快捷键相关英文文案与设置分组/分区标题 |
| src/core/hotkey/registry.ts | 新增渲染端动作注册表(HotkeyActionId → handler) |
| src/core/hotkey/recorder.ts | 新增录入态键序捕获 composable(KeyboardEvent → Accelerator) |
| src/core/hotkey/manager.ts | 新增渲染端快捷键管理器(编译绑定表、in-app 匹配、订阅 main trigger) |
| src/components/ui/SInput.vue | SInput 支持 readonly 与 error 状态展示(用于快捷键录入输入框) |
| src/components/settings/custom/HotkeyConfig.vue | 新增快捷键设置 UI(录入、冲突提示、重置、全局开关) |
| shared/utils/accelerator.ts | 新增 Accelerator 解析/规范化/匹配/显示/录入转换工具(主/渲染共用) |
| shared/types/settings.ts | SystemConfig 新增 hotkeys: HotkeyConfig 字段 |
| shared/types/hotkey.ts | 新增快捷键系统类型与 preload API 约定(HotkeyApi 等) |
| shared/defaults/settings.ts | 默认 SystemConfig 增加 hotkeys 默认值 |
| shared/defaults/hotkeys.ts | 新增快捷键动作清单 HOTKEY_ACTIONS 与默认绑定/默认配置 |
| electron/preload/index.ts | preload 暴露 window.api.hotkey(getAll/set/reset/probe/onTrigger/onConflicts) |
| electron/preload/index.d.ts | Window API 类型补充 hotkey: HotkeyApi |
| electron/main/services/globalHotkey.ts | 新增主进程全局快捷键服务:注册/重注册/冲突广播/探测 |
| electron/main/ipc/index.ts | 注册 hotkey IPC handlers |
| electron/main/ipc/hotkey.ts | 新增 hotkey IPC handlers(getAll/set/reset/setGlobalEnabled/probe/getConflicts) |
| electron/main/core/index.ts | 应用初始化时调用 initGlobalHotkey() 注册全局快捷键 |
| components.d.ts | 自动生成组件声明更新(包含 HotkeyConfig 组件等) |
| installed = true; | ||
| buildRegistry(); | ||
| recompile(); | ||
| stopWatchBindings = watch(() => useHotkeyStore().bindings, recompile, { deep: true }); |
There was a problem hiding this comment.
这里 watch(() => useHotkeyStore().bindings, ...) 的 getter 没有读取到 .value,不会建立任何响应式依赖,后续 bindings 变化时 recompile() 很可能不会触发。建议直接 watch(useHotkeyStore().bindings, ...) 或 watch(() => useHotkeyStore().bindings.value, ...)(配合 deep: true)。
| stopWatchBindings = watch(() => useHotkeyStore().bindings, recompile, { deep: true }); | |
| stopWatchBindings = watch(() => useHotkeyStore().bindings.value, recompile, { deep: true }); |
| await useHotkeyStore().init(); | ||
| installHotkeyManager(); |
There was a problem hiding this comment.
这里把 useHotkeyStore().init() 放在 app.mount() 之前且未做 try/catch;一旦 hotkey IPC 报错/超时(或主进程未注册该 handler),Promise reject 会导致应用完全不挂载且没有兜底日志。建议捕获错误并 fail-open(记录错误后继续 mount),避免快捷键模块影响启动可用性。
| await useHotkeyStore().init(); | |
| installHotkeyManager(); | |
| try { | |
| await useHotkeyStore().init(); | |
| installHotkeyManager(); | |
| } catch (error) { | |
| console.error("[main] 快捷键初始化失败,已跳过并继续启动应用", error); | |
| } |
| window.api.hotkey.onConflicts((list) => { | ||
| conflicts.value = list; | ||
| }); | ||
| initialized.value = true; |
There was a problem hiding this comment.
init() 内订阅 window.api.hotkey.onConflicts(...) 但没有保存返回的 unsubscribe。当前 store 通过 initialized 避免重复订阅,但在 HMR / store dispose 场景仍可能累积监听器。建议保存 unsubscribe 并在再次 init 或 store dispose 时调用清理。
| const recorder = useHotkeyRecorder({ | ||
| isMac: isMac, | ||
| requireModifier: false, | ||
| onConfirm: async (accel) => { | ||
| const target = recordingTarget.value; |
There was a problem hiding this comment.
Hotkey 录入器支持 requireModifier 用于限制 global 录入不允许单键(避免在其他应用打字时被全局拦截),但这里统一传了 requireModifier: false,会允许把例如 A/Space 这种单键设置为全局快捷键(只要 probe 通过就会在系统层注册)。建议在 scope === "global" 时额外校验必须包含至少一个 modifier(或为 global 单独创建一个 recorder 配置)。
| "hotkeys": { | ||
| "globalEnabled": "开启全局快捷键", | ||
| "globalEnabledHint": "应用未聚焦时也可触发,可能与系统或其他应用冲突", | ||
| "colAction": "动作", | ||
| "colInApp": "应用内", | ||
| "colGlobal": "全局", | ||
| "unbound": "未绑定", | ||
| "recording": "请按下快捷键", | ||
| "resetRow": "恢复默认", | ||
| "resetAll": "全部恢复默认", | ||
| "resetAllHint": "将每个动作的快捷键还原为默认值", | ||
| "confirmResetTitle": "确认恢复默认?", | ||
| "confirmResetDesc": "所有快捷键将被重置,已自定义的绑定会被覆盖。", | ||
| "duplicateWith": "与「{action}」冲突", | ||
| "osOccupied": "该组合键已被系统或其他应用占用", | ||
| "actions": { | ||
| "togglePlay": "播放 / 暂停", | ||
| "next": "下一首", | ||
| "prev": "上一首", | ||
| "seekForward": "快进 5 秒", | ||
| "seekBack": "快退 5 秒", | ||
| "volumeUp": "音量增加", | ||
| "volumeDown": "音量减小", | ||
| "cycleRepeat": "切换循环模式", | ||
| "toggleShuffle": "切换随机播放" | ||
| } | ||
| }, |
There was a problem hiding this comment.
设置搜索会读取 t(settings.${item.key}.label) / .description(见 SettingsSearch.vue),但这里新增了 settings.hotkeys 分组后没有提供 settings.hotkeys.label/description,导致搜索结果/文案出现缺失 key。建议在 settings.hotkeys 下补充 label 与 description 字段(或将 schema item key 改为不与分组对象冲突的名字)。
| "hotkeys": { | ||
| "globalEnabled": "Enable global shortcuts", | ||
| "globalEnabledHint": "Fire even when the app is not focused. May conflict with the OS or other apps.", | ||
| "colAction": "Action", | ||
| "colInApp": "In-App", | ||
| "colGlobal": "Global", | ||
| "unbound": "Unbound", | ||
| "recording": "Press a shortcut", | ||
| "resetRow": "Reset to default", | ||
| "resetAll": "Reset all", | ||
| "resetAllHint": "Restore every action's shortcut to its default", | ||
| "confirmResetTitle": "Reset to defaults?", | ||
| "confirmResetDesc": "All shortcuts will be reset and any customizations will be overwritten.", | ||
| "duplicateWith": "Conflicts with \"{action}\"", | ||
| "osOccupied": "Already taken by the OS or another app", | ||
| "actions": { | ||
| "togglePlay": "Play / Pause", | ||
| "next": "Next track", | ||
| "prev": "Previous track", | ||
| "seekForward": "Seek forward 5s", | ||
| "seekBack": "Seek back 5s", | ||
| "volumeUp": "Volume up", | ||
| "volumeDown": "Volume down", | ||
| "cycleRepeat": "Cycle repeat mode", | ||
| "toggleShuffle": "Toggle shuffle" | ||
| } | ||
| }, |
There was a problem hiding this comment.
设置搜索会读取 t(settings.${item.key}.label) / .description(见 SettingsSearch.vue),但这里新增了 settings.hotkeys 分组后没有提供 settings.hotkeys.label/description,导致搜索结果/文案出现缺失 key。建议在 settings.hotkeys 下补充 label 与 description 字段(或将 schema item key 改为不与分组对象冲突的名字)。
| hotkey: { | ||
| getAll: () => ipcRenderer.invoke("hotkey:getAll"), | ||
| set: (id: HotkeyActionId, binding: HotkeyBinding) => | ||
| ipcRenderer.invoke("hotkey:set", id, binding), | ||
| reset: (id?: HotkeyActionId) => ipcRenderer.invoke("hotkey:reset", id), | ||
| setGlobalEnabled: (enabled: boolean) => | ||
| ipcRenderer.invoke("hotkey:setGlobalEnabled", enabled), | ||
| probe: (accelerator: string) => ipcRenderer.invoke("hotkey:probe", accelerator), | ||
| getConflicts: () => ipcRenderer.invoke("hotkey:getConflicts"), | ||
| onTrigger: (callback: (id: HotkeyActionId) => void) => | ||
| subscribe<HotkeyActionId>("hotkey:trigger", callback), | ||
| onConflicts: (callback: (conflicts: HotkeyConflict[]) => void) => | ||
| subscribe<HotkeyConflict[]>("hotkey:conflicts", callback), |
There was a problem hiding this comment.
根据仓库约定(CLAUDE.md:preload 的事件订阅需在重复注册前 ipcRenderer.removeAllListeners() 以避免 HMR 监听器累积),这里新增的 hotkey.onTrigger/onConflicts 直接复用 subscribe() 会在热更新或重复初始化时堆叠同 channel 监听。建议为 hotkey 事件使用带 removeAllListeners(channel) 的订阅方式,或在 subscribe() 内统一处理。
| const store = useHotkeyStore(); | ||
| const next: typeof compiled = []; | ||
| const ids = Object.keys(store.bindings) as HotkeyActionId[]; | ||
| for (const id of ids) { | ||
| const accel = store.bindings[id]?.inApp; | ||
| if (!accel) continue; | ||
| const parsed = parseAccelerator(accel, isMac); | ||
| if (parsed) next.push({ id, parsed }); | ||
| } |
There was a problem hiding this comment.
recompile() 里把 store.bindings 当成普通对象使用了,但在 Pinia setup store 里 bindings 是 ref<HotkeyBindingsMap>。这里 Object.keys(store.bindings)/store.bindings[id] 实际会拿到 Ref 包装对象,导致编译表为空或直接类型报错。建议改为使用 store.bindings.value(或在 store 内返回 reactive/toRef 后再使用)。
| } else { | ||
| const ok = await hotkey.probe(accel); | ||
| if (!ok) { | ||
| errorFor.value = { id: target.id, scope: "global", kind: "osOccupied" }; | ||
| toast.error(t("settings.hotkeys.osOccupied")); | ||
| return; | ||
| } |
| <SInput | ||
| readonly | ||
| :model-value="valueOf(action.id, 'inApp')" | ||
| :placeholder="placeholderOf(action.id, 'inApp')" | ||
| :status="statusOf(action.id, 'inApp')" | ||
| @focus="startRecord(action.id, 'inApp')" | ||
| @blur="stopRecord" |
| /** 检查全局是否被占用 */ | ||
| const isGlobalOsOccupied = (id: HotkeyActionId): boolean => | ||
| hotkey.conflicts.some((c) => c.id === id && c.scope === "global" && c.reason === "os-occupied"); | ||
|
|
||
| /** 检查状态 */ | ||
| const statusOf = (id: HotkeyActionId, scope: Scope): "default" | "error" => { | ||
| if (errorFor.value && errorFor.value.id === id && errorFor.value.scope === scope) return "error"; | ||
| if (scope === "global" && hotkey.globalEnabled && isGlobalOsOccupied(id)) return "error"; | ||
| return "default"; |
| export const dispatch = (id: HotkeyActionId): void => { | ||
| const fn = handlers.get(id); | ||
| if (!fn) return; | ||
| try { | ||
| fn(); | ||
| } catch (err) { | ||
| console.error(`[hotkey] dispatch ${id} failed`, err); |
| export const setBinding = (id: HotkeyActionId, binding: HotkeyBinding): HotkeyConfig => { | ||
| const config = readConfig(); | ||
| const norm = (s: string | null): string | null => (s ? normalizeAccelerator(s) || null : null); | ||
| config.bindings[id] = { inApp: norm(binding.inApp), global: norm(binding.global) }; | ||
| return writeConfig(config); |
No description provided.