mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 05:16:27 -07:00
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:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({}), []);
|
||||
});
|
||||
|
||||
@@ -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?.() ?? [];
|
||||
});
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user