From e1a0bf4db0a0f8327b493c3e88c75e4c67a1596f Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 11 Sep 2026 03:46:15 -0700 Subject: [PATCH] feat(overlay): discover unclaimed mpv key bindings - Import non-conflicting mpv bindings for the overlay session - Preserve SubMiner precedence and document discovery behavior --- changes/mpv-overlay-bindings.md | 4 + docs-site/configuration.md | 5 + docs-site/shortcuts.md | 17 +++ docs/architecture/domains.md | 11 ++ src/core/services/ipc.test.ts | 15 +++ src/core/services/ipc.ts | 8 ++ src/core/services/session-bindings.ts | 8 +- src/main.ts | 12 ++ src/main/dependencies.ts | 2 + src/main/runtime/mpv-input-bindings.test.ts | 41 +++++++ src/main/runtime/mpv-input-bindings.ts | 31 +++++ src/preload.ts | 1 + src/renderer/handlers/keyboard.test.ts | 76 +++++++++++- src/renderer/handlers/keyboard.ts | 31 +++++ .../handlers/mpv-input-forwarding.test.ts | 112 ++++++++++++++++++ src/renderer/handlers/mpv-input-forwarding.ts | 91 ++++++++++++++ src/shared/ipc/contracts.ts | 1 + src/shared/mpv-input-bindings.test.ts | 90 ++++++++++++++ src/shared/mpv-input-bindings.ts | 98 +++++++++++++++ src/types/runtime.ts | 2 + src/types/session-bindings.ts | 5 + 21 files changed, 656 insertions(+), 5 deletions(-) create mode 100644 changes/mpv-overlay-bindings.md create mode 100644 src/main/runtime/mpv-input-bindings.test.ts create mode 100644 src/main/runtime/mpv-input-bindings.ts create mode 100644 src/renderer/handlers/mpv-input-forwarding.test.ts create mode 100644 src/renderer/handlers/mpv-input-forwarding.ts create mode 100644 src/shared/mpv-input-bindings.test.ts create mode 100644 src/shared/mpv-input-bindings.ts diff --git a/changes/mpv-overlay-bindings.md b/changes/mpv-overlay-bindings.md new file mode 100644 index 00000000..f22597b5 --- /dev/null +++ b/changes/mpv-overlay-bindings.md @@ -0,0 +1,4 @@ +type: added +area: overlay + +- The overlay discovers non-conflicting keyboard bindings from mpv defaults, input.conf, and loaded scripts in the background. SubMiner controls and explicitly disabled bindings take precedence. Discovered bindings stay session-only and do not appear in SubMiner's help menu. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 32d685b0..edefdf5e 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -635,6 +635,11 @@ See `config.example.jsonc` for detailed configuration options and more examples. **Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.) +Supported, unclaimed single-key keyboard bindings from the connected mpv session are also available +in the overlay automatically. Configured SubMiner bindings, including `null` entries, +take precedence. See [mpv binding discovery](/shortcuts#automatic-mpv-bindings) for session refresh +behavior and limitations. + Subtitle delay commands (`sub-delay`, `sub-step`) show a native mpv OSD notification after the command runs. Subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) show playback feedback through the configured notification surface. **See `config.example.jsonc`** for more keybinding examples and configuration options. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index ca35051b..28006ca0 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -169,3 +169,20 @@ The `keybindings` array overrides or extends the overlay's built-in key handling Mouse keybinding names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Both `shortcuts`, `keybindings`, and `subtitleSidebar` are [hot-reloadable](/configuration#hot-reload-behavior) - changes take effect without restarting SubMiner. + +### Automatic mpv bindings + +The overlay also discovers supported single-key keyboard bindings from the connected mpv session, +including `input.conf`, mpv defaults, and loaded scripts. When SubMiner does not handle a +key, it forwards the key to mpv to run the current binding. SubMiner shortcuts and +configured bindings take precedence, including entries explicitly disabled with +`"command": null`. Text entry, overlay menus, and Yomitan popups do not forward these +fallback keys. + +Discovery runs in the background at startup, again after a short delay for scripts, +when the overlay regains focus, and when SubMiner's binding configuration reloads. +Bindings added later may require refocusing the overlay. Imported bindings stay in +memory for the session and do not appear in SubMiner's help menu or modify its config. +Supported keys include characters, common navigation keys, and F1 through F24, with +modifiers. Mouse bindings, keypad-specific and media keys, key sequences, and full +navigation of interactive mpv script menus are not imported. If discovery is unavailable, SubMiner's configured controls keep working. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index 99fc30ab..f0d131f1 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -39,6 +39,17 @@ Read when: you need to find the owner module for a behavior or test surface ## Shared Contract Entry Points +Automatic mpv keyboard discovery uses the `get-mpv-input-bindings` IPC request and +`MpvInputBindingsSnapshot` in `src/types/session-bindings.ts`. +`src/main/runtime/mpv-input-bindings.ts` queries the connected player and preserves +configured keys, including disabled entries. `src/shared/mpv-input-bindings.ts` +validates discovered keys and translates browser input. The renderer's +`handlers/mpv-input-forwarding.ts` keeps the session lookup, coalesces asynchronous +refreshes, and releases held keys on blur or disposal. `handlers/keyboard.ts` runs +this fallback after SubMiner controls and refreshes on startup, a delayed startup +pass, focus, and binding reload. Discovery does not enter compiled session bindings, +the plugin artifact, persistent config, or session help. + The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey` identifies the media and subtitle source so renderer selections are invalidated on source changes, including changes whose cue text and timings are identical. Native selection and clean clipboard diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 8fcfb2e6..8c2ddeeb 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -1323,3 +1323,18 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn assert.deepEqual(calls, [21355]); assert.deepEqual(searches, ['Re:ZERO']); }); + +test('mpv discovery has its own request and does not change session bindings', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const snapshot = { keys: ['r'], blockedKeys: [] }; + registerIpcHandlers( + createRegisterIpcDeps({ getMpvInputBindings: async () => snapshot }), + registrar, + ); + const discovery = handlers.handle.get(IPC_CHANNELS.request.getMpvInputBindings); + const session = handlers.handle.get(IPC_CHANNELS.request.getSessionBindings); + assert.ok(discovery); + assert.ok(session); + assert.deepEqual(await discovery({}), snapshot); + assert.deepEqual(await session({}), []); +}); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 91bbaa45..2a26dac1 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -1,4 +1,5 @@ import electron from 'electron'; +import type { MpvInputBindingsSnapshot } from '../../types/session-bindings'; import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron'; import type { ChangelogSnapshot, @@ -89,6 +90,7 @@ export interface IpcServiceDeps { setMecabEnabled: (enabled: boolean) => void; handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; + getMpvInputBindings?: () => Promise; getSessionBindings?: () => CompiledSessionBinding[]; getConfiguredShortcuts: () => unknown; dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise; @@ -344,6 +346,7 @@ export interface IpcDepsRuntimeOptions { getMecabTokenizer: () => MecabTokenizerLike | null; handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; + getMpvInputBindings?: () => Promise; getSessionBindings?: () => CompiledSessionBinding[]; getConfiguredShortcuts: () => unknown; dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise; @@ -438,6 +441,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService }, handleMpvCommand: options.handleMpvCommand, getKeybindings: options.getKeybindings, + getMpvInputBindings: options.getMpvInputBindings, getSessionBindings: options.getSessionBindings ?? (() => []), getConfiguredShortcuts: options.getConfiguredShortcuts, dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}), @@ -769,6 +773,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return deps.getKeybindings(); }); + ipc.handle(IPC_CHANNELS.request.getMpvInputBindings, () => { + return deps.getMpvInputBindings?.() ?? { keys: [], blockedKeys: [] }; + }); + ipc.handle(IPC_CHANNELS.request.getSessionBindings, () => { return deps.getSessionBindings?.() ?? []; }); diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts index 7bbbc3b6..f2e6e401 100644 --- a/src/core/services/session-bindings.ts +++ b/src/core/services/session-bindings.ts @@ -211,7 +211,7 @@ function parseAccelerator( }; } -function parseDomKeyString( +export function parseSessionBindingKey( key: string, platform: PlatformKeyModel, ): { key: SessionKeySpec | null; message?: string } { @@ -435,7 +435,7 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): { } if (statsToggleKey) { - const parsed = parseDomKeyString(statsToggleKey, input.platform); + const parsed = parseSessionBindingKey(statsToggleKey, input.platform); if (!parsed.key) { warnings.push({ kind: 'unsupported', @@ -462,7 +462,7 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): { } if (statsMarkWatchedKey) { - const parsed = parseDomKeyString(statsMarkWatchedKey, input.platform); + const parsed = parseSessionBindingKey(statsMarkWatchedKey, input.platform); if (!parsed.key) { warnings.push({ kind: 'unsupported', @@ -490,7 +490,7 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): { input.keybindings.forEach((binding, index) => { if (!binding.command) return; - const parsed = parseDomKeyString(binding.key, input.platform); + const parsed = parseSessionBindingKey(binding.key, input.platform); if (!parsed.key) { warnings.push({ kind: 'unsupported', diff --git a/src/main.ts b/src/main.ts index 7a98dbdd..b764cdca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,7 @@ import { } from 'electron'; import { applyControllerConfigUpdate } from './main/controller-config-update.js'; import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open'; +import { readMpvInputBindings } from './main/runtime/mpv-input-bindings'; import { createAniSkipRuntime } from './main/runtime/aniskip-runtime'; import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata'; import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js'; @@ -5859,6 +5860,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ saveSubtitlePosition: (position) => saveSubtitlePosition(position), getMecabTokenizer: () => appState.mecabTokenizer, getKeybindings: () => appState.keybindings, + getMpvInputBindings: () => + readMpvInputBindings({ + getMpvClient: () => appState.mpvClient, + getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [], + platform: + process.platform === 'darwin' + ? 'darwin' + : process.platform === 'win32' + ? 'win32' + : 'linux', + }), getSessionBindings: () => appState.sessionBindings, getConfiguredShortcuts: () => getConfiguredShortcuts(), dispatchSessionAction: (request) => dispatchSessionAction(request), diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 45240187..1756b02c 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -83,6 +83,7 @@ export interface MainIpcRuntimeServiceDepsParams { getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer']; handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand']; getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; + getMpvInputBindings?: IpcDepsRuntimeOptions['getMpvInputBindings']; getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction']; @@ -280,6 +281,7 @@ export function createMainIpcRuntimeServiceDeps( getMecabTokenizer: params.getMecabTokenizer, handleMpvCommand: params.handleMpvCommand, getKeybindings: params.getKeybindings, + getMpvInputBindings: params.getMpvInputBindings, getSessionBindings: params.getSessionBindings, getConfiguredShortcuts: params.getConfiguredShortcuts, dispatchSessionAction: params.dispatchSessionAction, diff --git a/src/main/runtime/mpv-input-bindings.test.ts b/src/main/runtime/mpv-input-bindings.test.ts new file mode 100644 index 00000000..f8f1202c --- /dev/null +++ b/src/main/runtime/mpv-input-bindings.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readMpvInputBindings } from './mpv-input-bindings'; + +test('discovery reads the connected player and preserves configured keys including disabled bindings', async () => { + const client = { + connected: true, + requestProperty: async (name: string) => { + assert.equal(name, 'input-bindings'); + return [{ key: 'r', cmd: 'script-binding replay/run', priority: 1 }]; + }, + }; + assert.deepEqual( + await readMpvInputBindings({ + getMpvClient: () => client, + getConfiguredKeybindings: () => [{ key: 'Ctrl+KeyR', command: null }], + platform: 'linux', + }), + { keys: ['r'], blockedKeys: [{ code: 'KeyR', modifiers: ['ctrl'] }] }, + ); +}); + +test('discovery safely handles unsupported properties and disconnects during a request', async () => { + const client = { + connected: true, + requestProperty: async (): Promise => { + throw new Error('property unavailable'); + }, + }; + const deps = { + getMpvClient: () => client, + getConfiguredKeybindings: () => [], + platform: 'linux', + } satisfies Parameters[0]; + assert.deepEqual((await readMpvInputBindings(deps)).keys, []); + client.requestProperty = async () => { + client.connected = false; + return [{ key: 'r', cmd: 'seek 5', priority: 1 }]; + }; + assert.deepEqual((await readMpvInputBindings(deps)).keys, []); +}); diff --git a/src/main/runtime/mpv-input-bindings.ts b/src/main/runtime/mpv-input-bindings.ts new file mode 100644 index 00000000..ffc88749 --- /dev/null +++ b/src/main/runtime/mpv-input-bindings.ts @@ -0,0 +1,31 @@ +import type { Keybinding } from '../../types'; +import { parseSessionBindingKey } from '../../core/services/session-bindings'; +import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings'; +import type { MpvInputBindingsSnapshot } from '../../types/session-bindings'; + +export async function readMpvInputBindings(deps: { + getMpvClient: () => { + connected: boolean; + requestProperty: (name: string) => Promise; + } | null; + getConfiguredKeybindings: () => Keybinding[]; + platform: 'darwin' | 'win32' | 'linux'; +}): Promise { + const blockedKeys = deps.getConfiguredKeybindings().flatMap((binding) => { + const { key } = parseSessionBindingKey(binding.key, deps.platform); + return key ? [key] : []; + }); + const client = deps.getMpvClient(); + if (!client?.connected) return { keys: [], blockedKeys }; + try { + const value = await client.requestProperty('input-bindings'); + return { + keys: + client === deps.getMpvClient() && client.connected ? parseMpvInputBindingKeys(value) : [], + blockedKeys, + }; + } catch { + // Older mpv versions and disconnected sessions retain SubMiner's controls. + return { keys: [], blockedKeys }; + } +} diff --git a/src/preload.ts b/src/preload.ts index b650aba6..d1aba1b2 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -350,6 +350,7 @@ const electronAPI: ElectronAPI = { getKeybindings: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings), + getMpvInputBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getMpvInputBindings), getSessionBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSessionBindings), getConfiguredShortcuts: (): Promise> => ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 6638680e..03c8e275 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -6,6 +6,7 @@ import test from 'node:test'; import { createKeyboardHandlers } from './keyboard.js'; import { createRendererState } from '../state.js'; import type { CompiledSessionBinding } from '../../types'; +import type { MpvInputBindingsSnapshot } from '../../types/session-bindings'; import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions'; import { compileSessionBindings } from '../../core/services/session-bindings'; import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config'; @@ -115,6 +116,10 @@ function installKeyboardTestGlobals() { const sessionActions: Array<{ actionId: string; payload?: unknown }> = []; const interactionActivations: string[] = []; let sessionBindings: CompiledSessionBinding[] = []; + let getMpvInputBindings: () => Promise = async () => ({ + keys: [], + blockedKeys: [], + }); let getSessionBindingsImpl: () => Promise = async () => sessionBindings; let playbackPausedResponse: boolean | null = false; let statsToggleKey = 'Backquote'; @@ -238,6 +243,7 @@ function installKeyboardTestGlobals() { }, electronAPI: { getKeybindings: async () => [], + getMpvInputBindings: () => getMpvInputBindings(), getSessionBindings: () => getSessionBindingsImpl(), getConfiguredShortcuts: async () => configuredShortcuts, sendMpvCommand: (command: Array) => { @@ -308,6 +314,7 @@ function installKeyboardTestGlobals() { altKey?: boolean; shiftKey?: boolean; repeat?: boolean; + target?: unknown; }): void { const listeners = documentListeners.get('keydown') ?? []; const keyboardEvent = { @@ -319,7 +326,7 @@ function installKeyboardTestGlobals() { shiftKey: event.shiftKey ?? false, repeat: event.repeat ?? false, preventDefault: () => {}, - target: null, + target: event.target ?? null, }; for (const listener of listeners) { listener(keyboardEvent); @@ -369,6 +376,7 @@ function installKeyboardTestGlobals() { } function restore() { + dispatchWindowEvent('beforeunload'); Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); Object.defineProperty(globalThis, 'MutationObserver', { @@ -421,6 +429,9 @@ function installKeyboardTestGlobals() { setConfiguredShortcuts: (value: typeof configuredShortcuts) => { configuredShortcuts = value; }, + setGetMpvInputBindings: (value: typeof getMpvInputBindings) => { + getMpvInputBindings = value; + }, setSessionBindings: (value: CompiledSessionBinding[]) => { sessionBindings = value; }, @@ -2307,3 +2318,66 @@ test('mark-watched keybinding does not send mpv commands when no active session' testGlobals.restore(); } }); + +test('discovered mpv keys only run after SubMiner controls and stay out of session help', async () => { + const { handlers, testGlobals, ctx } = createKeyboardHandlerHarness(); + try { + testGlobals.setGetMpvInputBindings(async () => ({ + keys: ['r', 'SPACE', 'y', 'v'], + blockedKeys: [], + })); + testGlobals.setSessionBindings([ + { + sourcePath: 'keybindings[0].key', + originalKey: 'Space', + key: { code: 'Space', modifiers: [] }, + actionType: 'mpv-command', + command: ['cycle', 'pause'], + }, + ]); + await handlers.setupMpvInputForwarding(); + await wait(0); + testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR' }); + testGlobals.dispatchKeydown({ key: ' ', code: 'Space' }); + assert.deepEqual(testGlobals.mpvCommands, [ + ['keydown', 'r'], + ['cycle', 'pause'], + ]); + assert.equal(ctx.state.sessionBindings.length, 1); + testGlobals.dispatchWindowEvent('blur'); + const before = testGlobals.mpvCommands.length; + ctx.state.playlistBrowserModalOpen = true; + testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR' }); + ctx.state.playlistBrowserModalOpen = false; + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR' }); + ctx.state.yomitanPopupVisible = false; + testGlobals.setPopupVisible(false); + testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR', target: { closest: () => ({}) } }); + assert.equal(testGlobals.mpvCommands.length, before); + } finally { + testGlobals.restore(); + } +}); + +test('stalled mpv discovery does not delay configured overlay controls', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + try { + testGlobals.setGetMpvInputBindings(() => new Promise(() => {})); + testGlobals.setSessionBindings([ + { + sourcePath: 'keybindings[0].key', + originalKey: 'Space', + key: { code: 'Space', modifiers: [] }, + actionType: 'mpv-command', + command: ['cycle', 'pause'], + }, + ]); + await handlers.setupMpvInputForwarding(); + testGlobals.dispatchKeydown({ key: ' ', code: 'Space' }); + assert.deepEqual(testGlobals.mpvCommands, [['cycle', 'pause']]); + } finally { + testGlobals.restore(); + } +}); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index bc3cd0e8..16586367 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -1,5 +1,6 @@ import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '../../types'; import type { RendererContext } from '../context'; +import { createMpvInputForwarding } from './mpv-input-forwarding'; import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT, @@ -55,6 +56,11 @@ export function createKeyboardHandlers( timeout: ReturnType | null; } | null = null; let mpvInputForwardingListenersInstalled = false; + let keyboardConfigLoaded = false; + const importedMpvBindings = createMpvInputForwarding({ + load: () => window.electronAPI.getMpvInputBindings(), + send: (command) => window.electronAPI.sendMpvCommand(command), + }); const CHORD_MAP = new Map< string, @@ -131,6 +137,7 @@ export function createKeyboardHandlers( ctx.state.sessionBindingMap = new Map( bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]), ); + void importedMpvBindings.refresh(); } function keyEventToStringFromBinding(binding: CompiledSessionBinding): string { @@ -984,6 +991,7 @@ export function createKeyboardHandlers( ]); updateSessionBindings(sessionBindings); updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey); + keyboardConfigLoaded = true; syncKeyboardTokenSelection(); } @@ -1034,6 +1042,18 @@ export function createKeyboardHandlers( return; } mpvInputForwardingListenersInstalled = true; + const lateScriptRefresh = setTimeout(() => { + void importedMpvBindings.refresh(); + }, 1500); + window.addEventListener('focus', () => { + void importedMpvBindings.refresh(); + }); + window.addEventListener('blur', importedMpvBindings.releaseAll); + window.addEventListener('beforeunload', () => { + clearTimeout(lateScriptRefresh); + importedMpvBindings.dispose(); + }); + document.addEventListener('keyup', importedMpvBindings.keyup, true); const subtitleMutationObserver = new MutationObserver(() => { syncKeyboardTokenSelection(); @@ -1248,7 +1268,18 @@ export function createKeyboardHandlers( if (binding) { e.preventDefault(); dispatchSessionBinding(binding); + return; } + if ( + keyboardConfigLoaded && + !ctx.state.playlistBrowserModalOpen && + !ctx.state.youtubePickerModalOpen && + !ctx.state.subtitleSidebarModalOpen && + !ctx.state.yomitanPopupVisible && + !isYomitanPopupVisible(document) && + !isInteractiveTarget(e.target) + ) + importedMpvBindings.keydown(e); }); document.addEventListener('mousedown', (e: MouseEvent) => { diff --git a/src/renderer/handlers/mpv-input-forwarding.test.ts b/src/renderer/handlers/mpv-input-forwarding.test.ts new file mode 100644 index 00000000..919da743 --- /dev/null +++ b/src/renderer/handlers/mpv-input-forwarding.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMpvInputForwarding } from './mpv-input-forwarding'; +import type { MpvInputBindingsSnapshot } from '../../types/session-bindings'; + +function keyEvent( + overrides: Partial['keydown']>[0]> = {}, +) { + return { + key: 'r', + code: 'KeyR', + ctrlKey: false, + altKey: false, + shiftKey: false, + metaKey: false, + repeat: false, + defaultPrevented: false, + isComposing: false, + getModifierState: () => false, + preventDefault: () => {}, + ...overrides, + }; +} + +test('forwarded keys use mpv repeat and retain the pressed key through modifier changes', async () => { + const commands: (string | number)[][] = []; + const forwarding = createMpvInputForwarding({ + load: async () => ({ keys: ['r', 'ctrl+A'], blockedKeys: [] }), + send: (command) => commands.push(command), + }); + await forwarding.refresh(); + assert.equal(forwarding.keydown(keyEvent()), true); + assert.equal(forwarding.keydown(keyEvent({ repeat: true })), true); + forwarding.keyup(keyEvent()); + forwarding.keydown(keyEvent({ key: 'A', code: 'KeyA', ctrlKey: true, shiftKey: true })); + forwarding.keyup(keyEvent({ key: 'a', code: 'KeyA' })); + assert.deepEqual(commands, [ + ['keydown', 'r'], + ['keyup', 'r'], + ['keydown', 'ctrl+A'], + ['keyup', 'ctrl+A'], + ]); +}); + +test('configured and disabled keys, handled input, and unknown keys are not forwarded', async () => { + const commands: (string | number)[][] = []; + const forwarding = createMpvInputForwarding({ + load: async () => ({ keys: ['r', 't', '1'], blockedKeys: [{ code: 'KeyR', modifiers: [] }] }), + send: (command) => commands.push(command), + }); + await forwarding.refresh(); + assert.equal(forwarding.keydown(keyEvent()), false); + assert.equal( + forwarding.keydown(keyEvent({ key: 't', code: 'KeyT', defaultPrevented: true })), + false, + ); + assert.equal(forwarding.keydown(keyEvent({ key: 'z', code: 'KeyZ' })), false); + assert.equal(forwarding.keydown(keyEvent({ key: '1', code: 'Numpad1' })), false); + assert.deepEqual(commands, []); +}); + +test('refresh discards stale responses and coalesces concurrent requests', async () => { + let resolveFirst: (snapshot: MpvInputBindingsSnapshot) => void = () => {}; + let requests = 0; + const forwarding = createMpvInputForwarding({ + load: () => { + requests += 1; + if (requests === 1) + return new Promise((resolve) => { + resolveFirst = resolve; + }); + return Promise.resolve({ keys: ['t'], blockedKeys: [] }); + }, + send: () => {}, + }); + const first = forwarding.refresh(); + const second = forwarding.refresh(); + forwarding.refresh(); + assert.equal(requests, 1); + resolveFirst({ keys: ['r'], blockedKeys: [] }); + await Promise.all([first, second]); + assert.equal(requests, 2); + assert.equal(forwarding.keydown(keyEvent()), false); + assert.equal(forwarding.keydown(keyEvent({ key: 't', code: 'KeyT' })), true); +}); + +test('focus loss releases held keys and failed refresh clears stale bindings', async () => { + let fail = false; + const commands: (string | number)[][] = []; + const forwarding = createMpvInputForwarding({ + load: async () => { + if (fail) throw new Error('disconnected'); + return { keys: ['r'], blockedKeys: [] }; + }, + send: (command) => commands.push(command), + }); + await forwarding.refresh(); + forwarding.keydown(keyEvent()); + forwarding.releaseAll(); + forwarding.keyup(keyEvent()); + assert.deepEqual(commands, [ + ['keydown', 'r'], + ['keyup', 'r'], + ]); + fail = true; + await forwarding.refresh(); + assert.equal(forwarding.keydown(keyEvent()), false); + forwarding.dispose(); + fail = false; + await forwarding.refresh(); + assert.equal(forwarding.keydown(keyEvent()), false); +}); diff --git a/src/renderer/handlers/mpv-input-forwarding.ts b/src/renderer/handlers/mpv-input-forwarding.ts new file mode 100644 index 00000000..53cca512 --- /dev/null +++ b/src/renderer/handlers/mpv-input-forwarding.ts @@ -0,0 +1,91 @@ +import { keyboardEventToMpvKey } from '../../shared/mpv-input-bindings'; +import type { MpvInputBindingsSnapshot } from '../../types/session-bindings'; + +type ForwardedKeyEvent = Parameters[0] & + Pick; + +export function createMpvInputForwarding(deps: { + load: () => Promise; + send: (command: (string | number)[]) => void; +}) { + let keys = new Set(); + let blockedKeys: MpvInputBindingsSnapshot['blockedKeys'] = []; + const heldKeys = new Map(); + let generation = 0; + let disposed = false; + let pending: Promise | null = null; + + function releaseAll(): void { + for (const key of heldKeys.values()) deps.send(['keyup', key]); + heldKeys.clear(); + } + + function refresh(): Promise { + if (disposed) return Promise.resolve(); + generation += 1; + keys.clear(); + blockedKeys = []; + releaseAll(); + if (pending) return pending; + pending = (async () => { + let requestedGeneration: number; + do { + requestedGeneration = generation; + try { + const snapshot = await deps.load(); + if (!disposed && requestedGeneration === generation) { + keys = new Set(snapshot.keys); + blockedKeys = snapshot.blockedKeys; + } + } catch { + // Discovery is optional. Keep the existing overlay controls available. + } + } while (!disposed && requestedGeneration !== generation); + })().finally(() => { + pending = null; + }); + return pending; + } + + function keydown(event: ForwardedKeyEvent): boolean { + if (disposed || event.defaultPrevented) return false; + if (heldKeys.has(event.code)) { + event.preventDefault(); + return true; + } + if (event.repeat || event.code.startsWith('Numpad')) return false; + if ( + blockedKeys.some( + ({ code, modifiers }) => + code === event.code && + modifiers.includes('ctrl') === event.ctrlKey && + modifiers.includes('alt') === event.altKey && + modifiers.includes('shift') === event.shiftKey && + modifiers.includes('meta') === event.metaKey, + ) + ) + return false; + const key = keyboardEventToMpvKey(event); + if (!key || !keys.has(key)) return false; + heldKeys.set(event.code, key); + deps.send(['keydown', key]); + event.preventDefault(); + return true; + } + + function keyup(event: Pick): void { + const key = heldKeys.get(event.code); + if (!key) return; + heldKeys.delete(event.code); + deps.send(['keyup', key]); + event.preventDefault(); + } + + function dispose(): void { + disposed = true; + keys.clear(); + releaseAll(); + } + + return { refresh, keydown, keyup, releaseAll, dispose }; +} diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index bf6f9d98..e2431de5 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -61,6 +61,7 @@ export const IPC_CHANNELS = { getSubtitleStyle: 'get-subtitle-style', getMecabStatus: 'get-mecab-status', getKeybindings: 'get-keybindings', + getMpvInputBindings: 'get-mpv-input-bindings', getSessionBindings: 'get-session-bindings', getConfigShortcuts: 'get-config-shortcuts', getStatsToggleKey: 'get-stats-toggle-key', diff --git a/src/shared/mpv-input-bindings.test.ts b/src/shared/mpv-input-bindings.test.ts new file mode 100644 index 00000000..7f6d112e --- /dev/null +++ b/src/shared/mpv-input-bindings.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + keyboardEventToMpvKey, + normalizeMpvInputKey, + parseMpvInputBindingKeys, +} from './mpv-input-bindings'; + +test('mpv discovery validates entries and excludes inactive, mouse, sequence, and SubMiner keys', () => { + assert.deepEqual( + parseMpvInputBindingKeys([ + { key: 'r', cmd: 'script-binding replay/run', priority: 1, owner: 'replay' }, + { key: 'r', cmd: 'show-text duplicate', priority: 0 }, + { key: 'Ctrl+A', cmd: 'show-text shifted', priority: 1 }, + { key: 'g-g', cmd: 'seek 0', priority: 1 }, + { key: 'MBTN_LEFT', cmd: 'cycle pause', priority: 1 }, + { key: 'q', cmd: 'quit', priority: -1 }, + { key: 's', cmd: 'screenshot', priority: 1 }, + { key: 's', cmd: 'script-binding subminer/session', priority: 5, owner: 'subminer' }, + { key: 't', cmd: 'script-message subminer-toggle', priority: 1 }, + { key: 'x', cmd: 'ignore', priority: NaN }, + { key: 'z', cmd: 5, priority: 1 }, + null, + ]), + ['r', 'ctrl+A'], + ); + assert.deepEqual(parseMpvInputBindingKeys({ key: 'r' }), []); +}); + +test('mpv keys retain printable characters and normalize modifiers', () => { + assert.equal(normalizeMpvInputKey('Alt+Ctrl+Shift+a'), 'ctrl+alt+A'); + assert.equal(normalizeMpvInputKey('Ctrl++'), 'ctrl++'); + assert.equal(normalizeMpvInputKey('Shift+LEFT'), 'shift+LEFT'); + assert.equal(normalizeMpvInputKey('F12'), 'F12'); + assert.equal(normalizeMpvInputKey('UNMAPPED'), null); +}); + +test('keyboard conversion respects layout characters and skips composition and AltGr', () => { + const event = { + key: 'A', + ctrlKey: true, + shiftKey: true, + altKey: false, + metaKey: false, + isComposing: false, + getModifierState: () => false, + }; + assert.equal(keyboardEventToMpvKey(event), 'ctrl+A'); + assert.equal(keyboardEventToMpvKey({ ...event, key: '#', ctrlKey: false }), 'SHARP'); + assert.equal(keyboardEventToMpvKey({ ...event, key: 'ArrowLeft', ctrlKey: false }), 'shift+LEFT'); + assert.equal(keyboardEventToMpvKey({ ...event, key: 'Dead' }), null); + assert.equal(keyboardEventToMpvKey({ ...event, isComposing: true }), null); + assert.equal( + keyboardEventToMpvKey({ ...event, getModifierState: (key) => key === 'AltGraph' }), + null, + ); +}); + +test('only the highest-priority active binding determines SubMiner ownership', () => { + const user = { key: 'r', cmd: 'script-binding replay/run', is_weak: false, priority: 12 }; + const plugin = { + key: 'r', + cmd: 'script-binding subminer/run', + owner: 'subminer', + is_weak: true, + priority: 2, + }; + assert.deepEqual(parseMpvInputBindingKeys([plugin, user]), ['r']); + assert.deepEqual(parseMpvInputBindingKeys([user, plugin]), ['r']); + assert.deepEqual(parseMpvInputBindingKeys([user, { ...plugin, priority: -1 }]), ['r']); + assert.deepEqual( + parseMpvInputBindingKeys([user, { ...plugin, is_weak: false, priority: 15 }]), + [], + ); +}); + +test('SubMiner ownership excludes only its script commands and respects explicit owners', () => { + assert.deepEqual( + parseMpvInputBindingKeys([ + { key: 'a', cmd: 'show-text "subminer/readme"', priority: 1 }, + { key: 'b', cmd: 'run subminer-helper', priority: 1 }, + { key: 'c', cmd: 'script-message subminer-toggle', owner: 'other-script', priority: 1 }, + { key: 'd', cmd: 'script-binding subminer/action', owner: 'other-script', priority: 1 }, + { key: 'e', cmd: ' script-binding "subminer/action"', priority: 1 }, + { key: 'f', cmd: 'script-message subminer-toggle', priority: 1 }, + { key: 'g', cmd: 'ignore', owner: 'subminer', priority: 1 }, + ]), + ['a', 'b', 'c', 'd'], + ); +}); diff --git a/src/shared/mpv-input-bindings.ts b/src/shared/mpv-input-bindings.ts new file mode 100644 index 00000000..8041b275 --- /dev/null +++ b/src/shared/mpv-input-bindings.ts @@ -0,0 +1,98 @@ +const SPECIAL_KEYS: Record = { + ' ': 'SPACE', + '#': 'SHARP', + Enter: 'ENTER', + Escape: 'ESC', + Backspace: 'BS', + Tab: 'TAB', + Delete: 'DEL', + Insert: 'INS', + Home: 'HOME', + End: 'END', + PageUp: 'PGUP', + PageDown: 'PGDWN', + ArrowLeft: 'LEFT', + ArrowRight: 'RIGHT', + ArrowUp: 'UP', + ArrowDown: 'DOWN', +}; +const MPV_SPECIAL_KEYS = new Set(Object.values(SPECIAL_KEYS)); + +// Only single keyboard strokes are imported. Mouse input and sequences need +// their own focus and conflict rules before they can be forwarded safely. +export function normalizeMpvInputKey(value: string): string | null { + const modifiers = new Set(); + let key = value; + let modifier = /^(Shift|Ctrl|Alt|Meta)\+/i.exec(key); + while (modifier?.[1]) { + modifiers.add(modifier[1].toLowerCase()); + key = key.slice(modifier[0].length); + modifier = /^(Shift|Ctrl|Alt|Meta)\+/i.exec(key); + } + if (key === 'SHARP') modifiers.delete('shift'); + if (!MPV_SPECIAL_KEYS.has(key) && !/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(key)) { + if ([...key].length !== 1) return null; + if (modifiers.has('shift') && /^[a-z]$/i.test(key)) key = key.toUpperCase(); + modifiers.delete('shift'); + } + return [...['ctrl', 'alt', 'shift', 'meta'].filter((item) => modifiers.has(item)), key].join('+'); +} + +export function keyboardEventToMpvKey( + event: Pick< + KeyboardEvent, + 'key' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey' | 'isComposing' | 'getModifierState' + >, +): string | null { + if (event.isComposing || event.key === 'Dead' || event.getModifierState?.('AltGraph')) + return null; + const key = SPECIAL_KEYS[event.key] ?? event.key; + const modifiers = [ + ...(event.ctrlKey ? ['ctrl'] : []), + ...(event.altKey ? ['alt'] : []), + ...(event.shiftKey ? ['shift'] : []), + ...(event.metaKey ? ['meta'] : []), + ]; + return normalizeMpvInputKey([...modifiers, key].join('+')); +} + +export function parseMpvInputBindingKeys(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const bindings = new Map(); + for (const candidate of value) { + const entry: unknown = candidate; + if ( + !entry || + typeof entry !== 'object' || + !('key' in entry) || + typeof entry.key !== 'string' || + !('cmd' in entry) || + typeof entry.cmd !== 'string' || + !('priority' in entry) || + typeof entry.priority !== 'number' || + !Number.isFinite(entry.priority) || + entry.priority < 0 + ) + continue; + const key = normalizeMpvInputKey(entry.key); + if (!key) continue; + const owner = 'owner' in entry ? entry.owner : undefined; + const owned = + owner === 'subminer' || + (owner === undefined && + /^(?:script-binding\s+["']?subminer\/|script-message\s+["']?subminer-)/.test( + entry.cmd.trimStart(), + )); + const previous = bindings.get(key); + // mpv's reported priority already ranks active non-weak bindings above weak + // bindings. Only the winning binding determines whether the key is imported. + if ( + !previous || + entry.priority > previous.priority || + (entry.priority === previous.priority && owned) + ) { + bindings.set(key, { priority: entry.priority, owned }); + } + } + return [...bindings].filter(([, binding]) => !binding.owned).map(([key]) => key); +} diff --git a/src/types/runtime.ts b/src/types/runtime.ts index c595dcbb..d4e24a89 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -1,3 +1,4 @@ +import type { MpvInputBindingsSnapshot } from './session-bindings'; import type { KikuFieldGroupingChoice, KikuFieldGroupingRequestData, @@ -462,6 +463,7 @@ export interface ElectronAPI { setMecabEnabled: (enabled: boolean) => void; sendMpvCommand: (command: (string | number)[]) => void; getKeybindings: () => Promise; + getMpvInputBindings: () => Promise; getSessionBindings: () => Promise; getConfiguredShortcuts: () => Promise>; dispatchSessionAction: ( diff --git a/src/types/session-bindings.ts b/src/types/session-bindings.ts index 3e0deeb1..3ba5d950 100644 --- a/src/types/session-bindings.ts +++ b/src/types/session-bindings.ts @@ -34,6 +34,11 @@ export interface SessionKeySpec { modifiers: SessionKeyModifier[]; } +export interface MpvInputBindingsSnapshot { + keys: string[]; + blockedKeys: SessionKeySpec[]; +} + export interface SessionBindingWarning { kind: 'unsupported' | 'conflict' | 'deprecated-config'; path: string;