Compare commits

...
Author SHA1 Message Date
sudacode e1a0bf4db0 feat(overlay): discover unclaimed mpv key bindings
- Import non-conflicting mpv bindings for the overlay session
- Preserve SubMiner precedence and document discovery behavior
2026-09-11 03:46:15 -07:00
21 changed files with 656 additions and 5 deletions
+4
View File
@@ -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.
+5
View File
@@ -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 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. 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. **See `config.example.jsonc`** for more keybinding examples and configuration options.
+17
View File
@@ -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`. 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. 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.
+11
View File
@@ -39,6 +39,17 @@ Read when: you need to find the owner module for a behavior or test surface
## Shared Contract Entry Points ## 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` The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
identifies the media and subtitle source so renderer selections are invalidated on source changes, 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 including changes whose cue text and timings are identical. Native selection and clean clipboard
+15
View File
@@ -1323,3 +1323,18 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn
assert.deepEqual(calls, [21355]); assert.deepEqual(calls, [21355]);
assert.deepEqual(searches, ['Re:ZERO']); 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({}), []);
});
+8
View File
@@ -1,4 +1,5 @@
import electron from 'electron'; import electron from 'electron';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron'; import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
import type { import type {
ChangelogSnapshot, ChangelogSnapshot,
@@ -89,6 +90,7 @@ export interface IpcServiceDeps {
setMecabEnabled: (enabled: boolean) => void; setMecabEnabled: (enabled: boolean) => void;
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[]; getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>; dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
@@ -344,6 +346,7 @@ export interface IpcDepsRuntimeOptions {
getMecabTokenizer: () => MecabTokenizerLike | null; getMecabTokenizer: () => MecabTokenizerLike | null;
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[]; getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown; getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>; dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
@@ -438,6 +441,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
}, },
handleMpvCommand: options.handleMpvCommand, handleMpvCommand: options.handleMpvCommand,
getKeybindings: options.getKeybindings, getKeybindings: options.getKeybindings,
getMpvInputBindings: options.getMpvInputBindings,
getSessionBindings: options.getSessionBindings ?? (() => []), getSessionBindings: options.getSessionBindings ?? (() => []),
getConfiguredShortcuts: options.getConfiguredShortcuts, getConfiguredShortcuts: options.getConfiguredShortcuts,
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}), dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
@@ -769,6 +773,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getKeybindings(); return deps.getKeybindings();
}); });
ipc.handle(IPC_CHANNELS.request.getMpvInputBindings, () => {
return deps.getMpvInputBindings?.() ?? { keys: [], blockedKeys: [] };
});
ipc.handle(IPC_CHANNELS.request.getSessionBindings, () => { ipc.handle(IPC_CHANNELS.request.getSessionBindings, () => {
return deps.getSessionBindings?.() ?? []; return deps.getSessionBindings?.() ?? [];
}); });
+4 -4
View File
@@ -211,7 +211,7 @@ function parseAccelerator(
}; };
} }
function parseDomKeyString( export function parseSessionBindingKey(
key: string, key: string,
platform: PlatformKeyModel, platform: PlatformKeyModel,
): { key: SessionKeySpec | null; message?: string } { ): { key: SessionKeySpec | null; message?: string } {
@@ -435,7 +435,7 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
} }
if (statsToggleKey) { if (statsToggleKey) {
const parsed = parseDomKeyString(statsToggleKey, input.platform); const parsed = parseSessionBindingKey(statsToggleKey, input.platform);
if (!parsed.key) { if (!parsed.key) {
warnings.push({ warnings.push({
kind: 'unsupported', kind: 'unsupported',
@@ -462,7 +462,7 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
} }
if (statsMarkWatchedKey) { if (statsMarkWatchedKey) {
const parsed = parseDomKeyString(statsMarkWatchedKey, input.platform); const parsed = parseSessionBindingKey(statsMarkWatchedKey, input.platform);
if (!parsed.key) { if (!parsed.key) {
warnings.push({ warnings.push({
kind: 'unsupported', kind: 'unsupported',
@@ -490,7 +490,7 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
input.keybindings.forEach((binding, index) => { input.keybindings.forEach((binding, index) => {
if (!binding.command) return; if (!binding.command) return;
const parsed = parseDomKeyString(binding.key, input.platform); const parsed = parseSessionBindingKey(binding.key, input.platform);
if (!parsed.key) { if (!parsed.key) {
warnings.push({ warnings.push({
kind: 'unsupported', kind: 'unsupported',
+12
View File
@@ -33,6 +33,7 @@ import {
} from 'electron'; } from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js'; import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open'; 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 { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata'; import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js'; import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
@@ -5859,6 +5860,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
saveSubtitlePosition: (position) => saveSubtitlePosition(position), saveSubtitlePosition: (position) => saveSubtitlePosition(position),
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings, 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, getSessionBindings: () => appState.sessionBindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
dispatchSessionAction: (request) => dispatchSessionAction(request), dispatchSessionAction: (request) => dispatchSessionAction(request),
+2
View File
@@ -83,6 +83,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer']; getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand']; handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getMpvInputBindings?: IpcDepsRuntimeOptions['getMpvInputBindings'];
getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings']; getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction']; dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
@@ -280,6 +281,7 @@ export function createMainIpcRuntimeServiceDeps(
getMecabTokenizer: params.getMecabTokenizer, getMecabTokenizer: params.getMecabTokenizer,
handleMpvCommand: params.handleMpvCommand, handleMpvCommand: params.handleMpvCommand,
getKeybindings: params.getKeybindings, getKeybindings: params.getKeybindings,
getMpvInputBindings: params.getMpvInputBindings,
getSessionBindings: params.getSessionBindings, getSessionBindings: params.getSessionBindings,
getConfiguredShortcuts: params.getConfiguredShortcuts, getConfiguredShortcuts: params.getConfiguredShortcuts,
dispatchSessionAction: params.dispatchSessionAction, dispatchSessionAction: params.dispatchSessionAction,
@@ -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<unknown> => {
throw new Error('property unavailable');
},
};
const deps = {
getMpvClient: () => client,
getConfiguredKeybindings: () => [],
platform: 'linux',
} satisfies Parameters<typeof readMpvInputBindings>[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, []);
});
+31
View File
@@ -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<unknown>;
} | null;
getConfiguredKeybindings: () => Keybinding[];
platform: 'darwin' | 'win32' | 'linux';
}): Promise<MpvInputBindingsSnapshot> {
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 };
}
}
+1
View File
@@ -350,6 +350,7 @@ const electronAPI: ElectronAPI = {
getKeybindings: (): Promise<Keybinding[]> => getKeybindings: (): Promise<Keybinding[]> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings), ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
getMpvInputBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getMpvInputBindings),
getSessionBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSessionBindings), getSessionBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSessionBindings),
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> => getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
+75 -1
View File
@@ -6,6 +6,7 @@ import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js'; import { createKeyboardHandlers } from './keyboard.js';
import { createRendererState } from '../state.js'; import { createRendererState } from '../state.js';
import type { CompiledSessionBinding } from '../../types'; import type { CompiledSessionBinding } from '../../types';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions'; import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
import { compileSessionBindings } from '../../core/services/session-bindings'; import { compileSessionBindings } from '../../core/services/session-bindings';
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config'; import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
@@ -115,6 +116,10 @@ function installKeyboardTestGlobals() {
const sessionActions: Array<{ actionId: string; payload?: unknown }> = []; const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
const interactionActivations: string[] = []; const interactionActivations: string[] = [];
let sessionBindings: CompiledSessionBinding[] = []; let sessionBindings: CompiledSessionBinding[] = [];
let getMpvInputBindings: () => Promise<MpvInputBindingsSnapshot> = async () => ({
keys: [],
blockedKeys: [],
});
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = async () => sessionBindings; let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = async () => sessionBindings;
let playbackPausedResponse: boolean | null = false; let playbackPausedResponse: boolean | null = false;
let statsToggleKey = 'Backquote'; let statsToggleKey = 'Backquote';
@@ -238,6 +243,7 @@ function installKeyboardTestGlobals() {
}, },
electronAPI: { electronAPI: {
getKeybindings: async () => [], getKeybindings: async () => [],
getMpvInputBindings: () => getMpvInputBindings(),
getSessionBindings: () => getSessionBindingsImpl(), getSessionBindings: () => getSessionBindingsImpl(),
getConfiguredShortcuts: async () => configuredShortcuts, getConfiguredShortcuts: async () => configuredShortcuts,
sendMpvCommand: (command: Array<string | number>) => { sendMpvCommand: (command: Array<string | number>) => {
@@ -308,6 +314,7 @@ function installKeyboardTestGlobals() {
altKey?: boolean; altKey?: boolean;
shiftKey?: boolean; shiftKey?: boolean;
repeat?: boolean; repeat?: boolean;
target?: unknown;
}): void { }): void {
const listeners = documentListeners.get('keydown') ?? []; const listeners = documentListeners.get('keydown') ?? [];
const keyboardEvent = { const keyboardEvent = {
@@ -319,7 +326,7 @@ function installKeyboardTestGlobals() {
shiftKey: event.shiftKey ?? false, shiftKey: event.shiftKey ?? false,
repeat: event.repeat ?? false, repeat: event.repeat ?? false,
preventDefault: () => {}, preventDefault: () => {},
target: null, target: event.target ?? null,
}; };
for (const listener of listeners) { for (const listener of listeners) {
listener(keyboardEvent); listener(keyboardEvent);
@@ -369,6 +376,7 @@ function installKeyboardTestGlobals() {
} }
function restore() { function restore() {
dispatchWindowEvent('beforeunload');
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'MutationObserver', { Object.defineProperty(globalThis, 'MutationObserver', {
@@ -421,6 +429,9 @@ function installKeyboardTestGlobals() {
setConfiguredShortcuts: (value: typeof configuredShortcuts) => { setConfiguredShortcuts: (value: typeof configuredShortcuts) => {
configuredShortcuts = value; configuredShortcuts = value;
}, },
setGetMpvInputBindings: (value: typeof getMpvInputBindings) => {
getMpvInputBindings = value;
},
setSessionBindings: (value: CompiledSessionBinding[]) => { setSessionBindings: (value: CompiledSessionBinding[]) => {
sessionBindings = value; sessionBindings = value;
}, },
@@ -2307,3 +2318,66 @@ test('mark-watched keybinding does not send mpv commands when no active session'
testGlobals.restore(); 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();
}
});
+31
View File
@@ -1,5 +1,6 @@
import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '../../types'; import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '../../types';
import type { RendererContext } from '../context'; import type { RendererContext } from '../context';
import { createMpvInputForwarding } from './mpv-input-forwarding';
import { import {
YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT, YOMITAN_POPUP_SHOWN_EVENT,
@@ -55,6 +56,11 @@ export function createKeyboardHandlers(
timeout: ReturnType<typeof setTimeout> | null; timeout: ReturnType<typeof setTimeout> | null;
} | null = null; } | null = null;
let mpvInputForwardingListenersInstalled = false; let mpvInputForwardingListenersInstalled = false;
let keyboardConfigLoaded = false;
const importedMpvBindings = createMpvInputForwarding({
load: () => window.electronAPI.getMpvInputBindings(),
send: (command) => window.electronAPI.sendMpvCommand(command),
});
const CHORD_MAP = new Map< const CHORD_MAP = new Map<
string, string,
@@ -131,6 +137,7 @@ export function createKeyboardHandlers(
ctx.state.sessionBindingMap = new Map( ctx.state.sessionBindingMap = new Map(
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]), bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
); );
void importedMpvBindings.refresh();
} }
function keyEventToStringFromBinding(binding: CompiledSessionBinding): string { function keyEventToStringFromBinding(binding: CompiledSessionBinding): string {
@@ -984,6 +991,7 @@ export function createKeyboardHandlers(
]); ]);
updateSessionBindings(sessionBindings); updateSessionBindings(sessionBindings);
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey); updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
keyboardConfigLoaded = true;
syncKeyboardTokenSelection(); syncKeyboardTokenSelection();
} }
@@ -1034,6 +1042,18 @@ export function createKeyboardHandlers(
return; return;
} }
mpvInputForwardingListenersInstalled = true; 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(() => { const subtitleMutationObserver = new MutationObserver(() => {
syncKeyboardTokenSelection(); syncKeyboardTokenSelection();
@@ -1248,7 +1268,18 @@ export function createKeyboardHandlers(
if (binding) { if (binding) {
e.preventDefault(); e.preventDefault();
dispatchSessionBinding(binding); 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) => { document.addEventListener('mousedown', (e: MouseEvent) => {
@@ -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<Parameters<ReturnType<typeof createMpvInputForwarding>['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);
});
@@ -0,0 +1,91 @@
import { keyboardEventToMpvKey } from '../../shared/mpv-input-bindings';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
type ForwardedKeyEvent = Parameters<typeof keyboardEventToMpvKey>[0] &
Pick<KeyboardEvent, 'code' | 'repeat' | 'defaultPrevented' | 'preventDefault'>;
export function createMpvInputForwarding(deps: {
load: () => Promise<MpvInputBindingsSnapshot>;
send: (command: (string | number)[]) => void;
}) {
let keys = new Set<string>();
let blockedKeys: MpvInputBindingsSnapshot['blockedKeys'] = [];
const heldKeys = new Map<string, string>();
let generation = 0;
let disposed = false;
let pending: Promise<void> | null = null;
function releaseAll(): void {
for (const key of heldKeys.values()) deps.send(['keyup', key]);
heldKeys.clear();
}
function refresh(): Promise<void> {
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<KeyboardEvent, 'code' | 'preventDefault'>): 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 };
}
+1
View File
@@ -61,6 +61,7 @@ export const IPC_CHANNELS = {
getSubtitleStyle: 'get-subtitle-style', getSubtitleStyle: 'get-subtitle-style',
getMecabStatus: 'get-mecab-status', getMecabStatus: 'get-mecab-status',
getKeybindings: 'get-keybindings', getKeybindings: 'get-keybindings',
getMpvInputBindings: 'get-mpv-input-bindings',
getSessionBindings: 'get-session-bindings', getSessionBindings: 'get-session-bindings',
getConfigShortcuts: 'get-config-shortcuts', getConfigShortcuts: 'get-config-shortcuts',
getStatsToggleKey: 'get-stats-toggle-key', getStatsToggleKey: 'get-stats-toggle-key',
+90
View File
@@ -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'],
);
});
+98
View File
@@ -0,0 +1,98 @@
const SPECIAL_KEYS: Record<string, string> = {
' ': '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<string>();
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<string, { priority: number; owned: boolean }>();
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);
}
+2
View File
@@ -1,3 +1,4 @@
import type { MpvInputBindingsSnapshot } from './session-bindings';
import type { import type {
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
KikuFieldGroupingRequestData, KikuFieldGroupingRequestData,
@@ -462,6 +463,7 @@ export interface ElectronAPI {
setMecabEnabled: (enabled: boolean) => void; setMecabEnabled: (enabled: boolean) => void;
sendMpvCommand: (command: (string | number)[]) => void; sendMpvCommand: (command: (string | number)[]) => void;
getKeybindings: () => Promise<Keybinding[]>; getKeybindings: () => Promise<Keybinding[]>;
getMpvInputBindings: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings: () => Promise<CompiledSessionBinding[]>; getSessionBindings: () => Promise<CompiledSessionBinding[]>;
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>; getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
dispatchSessionAction: ( dispatchSessionAction: (
+5
View File
@@ -34,6 +34,11 @@ export interface SessionKeySpec {
modifiers: SessionKeyModifier[]; modifiers: SessionKeyModifier[];
} }
export interface MpvInputBindingsSnapshot {
keys: string[];
blockedKeys: SessionKeySpec[];
}
export interface SessionBindingWarning { export interface SessionBindingWarning {
kind: 'unsupported' | 'conflict' | 'deprecated-config'; kind: 'unsupported' | 'conflict' | 'deprecated-config';
path: string; path: string;