Skip to content

feat: 快捷键 - #13

Merged
imsyy merged 6 commits into
devfrom
feat/hotkeys
May 5, 2026
Merged

imsyy merged 6 commits into
devfrom
feat/hotkeys

Conversation

@imsyy

@imsyy imsyy commented Apr 30, 2026

Copy link
Copy Markdown
Member

No description provided.

Copilot AI review requested due to automatic review settings April 30, 2026 17:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 });

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里 watch(() => useHotkeyStore().bindings, ...) 的 getter 没有读取到 .value,不会建立任何响应式依赖,后续 bindings 变化时 recompile() 很可能不会触发。建议直接 watch(useHotkeyStore().bindings, ...) 或 watch(() => useHotkeyStore().bindings.value, ...)(配合 deep: true)。

Suggested change
stopWatchBindings = watch(() => useHotkeyStore().bindings, recompile, { deep: true });
stopWatchBindings = watch(() => useHotkeyStore().bindings.value, recompile, { deep: true });

Copilot uses AI. Check for mistakes.
Comment thread src/main.ts Outdated
Comment on lines +43 to +44
await useHotkeyStore().init();
installHotkeyManager();

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里把 useHotkeyStore().init() 放在 app.mount() 之前且未做 try/catch;一旦 hotkey IPC 报错/超时(或主进程未注册该 handler),Promise reject 会导致应用完全不挂载且没有兜底日志。建议捕获错误并 fail-open(记录错误后继续 mount),避免快捷键模块影响启动可用性。

Suggested change
await useHotkeyStore().init();
installHotkeyManager();
try {
await useHotkeyStore().init();
installHotkeyManager();
} catch (error) {
console.error("[main] 快捷键初始化失败,已跳过并继续启动应用", error);
}

Copilot uses AI. Check for mistakes.
Comment thread src/stores/hotkey.ts Outdated
Comment on lines +30 to +33
window.api.hotkey.onConflicts((list) => {
conflicts.value = list;
});
initialized.value = true;

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

init() 内订阅 window.api.hotkey.onConflicts(...) 但没有保存返回的 unsubscribe。当前 store 通过 initialized 避免重复订阅,但在 HMR / store dispose 场景仍可能累积监听器。建议保存 unsubscribe 并在再次 init 或 store dispose 时调用清理。

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +47
const recorder = useHotkeyRecorder({
isMac: isMac,
requireModifier: false,
onConfirm: async (accel) => {
const target = recordingTarget.value;

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hotkey 录入器支持 requireModifier 用于限制 global 录入不允许单键(避免在其他应用打字时被全局拦截),但这里统一传了 requireModifier: false,会允许把例如 A/Space 这种单键设置为全局快捷键(只要 probe 通过就会在系统层注册)。建议在 scope === "global" 时额外校验必须包含至少一个 modifier(或为 global 单独创建一个 recorder 配置)。

Copilot uses AI. Check for mistakes.
Comment on lines +303 to +329
"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": "切换随机播放"
}
},

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

设置搜索会读取 t(settings.${item.key}.label) / .description(见 SettingsSearch.vue),但这里新增了 settings.hotkeys 分组后没有提供 settings.hotkeys.label/description,导致搜索结果/文案出现缺失 key。建议在 settings.hotkeys 下补充 label 与 description 字段(或将 schema item key 改为不与分组对象冲突的名字)。

Copilot uses AI. Check for mistakes.
Comment on lines +303 to +329
"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"
}
},

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

设置搜索会读取 t(settings.${item.key}.label) / .description(见 SettingsSearch.vue),但这里新增了 settings.hotkeys 分组后没有提供 settings.hotkeys.label/description,导致搜索结果/文案出现缺失 key。建议在 settings.hotkeys 下补充 label 与 description 字段(或将 schema item key 改为不与分组对象冲突的名字)。

Copilot uses AI. Check for mistakes.
Comment thread electron/preload/index.ts
Comment on lines +300 to +312
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),

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

根据仓库约定(CLAUDE.md:preload 的事件订阅需在重复注册前 ipcRenderer.removeAllListeners() 以避免 HMR 监听器累积),这里新增的 hotkey.onTrigger/onConflicts 直接复用 subscribe() 会在热更新或重复初始化时堆叠同 channel 监听。建议为 hotkey 事件使用带 removeAllListeners(channel) 的订阅方式,或在 subscribe() 内统一处理。

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +44
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 });
}

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recompile() 里把 store.bindings 当成普通对象使用了,但在 Pinia setup store 里 bindings 是 ref<HotkeyBindingsMap>。这里 Object.keys(store.bindings)/store.bindings[id] 实际会拿到 Ref 包装对象,导致编译表为空或直接类型报错。建议改为使用 store.bindings.value(或在 store 内返回 reactive/toRef 后再使用)。

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings May 5, 2026 15:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 23 changed files in this pull request and generated 5 comments.

Comment on lines +70 to +76
} 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;
}
Comment on lines +230 to +236
<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"
Comment on lines +162 to +170
/** 检查全局是否被占用 */
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";
Comment thread src/core/hotkey/registry.ts Outdated
Comment on lines +82 to +88
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);
Comment on lines +124 to +128
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);
@imsyy
imsyy merged commit 34822a1 into dev May 5, 2026
3 checks passed
@imsyy
imsyy deleted the feat/hotkeys branch June 5, 2026 16:16
roarrabbit pushed a commit to roarrabbit/SPlayer-Next that referenced this pull request Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants