feat(overlay): discover unclaimed mpv key bindings (#246)

This commit is contained in:
2026-09-15 18:26:45 -07:00
committed by GitHub
parent e6dc9dfec5
commit 7c1eac03dc
21 changed files with 674 additions and 5 deletions
+75 -1
View File
@@ -6,6 +6,7 @@ import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js';
import { createRendererState } from '../state.js';
import type { CompiledSessionBinding } from '../../types';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
import { compileSessionBindings } from '../../core/services/session-bindings';
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
@@ -115,6 +116,10 @@ function installKeyboardTestGlobals() {
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
const interactionActivations: string[] = [];
let sessionBindings: CompiledSessionBinding[] = [];
let getMpvInputBindings: () => Promise<MpvInputBindingsSnapshot> = async () => ({
keys: [],
blockedKeys: [],
});
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = async () => sessionBindings;
let playbackPausedResponse: boolean | null = false;
let statsToggleKey = 'Backquote';
@@ -238,6 +243,7 @@ function installKeyboardTestGlobals() {
},
electronAPI: {
getKeybindings: async () => [],
getMpvInputBindings: () => getMpvInputBindings(),
getSessionBindings: () => getSessionBindingsImpl(),
getConfiguredShortcuts: async () => configuredShortcuts,
sendMpvCommand: (command: Array<string | number>) => {
@@ -308,6 +314,7 @@ function installKeyboardTestGlobals() {
altKey?: boolean;
shiftKey?: boolean;
repeat?: boolean;
target?: unknown;
}): void {
const listeners = documentListeners.get('keydown') ?? [];
const keyboardEvent = {
@@ -319,7 +326,7 @@ function installKeyboardTestGlobals() {
shiftKey: event.shiftKey ?? false,
repeat: event.repeat ?? false,
preventDefault: () => {},
target: null,
target: event.target ?? null,
};
for (const listener of listeners) {
listener(keyboardEvent);
@@ -369,6 +376,7 @@ function installKeyboardTestGlobals() {
}
function restore() {
dispatchWindowEvent('beforeunload');
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'MutationObserver', {
@@ -421,6 +429,9 @@ function installKeyboardTestGlobals() {
setConfiguredShortcuts: (value: typeof configuredShortcuts) => {
configuredShortcuts = value;
},
setGetMpvInputBindings: (value: typeof getMpvInputBindings) => {
getMpvInputBindings = value;
},
setSessionBindings: (value: CompiledSessionBinding[]) => {
sessionBindings = value;
},
@@ -2307,3 +2318,66 @@ test('mark-watched keybinding does not send mpv commands when no active session'
testGlobals.restore();
}
});
test('discovered mpv keys only run after SubMiner controls and stay out of session help', async () => {
const { handlers, testGlobals, ctx } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(async () => ({
keys: ['r', 'SPACE', 'y', 'v'],
blockedKeys: [],
}));
testGlobals.setSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Space',
key: { code: 'Space', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
]);
await handlers.setupMpvInputForwarding();
await wait(0);
testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR' });
testGlobals.dispatchKeydown({ key: ' ', code: 'Space' });
assert.deepEqual(testGlobals.mpvCommands, [
['keydown', 'r'],
['cycle', 'pause'],
]);
assert.equal(ctx.state.sessionBindings.length, 1);
testGlobals.dispatchWindowEvent('blur');
const before = testGlobals.mpvCommands.length;
ctx.state.playlistBrowserModalOpen = true;
testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR' });
ctx.state.playlistBrowserModalOpen = false;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR' });
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(false);
testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR', target: { closest: () => ({}) } });
assert.equal(testGlobals.mpvCommands.length, before);
} finally {
testGlobals.restore();
}
});
test('stalled mpv discovery does not delay configured overlay controls', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(() => new Promise(() => {}));
testGlobals.setSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Space',
key: { code: 'Space', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
]);
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: ' ', code: 'Space' });
assert.deepEqual(testGlobals.mpvCommands, [['cycle', 'pause']]);
} finally {
testGlobals.restore();
}
});
+31
View File
@@ -1,5 +1,6 @@
import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '../../types';
import type { RendererContext } from '../context';
import { createMpvInputForwarding } from './mpv-input-forwarding';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
@@ -55,6 +56,11 @@ export function createKeyboardHandlers(
timeout: ReturnType<typeof setTimeout> | null;
} | null = null;
let mpvInputForwardingListenersInstalled = false;
let keyboardConfigLoaded = false;
const importedMpvBindings = createMpvInputForwarding({
load: () => window.electronAPI.getMpvInputBindings(),
send: (command) => window.electronAPI.sendMpvCommand(command),
});
const CHORD_MAP = new Map<
string,
@@ -131,6 +137,7 @@ export function createKeyboardHandlers(
ctx.state.sessionBindingMap = new Map(
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
);
void importedMpvBindings.refresh();
}
function keyEventToStringFromBinding(binding: CompiledSessionBinding): string {
@@ -984,6 +991,7 @@ export function createKeyboardHandlers(
]);
updateSessionBindings(sessionBindings);
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
keyboardConfigLoaded = true;
syncKeyboardTokenSelection();
}
@@ -1034,6 +1042,18 @@ export function createKeyboardHandlers(
return;
}
mpvInputForwardingListenersInstalled = true;
const lateScriptRefresh = setTimeout(() => {
void importedMpvBindings.refresh();
}, 1500);
window.addEventListener('focus', () => {
void importedMpvBindings.refresh();
});
window.addEventListener('blur', importedMpvBindings.releaseAll);
window.addEventListener('beforeunload', () => {
clearTimeout(lateScriptRefresh);
importedMpvBindings.dispose();
});
document.addEventListener('keyup', importedMpvBindings.keyup, true);
const subtitleMutationObserver = new MutationObserver(() => {
syncKeyboardTokenSelection();
@@ -1248,7 +1268,18 @@ export function createKeyboardHandlers(
if (binding) {
e.preventDefault();
dispatchSessionBinding(binding);
return;
}
if (
keyboardConfigLoaded &&
!ctx.state.playlistBrowserModalOpen &&
!ctx.state.youtubePickerModalOpen &&
!ctx.state.subtitleSidebarModalOpen &&
!ctx.state.yomitanPopupVisible &&
!isYomitanPopupVisible(document) &&
!isInteractiveTarget(e.target)
)
importedMpvBindings.keydown(e);
});
document.addEventListener('mousedown', (e: MouseEvent) => {
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMpvInputForwarding } from './mpv-input-forwarding';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
function keyEvent(
overrides: Partial<Parameters<ReturnType<typeof createMpvInputForwarding>['keydown']>[0]> = {},
) {
return {
key: 'r',
code: 'KeyR',
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
repeat: false,
defaultPrevented: false,
isComposing: false,
getModifierState: () => false,
preventDefault: () => {},
...overrides,
};
}
test('forwarded keys use mpv repeat and retain the pressed key through modifier changes', async () => {
const commands: (string | number)[][] = [];
const forwarding = createMpvInputForwarding({
load: async () => ({ keys: ['r', 'ctrl+A'], blockedKeys: [] }),
send: (command) => commands.push(command),
});
await forwarding.refresh();
assert.equal(forwarding.keydown(keyEvent()), true);
assert.equal(forwarding.keydown(keyEvent({ repeat: true })), true);
forwarding.keyup(keyEvent());
forwarding.keydown(keyEvent({ key: 'A', code: 'KeyA', ctrlKey: true, shiftKey: true }));
forwarding.keyup(keyEvent({ key: 'a', code: 'KeyA' }));
assert.deepEqual(commands, [
['keydown', 'r'],
['keyup', 'r'],
['keydown', 'ctrl+A'],
['keyup', 'ctrl+A'],
]);
});
test('configured and disabled keys, handled input, and unknown keys are not forwarded', async () => {
const commands: (string | number)[][] = [];
const forwarding = createMpvInputForwarding({
load: async () => ({ keys: ['r', 't', '1'], blockedKeys: [{ code: 'KeyR', modifiers: [] }] }),
send: (command) => commands.push(command),
});
await forwarding.refresh();
assert.equal(forwarding.keydown(keyEvent()), false);
assert.equal(
forwarding.keydown(keyEvent({ key: 't', code: 'KeyT', defaultPrevented: true })),
false,
);
assert.equal(forwarding.keydown(keyEvent({ key: 'z', code: 'KeyZ' })), false);
assert.equal(forwarding.keydown(keyEvent({ key: '1', code: 'Numpad1' })), false);
assert.deepEqual(commands, []);
});
test('refresh discards stale responses and coalesces concurrent requests', async () => {
let resolveFirst: (snapshot: MpvInputBindingsSnapshot) => void = () => {};
let requests = 0;
const forwarding = createMpvInputForwarding({
load: () => {
requests += 1;
if (requests === 1)
return new Promise((resolve) => {
resolveFirst = resolve;
});
return Promise.resolve({ keys: ['t'], blockedKeys: [] });
},
send: () => {},
});
const first = forwarding.refresh();
const second = forwarding.refresh();
forwarding.refresh();
assert.equal(requests, 1);
resolveFirst({ keys: ['r'], blockedKeys: [] });
await Promise.all([first, second]);
assert.equal(requests, 2);
assert.equal(forwarding.keydown(keyEvent()), false);
assert.equal(forwarding.keydown(keyEvent({ key: 't', code: 'KeyT' })), true);
});
test('focus loss releases held keys and failed refresh clears stale bindings', async () => {
let fail = false;
const commands: (string | number)[][] = [];
const forwarding = createMpvInputForwarding({
load: async () => {
if (fail) throw new Error('disconnected');
return { keys: ['r'], blockedKeys: [] };
},
send: (command) => commands.push(command),
});
await forwarding.refresh();
forwarding.keydown(keyEvent());
forwarding.releaseAll();
forwarding.keyup(keyEvent());
assert.deepEqual(commands, [
['keydown', 'r'],
['keyup', 'r'],
]);
fail = true;
await forwarding.refresh();
assert.equal(forwarding.keydown(keyEvent()), false);
forwarding.dispose();
fail = false;
await forwarding.refresh();
assert.equal(forwarding.keydown(keyEvent()), false);
});
@@ -0,0 +1,91 @@
import { keyboardEventToMpvKey } from '../../shared/mpv-input-bindings';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
type ForwardedKeyEvent = Parameters<typeof keyboardEventToMpvKey>[0] &
Pick<KeyboardEvent, 'code' | 'repeat' | 'defaultPrevented' | 'preventDefault'>;
export function createMpvInputForwarding(deps: {
load: () => Promise<MpvInputBindingsSnapshot>;
send: (command: (string | number)[]) => void;
}) {
let keys = new Set<string>();
let blockedKeys: MpvInputBindingsSnapshot['blockedKeys'] = [];
const heldKeys = new Map<string, string>();
let generation = 0;
let disposed = false;
let pending: Promise<void> | null = null;
function releaseAll(): void {
for (const key of heldKeys.values()) deps.send(['keyup', key]);
heldKeys.clear();
}
function refresh(): Promise<void> {
if (disposed) return Promise.resolve();
generation += 1;
keys.clear();
blockedKeys = [];
releaseAll();
if (pending) return pending;
pending = (async () => {
let requestedGeneration: number;
do {
requestedGeneration = generation;
try {
const snapshot = await deps.load();
if (!disposed && requestedGeneration === generation) {
keys = new Set(snapshot.keys);
blockedKeys = snapshot.blockedKeys;
}
} catch {
// Discovery is optional. Keep the existing overlay controls available.
}
} while (!disposed && requestedGeneration !== generation);
})().finally(() => {
pending = null;
});
return pending;
}
function keydown(event: ForwardedKeyEvent): boolean {
if (disposed || event.defaultPrevented) return false;
if (heldKeys.has(event.code)) {
event.preventDefault();
return true;
}
if (event.repeat || event.code.startsWith('Numpad')) return false;
if (
blockedKeys.some(
({ code, modifiers }) =>
code === event.code &&
modifiers.includes('ctrl') === event.ctrlKey &&
modifiers.includes('alt') === event.altKey &&
modifiers.includes('shift') === event.shiftKey &&
modifiers.includes('meta') === event.metaKey,
)
)
return false;
const key = keyboardEventToMpvKey(event);
if (!key || !keys.has(key)) return false;
heldKeys.set(event.code, key);
deps.send(['keydown', key]);
event.preventDefault();
return true;
}
function keyup(event: Pick<KeyboardEvent, 'code' | 'preventDefault'>): void {
const key = heldKeys.get(event.code);
if (!key) return;
heldKeys.delete(event.code);
deps.send(['keyup', key]);
event.preventDefault();
}
function dispose(): void {
disposed = true;
keys.clear();
releaseAll();
}
return { refresh, keydown, keyup, releaseAll, dispose };
}