feat(overlay): forward mpv mouse button and wheel bindings (#279)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-01 00:44:51 -07:00
1 parent 9fadbc8cdd
commit 9e79fa93c2
18 files changed
+500 -76

No files matched your search

+122 -1
View File
@@ -342,8 +342,9 @@ function installKeyboardTestGlobals() {
altKey?: boolean;
shiftKey?: boolean;
target?: unknown;
type?: 'mousedown' | 'mouseup';
}): void {
const listeners = documentListeners.get('mousedown') ?? [];
const listeners = documentListeners.get(event.type ?? 'mousedown') ?? [];
const mouseEvent = {
button: event.button,
ctrlKey: event.ctrlKey ?? false,
@@ -360,6 +361,36 @@ function installKeyboardTestGlobals() {
const dispatchDocumentMouseDown = dispatchMousedown;
function dispatchWheel(event: {
deltaY: number;
deltaX?: number;
ctrlKey?: boolean;
shiftKey?: boolean;
target?: unknown;
}): boolean {
let prevented = false;
const wheelEvent = {
deltaX: event.deltaX ?? 0,
deltaY: event.deltaY,
deltaMode: 0,
ctrlKey: event.ctrlKey ?? false,
metaKey: false,
altKey: false,
shiftKey: event.shiftKey ?? false,
get defaultPrevented() {
return prevented;
},
preventDefault: () => {
prevented = true;
},
target: event.target ?? null,
};
for (const listener of documentListeners.get('wheel') ?? []) {
listener(wheelEvent);
}
return prevented;
}
function dispatchFocusInOnPopup(): void {
const listeners = documentListeners.get('focusin') ?? [];
const focusEvent = {
@@ -417,6 +448,7 @@ function installKeyboardTestGlobals() {
dispatchKeydown,
dispatchDocumentMouseDown,
dispatchMousedown,
dispatchWheel,
dispatchFocusInOnPopup,
dispatchWindowEvent,
setPopupVisible: (value: boolean) => {
@@ -1102,6 +1134,95 @@ test('configured mouse button keybinding dispatches through overlay mouse handli
}
});
test('configured wheel keybinding fires once per whole notch', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Ctrl+WHEEL_UP',
key: { code: 'WHEEL_UP', modifiers: ['ctrl'] },
actionType: 'mpv-command',
command: ['add', 'sub-scale', 0.1],
},
] as never);
assert.equal(testGlobals.dispatchWheel({ deltaY: -240, ctrlKey: true }), true);
testGlobals.dispatchWheel({ deltaY: -60, ctrlKey: true });
testGlobals.dispatchWheel({ deltaY: -60, ctrlKey: true });
testGlobals.dispatchWheel({ deltaY: -120 });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands, [
['add', 'sub-scale', 0.1],
['add', 'sub-scale', 0.1],
['add', 'sub-scale', 0.1],
]);
} finally {
testGlobals.restore();
}
});
test('imported mpv wheel bindings forward from the overlay but not over modals', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(async () => ({
keys: ['WHEEL_UP', 'WHEEL_DOWN'],
blockedKeys: [],
}));
await handlers.setupMpvInputForwarding();
await wait(0);
testGlobals.dispatchWheel({ deltaY: 120 });
testGlobals.dispatchWheel({ deltaY: -120, target: testGlobals.createInteractiveTarget() });
assert.deepEqual(testGlobals.mpvCommands, [['keypress', 'WHEEL_DOWN', 1]]);
} finally {
testGlobals.restore();
}
});
test('imported mpv mouse buttons forward only after SubMiner mouse handling', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(async () => ({
keys: ['MBTN_LEFT_DBL', 'MBTN_RIGHT', 'MBTN_BACK'],
blockedKeys: [],
}));
testGlobals.setSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'MBTN_BACK',
key: { code: 'MBTN_BACK', modifiers: [] },
actionType: 'mpv-command',
command: ['sub-seek', -1],
},
]);
await handlers.setupMpvInputForwarding();
await wait(0);
testGlobals.dispatchMousedown({ button: 0 });
testGlobals.dispatchMousedown({ button: 0, type: 'mouseup' });
testGlobals.dispatchMousedown({ button: 0, target: testGlobals.createInteractiveTarget() });
testGlobals.dispatchMousedown({ button: 2 });
testGlobals.dispatchMousedown({ button: 3 });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands, [
['keydown', 'MBTN_LEFT'],
['keyup', 'MBTN_LEFT'],
['sub-seek', -1],
['cycle', 'pause'],
]);
} finally {
testGlobals.restore();
}
});
test('configured subtitle-jump keybinding preserves pause when pause state is unknown', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
+68 -31
View File
@@ -1,6 +1,7 @@
import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '../../types';
import type { RendererContext } from '../context';
import { createMpvInputForwarding } from './mpv-input-forwarding';
import { MPV_MOUSE_BUTTON_BY_BUTTON, wheelEventToMpvWheel } from '../../shared/mpv-input-bindings';
import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
@@ -43,13 +44,6 @@ export function createKeyboardHandlers(
const CHORD_TIMEOUT_MS = 1000;
const MPV_INPUT_FORWARDING_CONFIG_LOAD_TIMEOUT_MS = 50;
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
const MOUSE_BUTTON_CODE_BY_BUTTON: Record<number, string> = {
0: 'MBTN_LEFT',
1: 'MBTN_MID',
2: 'MBTN_RIGHT',
3: 'MBTN_BACK',
4: 'MBTN_FORWARD',
};
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
let pendingLookupRefreshAfterSubtitleSeek = false;
let resetSelectionToStartOnNextSubtitleSync = false;
@@ -58,6 +52,8 @@ export function createKeyboardHandlers(
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple';
timeout: ReturnType<typeof setTimeout> | null;
} | null = null;
// Fractional wheel notches (trackpads) carried toward the next configured wheel binding.
let pendingWheelBinding: { key: string; notches: number } | null = null;
let mpvInputForwardingListenersInstalled = false;
let keyboardConfigLoaded = false;
const importedMpvBindings = createMpvInputForwarding({
@@ -89,20 +85,8 @@ export function createKeyboardHandlers(
return false;
}
function keyEventToString(e: KeyboardEvent): string {
const parts: string[] = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
if (e.metaKey) parts.push('Meta');
parts.push(e.code);
return parts.join('+');
}
function mouseEventToString(e: MouseEvent): string | null {
const code = MOUSE_BUTTON_CODE_BY_BUTTON[e.button];
if (!code) return null;
// Builds the session binding map key (`Ctrl+Shift+KeyR`) for an input event.
function inputEventToString(e: KeyboardEvent | MouseEvent, code: string): string {
const parts: string[] = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
@@ -112,6 +96,60 @@ export function createKeyboardHandlers(
return parts.join('+');
}
function keyEventToString(e: KeyboardEvent): string {
return inputEventToString(e, e.code);
}
function mouseEventToString(e: MouseEvent): string | null {
const code = MPV_MOUSE_BUTTON_BY_BUTTON[e.button];
return code ? inputEventToString(e, code) : null;
}
// Overlay UI that handles its own mouse input (menus, sidebar, notifications, controls).
function isOverlayControlTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return Boolean(
target.closest(
'.modal, .notification-history, .overlay-notification-stack, button, a, input, select, textarea',
),
);
}
// Imported mpv bindings only run when no SubMiner UI could be the input's target.
function canForwardToMpv(target: EventTarget | null): boolean {
return (
keyboardConfigLoaded &&
!ctx.state.playlistBrowserModalOpen &&
!ctx.state.youtubePickerModalOpen &&
!ctx.state.subtitleSidebarModalOpen &&
!ctx.state.yomitanPopupVisible &&
!isYomitanPopupVisible(document) &&
!isInteractiveTarget(target)
);
}
// Scrolling over subtitles still reaches mpv; only scrollable overlay UI keeps the wheel.
function handleWheel(e: WheelEvent): void {
if (isOverlayControlTarget(e.target)) return;
const scroll = wheelEventToMpvWheel(e);
if (!scroll) return;
const wheelString = inputEventToString(e, scroll.key);
const binding = ctx.state.sessionBindingMap.get(wheelString);
if (binding) {
e.preventDefault();
// SubMiner actions are not scalable, so fire once per whole notch.
const notches =
(pendingWheelBinding?.key === wheelString ? pendingWheelBinding.notches : 0) +
scroll.notches;
const presses = Math.floor(notches + 1e-6); // absorb float drift from summed deltas
pendingWheelBinding = { key: wheelString, notches: Math.max(0, notches - presses) };
for (let i = 0; i < presses; i++) dispatchSessionBinding(binding);
return;
}
pendingWheelBinding = null;
if (keyboardConfigLoaded) importedMpvBindings.wheel(e);
}
function updateConfiguredShortcuts(
shortcuts: Required<ShortcutsConfig>,
statsToggleKey?: string,
@@ -1290,16 +1328,7 @@ export function createKeyboardHandlers(
e.preventDefault();
return;
}
if (
keyboardConfigLoaded &&
!ctx.state.playlistBrowserModalOpen &&
!ctx.state.youtubePickerModalOpen &&
!ctx.state.subtitleSidebarModalOpen &&
!ctx.state.yomitanPopupVisible &&
!isYomitanPopupVisible(document) &&
!isInteractiveTarget(e.target)
)
importedMpvBindings.keydown(e);
if (canForwardToMpv(e.target)) importedMpvBindings.keydown(e);
});
document.addEventListener('mousedown', (e: MouseEvent) => {
@@ -1319,8 +1348,16 @@ export function createKeyboardHandlers(
.finally(() => {
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
});
return;
}
if (canForwardToMpv(e.target) && !isOverlayControlTarget(e.target)) {
importedMpvBindings.mousedown(e);
}
});
document.addEventListener('mouseup', importedMpvBindings.mouseup, true);
document.addEventListener('wheel', handleWheel, { passive: false });
document.addEventListener('contextmenu', (e: Event) => {
if (!isInteractiveTarget(e.target)) {
@@ -59,6 +59,71 @@ test('configured and disabled keys, handled input, and unknown keys are not forw
assert.deepEqual(commands, []);
});
test('imported wheel bindings forward as scaled keypresses unless SubMiner claims them', async () => {
const commands: (string | number)[][] = [];
const forwarding = createMpvInputForwarding({
load: async () => ({
keys: ['WHEEL_UP', 'WHEEL_DOWN', 'shift+WHEEL_UP'],
blockedKeys: [{ code: 'WHEEL_DOWN', modifiers: [] }],
}),
send: (command) => commands.push(command),
});
await forwarding.refresh();
const wheelEvent = {
deltaX: 0,
deltaY: -60,
deltaMode: 0,
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
defaultPrevented: false,
preventDefault: () => {},
};
assert.equal(forwarding.wheel(wheelEvent), true);
assert.equal(forwarding.wheel({ ...wheelEvent, deltaY: -120, shiftKey: true }), true);
assert.equal(forwarding.wheel({ ...wheelEvent, deltaY: 120 }), false);
assert.equal(forwarding.wheel({ ...wheelEvent, deltaX: 120, deltaY: 0 }), false);
assert.equal(forwarding.wheel({ ...wheelEvent, defaultPrevented: true }), false);
assert.deepEqual(commands, [
['keypress', 'WHEEL_UP', 0.5],
['keypress', 'shift+WHEEL_UP', 1],
]);
});
test('mouse buttons forward as held keys when mpv binds the button or its double-click', async () => {
const commands: (string | number)[][] = [];
const forwarding = createMpvInputForwarding({
load: async () => ({
keys: ['MBTN_LEFT_DBL', 'MBTN_BACK', 'MBTN_FORWARD'],
blockedKeys: [{ code: 'MBTN_FORWARD', modifiers: [] }],
}),
send: (command) => commands.push(command),
});
await forwarding.refresh();
const mouseEvent = {
button: 0,
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
defaultPrevented: false,
preventDefault: () => {},
};
assert.equal(forwarding.mousedown(mouseEvent), true);
forwarding.mouseup(mouseEvent);
assert.equal(forwarding.mousedown({ ...mouseEvent, button: 3 }), true);
forwarding.releaseAll();
assert.equal(forwarding.mousedown({ ...mouseEvent, button: 4 }), false);
assert.equal(forwarding.mousedown({ ...mouseEvent, button: 1 }), false);
assert.deepEqual(commands, [
['keydown', 'MBTN_LEFT'],
['keyup', 'MBTN_LEFT'],
['keydown', 'MBTN_BACK'],
['keyup', 'MBTN_BACK'],
]);
});
test('refresh discards stale responses and coalesces concurrent requests', async () => {
let resolveFirst: (snapshot: MpvInputBindingsSnapshot) => void = () => {};
let requests = 0;
+80 -13
View File
@@ -1,8 +1,19 @@
import { keyboardEventToMpvKey } from '../../shared/mpv-input-bindings';
import {
MPV_MOUSE_BUTTON_BY_BUTTON,
keyboardEventToMpvKey,
normalizeMpvInputKey,
wheelEventToMpvWheel,
} from '../../shared/mpv-input-bindings';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
type ModifierState = Pick<KeyboardEvent, 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey'>;
type ForwardedKeyEvent = Parameters<typeof keyboardEventToMpvKey>[0] &
Pick<KeyboardEvent, 'code' | 'repeat' | 'defaultPrevented' | 'preventDefault'>;
type ForwardedWheelEvent = Parameters<typeof wheelEventToMpvWheel>[0] &
ModifierState &
Pick<WheelEvent, 'defaultPrevented' | 'preventDefault'>;
type ForwardedMouseEvent = ModifierState &
Pick<MouseEvent, 'button' | 'defaultPrevented' | 'preventDefault'>;
export function createMpvInputForwarding(deps: {
load: () => Promise<MpvInputBindingsSnapshot>;
@@ -47,6 +58,30 @@ export function createMpvInputForwarding(deps: {
return pending;
}
// Keys claimed by SubMiner's configured keybindings stay with SubMiner.
function isBlocked(code: string, event: ModifierState): boolean {
return blockedKeys.some(
({ code: blockedCode, modifiers }) =>
blockedCode === code &&
modifiers.includes('ctrl') === event.ctrlKey &&
modifiers.includes('alt') === event.altKey &&
modifiers.includes('shift') === event.shiftKey &&
modifiers.includes('meta') === event.metaKey,
);
}
function modifiedMpvKey(event: ModifierState, key: string): string | null {
return normalizeMpvInputKey(
[
...(event.ctrlKey ? ['ctrl'] : []),
...(event.altKey ? ['alt'] : []),
...(event.shiftKey ? ['shift'] : []),
...(event.metaKey ? ['meta'] : []),
key,
].join('+'),
);
}
function keydown(event: ForwardedKeyEvent): boolean {
if (disposed || event.defaultPrevented) return false;
if (heldKeys.has(event.code)) {
@@ -54,17 +89,7 @@ export function createMpvInputForwarding(deps: {
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;
if (isBlocked(event.code, event)) return false;
const key = keyboardEventToMpvKey(event);
if (!key || !keys.has(key)) return false;
heldKeys.set(event.code, key);
@@ -81,11 +106,53 @@ export function createMpvInputForwarding(deps: {
event.preventDefault();
}
// Wheel scrolls are single events, so they go through mpv's keypress with the notch
// count as scale, matching how mpv handles precise scrolling natively.
function wheel(event: ForwardedWheelEvent): boolean {
if (disposed || event.defaultPrevented) return false;
const scroll = wheelEventToMpvWheel(event);
if (!scroll || isBlocked(scroll.key, event)) return false;
const key = modifiedMpvKey(event, scroll.key);
if (!key || !keys.has(key)) return false;
deps.send(['keypress', key, scroll.notches]);
event.preventDefault();
return true;
}
// Buttons go through keydown/keyup so held-button bindings and mpv's own double-click
// detection (MBTN_LEFT_DBL) work. A button is forwarded when mpv binds it or its
// double-click.
function mousedown(event: ForwardedMouseEvent): boolean {
if (disposed || event.defaultPrevented) return false;
const heldId = `mouse:${event.button}`;
if (heldKeys.has(heldId)) {
event.preventDefault();
return true;
}
const button = MPV_MOUSE_BUTTON_BY_BUTTON[event.button];
if (!button || isBlocked(button, event)) return false;
const key = modifiedMpvKey(event, button);
if (!key || (!keys.has(key) && !keys.has(`${key}_DBL`))) return false;
heldKeys.set(heldId, key);
deps.send(['keydown', key]);
event.preventDefault();
return true;
}
function mouseup(event: Pick<MouseEvent, 'button' | 'preventDefault'>): void {
const heldId = `mouse:${event.button}`;
const key = heldKeys.get(heldId);
if (!key) return;
heldKeys.delete(heldId);
deps.send(['keyup', key]);
event.preventDefault();
}
function dispose(): void {
disposed = true;
keys.clear();
releaseAll();
}
return { refresh, keydown, keyup, releaseAll, dispose };
return { refresh, keydown, keyup, wheel, mousedown, mouseup, releaseAll, dispose };
}