feat(overlay): discover unclaimed mpv key bindings

- Import non-conflicting mpv bindings for the overlay session
- Preserve SubMiner precedence and document discovery behavior
This commit is contained in:
2026-09-11 03:46:15 -07:00
parent e6dc9dfec5
commit e1a0bf4db0
21 changed files with 656 additions and 5 deletions
+15
View File
@@ -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({}), []);
});
+8
View File
@@ -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<string | number>) => void;
getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
@@ -344,6 +346,7 @@ export interface IpcDepsRuntimeOptions {
getMecabTokenizer: () => MecabTokenizerLike | null;
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings?: () => CompiledSessionBinding[];
getConfiguredShortcuts: () => unknown;
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
@@ -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?.() ?? [];
});
+4 -4
View File
@@ -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',
+12
View File
@@ -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),
+2
View File
@@ -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,
@@ -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[]> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
getMpvInputBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getMpvInputBindings),
getSessionBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSessionBindings),
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
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 { 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<MpvInputBindingsSnapshot> = async () => ({
keys: [],
blockedKeys: [],
});
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = 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<string | number>) => {
@@ -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();
}
});
+31
View File
@@ -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<typeof setTimeout> | 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) => {
@@ -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',
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',
+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 {
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
@@ -462,6 +463,7 @@ export interface ElectronAPI {
setMecabEnabled: (enabled: boolean) => void;
sendMpvCommand: (command: (string | number)[]) => void;
getKeybindings: () => Promise<Keybinding[]>;
getMpvInputBindings: () => Promise<MpvInputBindingsSnapshot>;
getSessionBindings: () => Promise<CompiledSessionBinding[]>;
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
dispatchSessionAction: (
+5
View File
@@ -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;