mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
feat(overlay): forward mpv mouse button and wheel bindings (#279)
This commit is contained in:
1 parent
9fadbc8cdd
commit
9e79fa93c2
18 files changed
+500
-76
No files matched your search
@@ -212,12 +212,13 @@ test('compileSessionBindings resolves CommandOrControl in DOM key strings per pl
|
||||
);
|
||||
});
|
||||
|
||||
test('compileSessionBindings supports mpv mouse button keybindings', () => {
|
||||
test('compileSessionBindings supports mpv mouse button and wheel keybindings', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts(),
|
||||
keybindings: [
|
||||
createKeybinding('MBTN_BACK', ['sub-seek', -1]),
|
||||
createKeybinding('Shift+MBTN_FORWARD', ['sub-seek', 1]),
|
||||
createKeybinding('Ctrl+WHEEL_UP', ['add', 'sub-scale', 0.1]),
|
||||
],
|
||||
platform: 'win32',
|
||||
});
|
||||
@@ -232,6 +233,7 @@ test('compileSessionBindings supports mpv mouse button keybindings', () => {
|
||||
[
|
||||
{ code: 'MBTN_BACK', modifiers: [], command: ['sub-seek', -1] },
|
||||
{ code: 'MBTN_FORWARD', modifiers: ['shift'], command: ['sub-seek', 1] },
|
||||
{ code: 'WHEEL_UP', modifiers: ['ctrl'], command: ['add', 'sub-scale', 0.1] },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,12 +34,16 @@ type DraftBinding = {
|
||||
};
|
||||
|
||||
const MODIFIER_ORDER: SessionKeyModifier[] = ['ctrl', 'alt', 'shift', 'meta'];
|
||||
const MPV_MOUSE_BUTTON_CODES = new Set([
|
||||
const MPV_MOUSE_CODES = new Set([
|
||||
'MBTN_LEFT',
|
||||
'MBTN_MID',
|
||||
'MBTN_RIGHT',
|
||||
'MBTN_BACK',
|
||||
'MBTN_FORWARD',
|
||||
'WHEEL_UP',
|
||||
'WHEEL_DOWN',
|
||||
'WHEEL_LEFT',
|
||||
'WHEEL_RIGHT',
|
||||
]);
|
||||
|
||||
const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||
@@ -82,7 +86,7 @@ function isValidCommandEntry(value: unknown): value is string | number {
|
||||
|
||||
function normalizeCodeToken(
|
||||
token: string,
|
||||
options: { allowMouseButtons?: boolean } = {},
|
||||
options: { allowMouseInput?: boolean } = {},
|
||||
): string | null {
|
||||
const normalized = token.trim();
|
||||
if (!normalized) return null;
|
||||
@@ -93,9 +97,9 @@ function normalizeCodeToken(
|
||||
.map((letter) => `Key${letter.toUpperCase()}`)
|
||||
.join('-');
|
||||
}
|
||||
if (options.allowMouseButtons === true) {
|
||||
if (options.allowMouseInput === true) {
|
||||
const normalizedMouse = normalized.toUpperCase();
|
||||
if (MPV_MOUSE_BUTTON_CODES.has(normalizedMouse)) {
|
||||
if (MPV_MOUSE_CODES.has(normalizedMouse)) {
|
||||
return normalizedMouse;
|
||||
}
|
||||
}
|
||||
@@ -270,7 +274,7 @@ export function parseSessionBindingKey(
|
||||
};
|
||||
}
|
||||
|
||||
const code = normalizeCodeToken(keyToken, { allowMouseButtons: true });
|
||||
const code = normalizeCodeToken(keyToken, { allowMouseInput: true });
|
||||
if (!code) {
|
||||
return {
|
||||
key: null,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -71,6 +71,10 @@ const KEY_NAME_MAP: Record<string, string> = {
|
||||
MBTN_RIGHT: 'Mouse Right',
|
||||
MBTN_BACK: 'Mouse Back',
|
||||
MBTN_FORWARD: 'Mouse Forward',
|
||||
WHEEL_UP: 'Wheel Up',
|
||||
WHEEL_DOWN: 'Wheel Down',
|
||||
WHEEL_LEFT: 'Wheel Left',
|
||||
WHEEL_RIGHT: 'Wheel Right',
|
||||
};
|
||||
|
||||
function normalizeKeyToken(token: string): string {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createMpvKeybindingRows,
|
||||
keyboardEventToConfigKey,
|
||||
mouseEventToConfigKey,
|
||||
wheelEventToConfigKey,
|
||||
} from './key-input';
|
||||
|
||||
test('keyboardEventToConfigKey formats Electron accelerators from learned input', () => {
|
||||
@@ -93,6 +94,21 @@ test('mouseEventToConfigKey formats mpv mouse buttons from learned input', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('wheelEventToConfigKey formats mpv wheel keys only for mpv keybindings', () => {
|
||||
const wheel = {
|
||||
deltaX: 0,
|
||||
deltaY: -120,
|
||||
deltaMode: 0,
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
};
|
||||
assert.equal(wheelEventToConfigKey(wheel, 'dom-code'), 'Ctrl+WHEEL_UP');
|
||||
assert.equal(wheelEventToConfigKey({ ...wheel, deltaY: 0 }, 'dom-code'), null);
|
||||
assert.equal(wheelEventToConfigKey(wheel, 'accelerator'), null);
|
||||
});
|
||||
|
||||
test('MPV keybinding rows save default key moves as a disable plus replacement', () => {
|
||||
const defaults: Keybinding[] = [{ key: 'Space', command: ['cycle', 'pause'] }];
|
||||
const rows = createMpvKeybindingRows(defaults, []);
|
||||
|
||||
+19
-13
@@ -1,4 +1,5 @@
|
||||
import type { Keybinding } from '../types/runtime';
|
||||
import { MPV_MOUSE_BUTTON_BY_BUTTON, wheelEventToMpvWheel } from '../shared/mpv-input-bindings';
|
||||
|
||||
export type KeyInputMode = 'accelerator' | 'dom-code' | 'code' | 'mpv-key';
|
||||
|
||||
@@ -19,6 +20,9 @@ export interface MouseInputLike {
|
||||
metaKey: boolean;
|
||||
}
|
||||
|
||||
export type WheelInputLike = Omit<MouseInputLike, 'button'> &
|
||||
Parameters<typeof wheelEventToMpvWheel>[0];
|
||||
|
||||
export interface MpvKeybindingRow {
|
||||
defaultKey: string;
|
||||
key: string;
|
||||
@@ -87,14 +91,6 @@ const MPV_KEY_BY_CODE: Record<string, string> = {
|
||||
Tab: 'TAB',
|
||||
};
|
||||
|
||||
const MPV_MOUSE_BUTTON_BY_BUTTON: Record<number, string> = {
|
||||
0: 'MBTN_LEFT',
|
||||
1: 'MBTN_MID',
|
||||
2: 'MBTN_RIGHT',
|
||||
3: 'MBTN_BACK',
|
||||
4: 'MBTN_FORWARD',
|
||||
};
|
||||
|
||||
function commandEquals(a: Keybinding['command'], b: Keybinding['command']): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
@@ -169,12 +165,22 @@ export function keyboardEventToConfigKey(
|
||||
}
|
||||
|
||||
export function mouseEventToConfigKey(input: MouseInputLike, mode: KeyInputMode): string | null {
|
||||
if (mode !== 'dom-code') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = MPV_MOUSE_BUTTON_BY_BUTTON[input.button];
|
||||
if (!key) {
|
||||
return key ? mouseInputToConfigKey(input, key, mode) : null;
|
||||
}
|
||||
|
||||
export function wheelEventToConfigKey(input: WheelInputLike, mode: KeyInputMode): string | null {
|
||||
const key = wheelEventToMpvWheel(input)?.key;
|
||||
return key ? mouseInputToConfigKey(input, key, mode) : null;
|
||||
}
|
||||
|
||||
// Mouse input is only bindable through mpv keybindings, which use DOM-code keys.
|
||||
function mouseInputToConfigKey(
|
||||
input: Omit<MouseInputLike, 'button'>,
|
||||
key: string,
|
||||
mode: KeyInputMode,
|
||||
): string | null {
|
||||
if (mode !== 'dom-code') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
keyboardEventToConfigKey,
|
||||
mouseEventToConfigKey,
|
||||
parseMpvCommandText,
|
||||
wheelEventToConfigKey,
|
||||
type KeyInputMode,
|
||||
type MpvKeybindingRow,
|
||||
} from './key-input';
|
||||
@@ -43,11 +44,13 @@ function startKeyLearning(
|
||||
let onKeyDown: (event: KeyboardEvent) => void;
|
||||
let onBlur: () => void;
|
||||
let onMouseDown: (event: MouseEvent) => void;
|
||||
let onWheel: (event: WheelEvent) => void;
|
||||
|
||||
const stop = (): void => {
|
||||
window.removeEventListener('keydown', onKeyDown, true);
|
||||
window.removeEventListener('blur', onBlur, true);
|
||||
window.removeEventListener('mousedown', onMouseDown, true);
|
||||
window.removeEventListener('wheel', onWheel, true);
|
||||
button.classList.remove('learning');
|
||||
if (button.textContent === 'Press Keys...') {
|
||||
button.textContent = previousText;
|
||||
@@ -85,9 +88,19 @@ function startKeyLearning(
|
||||
}
|
||||
};
|
||||
|
||||
onWheel = (event: WheelEvent): void => {
|
||||
const next = wheelEventToConfigKey(event, mode);
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
stop();
|
||||
onValue(next);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
window.addEventListener('blur', onBlur, true);
|
||||
window.addEventListener('mousedown', onMouseDown, true);
|
||||
window.addEventListener('wheel', onWheel, { capture: true, passive: false });
|
||||
activeKeyLearningStop = stop;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,19 @@ import {
|
||||
keyboardEventToMpvKey,
|
||||
normalizeMpvInputKey,
|
||||
parseMpvInputBindingKeys,
|
||||
wheelEventToMpvWheel,
|
||||
} from './mpv-input-bindings';
|
||||
|
||||
test('mpv discovery validates entries and excludes inactive, mouse, sequence, and SubMiner keys', () => {
|
||||
test('mpv discovery validates entries and excludes inactive, 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: 'MBTN_LEFT_DBL', cmd: 'cycle fullscreen', priority: 1 },
|
||||
{ key: 'MOUSE_MOVE', cmd: 'script-binding osc/move', priority: 1 },
|
||||
{ key: 'Shift+WHEEL_UP', cmd: 'add volume 2', 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' },
|
||||
@@ -22,7 +25,7 @@ test('mpv discovery validates entries and excludes inactive, mouse, sequence, an
|
||||
{ key: 'z', cmd: 5, priority: 1 },
|
||||
null,
|
||||
]),
|
||||
['r', 'ctrl+A'],
|
||||
['r', 'ctrl+A', 'MBTN_LEFT_DBL', 'shift+WHEEL_UP'],
|
||||
);
|
||||
assert.deepEqual(parseMpvInputBindingKeys({ key: 'r' }), []);
|
||||
});
|
||||
@@ -33,6 +36,27 @@ test('mpv keys retain printable characters and normalize modifiers', () => {
|
||||
assert.equal(normalizeMpvInputKey('Shift+LEFT'), 'shift+LEFT');
|
||||
assert.equal(normalizeMpvInputKey('F12'), 'F12');
|
||||
assert.equal(normalizeMpvInputKey('UNMAPPED'), null);
|
||||
assert.equal(normalizeMpvInputKey('Ctrl+WHEEL_DOWN'), 'ctrl+WHEEL_DOWN');
|
||||
});
|
||||
|
||||
test('wheel conversion picks the dominant axis and reports mpv notch scale', () => {
|
||||
assert.deepEqual(wheelEventToMpvWheel({ deltaX: 0, deltaY: -240, deltaMode: 0 }), {
|
||||
key: 'WHEEL_UP',
|
||||
notches: 2,
|
||||
});
|
||||
assert.deepEqual(wheelEventToMpvWheel({ deltaX: 3, deltaY: 30, deltaMode: 0 }), {
|
||||
key: 'WHEEL_DOWN',
|
||||
notches: 0.25,
|
||||
});
|
||||
assert.deepEqual(wheelEventToMpvWheel({ deltaX: 120, deltaY: 0, deltaMode: 0 }), {
|
||||
key: 'WHEEL_RIGHT',
|
||||
notches: 1,
|
||||
});
|
||||
assert.deepEqual(wheelEventToMpvWheel({ deltaX: -3, deltaY: 0, deltaMode: 1 }), {
|
||||
key: 'WHEEL_LEFT',
|
||||
notches: 1,
|
||||
});
|
||||
assert.equal(wheelEventToMpvWheel({ deltaX: 0, deltaY: 0, deltaMode: 0 }), null);
|
||||
});
|
||||
|
||||
test('keyboard conversion respects layout characters and skips composition and AltGr', () => {
|
||||
|
||||
@@ -16,13 +16,33 @@ const SPECIAL_KEYS: Record<string, string> = {
|
||||
ArrowUp: 'UP',
|
||||
ArrowDown: 'DOWN',
|
||||
};
|
||||
const MPV_SPECIAL_KEYS = new Set(Object.values(SPECIAL_KEYS));
|
||||
const MPV_WHEEL_KEYS = ['WHEEL_UP', 'WHEEL_DOWN', 'WHEEL_LEFT', 'WHEEL_RIGHT'] as const;
|
||||
export type MpvWheelKey = (typeof MPV_WHEEL_KEYS)[number];
|
||||
// DOM MouseEvent.button to mpv's mouse button names.
|
||||
export const MPV_MOUSE_BUTTON_BY_BUTTON: Readonly<Record<number, string>> = {
|
||||
0: 'MBTN_LEFT',
|
||||
1: 'MBTN_MID',
|
||||
2: 'MBTN_RIGHT',
|
||||
3: 'MBTN_BACK',
|
||||
4: 'MBTN_FORWARD',
|
||||
};
|
||||
// mpv synthesizes these from two quick presses of the base button.
|
||||
const MPV_DOUBLE_CLICK_KEYS = ['MBTN_LEFT_DBL', 'MBTN_MID_DBL', 'MBTN_RIGHT_DBL'];
|
||||
const MPV_SPECIAL_KEYS = new Set<string>([
|
||||
...Object.values(SPECIAL_KEYS),
|
||||
...MPV_WHEEL_KEYS,
|
||||
...Object.values(MPV_MOUSE_BUTTON_BY_BUTTON),
|
||||
...MPV_DOUBLE_CLICK_KEYS,
|
||||
]);
|
||||
// Chromium reports 120 px per wheel notch on both Wayland and X11.
|
||||
const WHEEL_NOTCH_PIXELS = 120;
|
||||
const WHEEL_NOTCH_LINES = 3;
|
||||
// Leading command flags accepted by mpv's input/cmd.c, before the command name.
|
||||
const MPV_COMMAND_PREFIXES =
|
||||
/^(?:(?:no-osd|osd-bar|osd-msg|osd-msg-bar|osd-auto|expand-properties|raw|repeatable|nonrepeatable|nonscalable|async|sync)\s+)+/;
|
||||
|
||||
// Only single keyboard strokes are imported. Mouse input and sequences need
|
||||
// their own focus and conflict rules before they can be forwarded safely.
|
||||
// Single keyboard strokes, mouse buttons, and wheel scrolls are imported. Key sequences
|
||||
// and pointer motion (MOUSE_MOVE) are not.
|
||||
export function normalizeMpvInputKey(value: string): string | null {
|
||||
const modifiers = new Set<string>();
|
||||
let key = value;
|
||||
@@ -59,6 +79,27 @@ export function keyboardEventToMpvKey(
|
||||
return normalizeMpvInputKey([...modifiers, key].join('+'));
|
||||
}
|
||||
|
||||
// Maps a DOM wheel event to mpv's wheel key and its notch count, which mpv uses as the
|
||||
// precise-scroll scale for `keypress <key> <scale>`. Trackpads yield fractional notches.
|
||||
export function wheelEventToMpvWheel(
|
||||
event: Pick<WheelEvent, 'deltaX' | 'deltaY' | 'deltaMode'>,
|
||||
): { key: MpvWheelKey; notches: number } | null {
|
||||
const vertical = Math.abs(event.deltaY) >= Math.abs(event.deltaX);
|
||||
const delta = vertical ? event.deltaY : event.deltaX;
|
||||
if (!Number.isFinite(delta) || delta === 0) return null;
|
||||
const key: MpvWheelKey = vertical
|
||||
? delta < 0
|
||||
? 'WHEEL_UP'
|
||||
: 'WHEEL_DOWN'
|
||||
: delta < 0
|
||||
? 'WHEEL_LEFT'
|
||||
: 'WHEEL_RIGHT';
|
||||
// deltaMode: 0 = pixels, 1 = lines, 2 = pages.
|
||||
const unit =
|
||||
event.deltaMode === 1 ? WHEEL_NOTCH_LINES : event.deltaMode === 2 ? 1 : WHEEL_NOTCH_PIXELS;
|
||||
return { key, notches: Math.abs(delta) / unit };
|
||||
}
|
||||
|
||||
export function parseMpvInputBindingKeys(
|
||||
value: unknown,
|
||||
{ includeIgnored = true }: { includeIgnored?: boolean } = {},
|
||||
|
||||
Reference in new issue
Block a user