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