mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 05:16:23 -07:00
feat(overlay): discover unclaimed mpv key bindings (#246)
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user