mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-01 05:40:50 -07:00
feat(overlay): forward mpv mouse button and wheel bindings (#279)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`, with modifiers) to `keybindings`, including capture from the settings key editor.
|
||||
- Mouse button and scroll wheel bindings from mpv (`input.conf` and mpv defaults, e.g. double-click fullscreen, wheel volume, back/forward for playlist) now work while the cursor is over the overlay. On Hyprland, where the overlay always receives input, they previously did nothing. SubMiner bindings and the right-click pause still take priority.
|
||||
@@ -260,10 +260,10 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
|
||||
}
|
||||
```
|
||||
|
||||
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`.
|
||||
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`. Scroll wheel keys are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`.
|
||||
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
|
||||
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, and `__runtime-option-cycle:<id>[:next|prev]`.
|
||||
- Unused single-key bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
|
||||
- Unused single-key, mouse button, and scroll wheel bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
|
||||
|
||||
### Shortcuts configuration
|
||||
|
||||
|
||||
@@ -132,15 +132,16 @@ The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay
|
||||
"keybindings": [
|
||||
{ "key": "m", "command": ["cycle", "mute"] },
|
||||
{ "key": "MBTN_BACK", "command": ["sub-seek", -1] },
|
||||
{ "key": "Ctrl+WHEEL_UP", "command": ["add", "sub-scale", 0.1] },
|
||||
{ "key": "Space", "command": null },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
|
||||
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
|
||||
|
||||
## Automatic mpv bindings
|
||||
|
||||
The overlay reads single-key bindings from the running mpv (`input.conf`, mpv defaults, and scripts). If SubMiner does not handle a key, it passes it to mpv. SubMiner shortcuts and `keybindings` entries win, including ones set to `null`. Keys are not forwarded while you type in a text field, use an overlay menu, or have a Yomitan popup open.
|
||||
The overlay reads single-key, mouse button, and scroll wheel bindings from the running mpv (`input.conf`, mpv defaults, and scripts). If SubMiner does not handle the input, it passes it to mpv, so mpv's defaults like double-click for fullscreen and the wheel for volume work over the overlay. SubMiner shortcuts and `keybindings` entries win, including ones set to `null`, and right-click always pauses. Keys and clicks are not forwarded while you type in a text field, use an overlay menu, or have a Yomitan popup open, and clicks on subtitles or overlay controls stay with SubMiner. Scrolling over an overlay menu, the subtitle sidebar, or notification history scrolls that panel instead.
|
||||
|
||||
Mouse buttons, keypad and media keys, and key sequences are not imported. Bindings imported this way do not appear in session help. If you add an mpv binding while SubMiner runs, refocus the overlay to pick it up.
|
||||
Keypad and media keys, key sequences, and mouse movement are not imported. Bindings imported this way do not appear in session help. If you add an mpv binding while SubMiner runs, refocus the overlay to pick it up.
|
||||
|
||||
@@ -48,7 +48,12 @@ Automatic mpv keyboard discovery uses the `get-mpv-input-bindings` IPC request a
|
||||
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
|
||||
refreshes, and releases held keys on blur or disposal. Discovered `WHEEL_*` bindings forward
|
||||
as `keypress <key> <notches>` (Chromium reports 120 px per notch), which matches mpv's own
|
||||
precise-scroll scaling. `MBTN_*` buttons forward as held `keydown`/`keyup` when mpv binds the
|
||||
button or its `_DBL` variant, so mpv synthesizes double-clicks itself. Configured keybindings and
|
||||
the built-in right-click pause run first. Forwarding is the only way pointer input reaches mpv on
|
||||
Hyprland, where the overlay cannot be made click-through. `handlers/keyboard.ts` runs
|
||||
this fallback after SubMiner controls and refreshes on startup, a delayed startup
|
||||
pass, focus, binding reload, and the `mpv-input-bindings:changed` event. Main sends
|
||||
that event from `session-bindings-runtime.ts` when mpv's discovered key set changes,
|
||||
|
||||
@@ -29,6 +29,10 @@ local KEY_NAME_MAP = {
|
||||
MBTN_RIGHT = "MBTN_RIGHT",
|
||||
MBTN_BACK = "MBTN_BACK",
|
||||
MBTN_FORWARD = "MBTN_FORWARD",
|
||||
WHEEL_UP = "WHEEL_UP",
|
||||
WHEEL_DOWN = "WHEEL_DOWN",
|
||||
WHEEL_LEFT = "WHEEL_LEFT",
|
||||
WHEEL_RIGHT = "WHEEL_RIGHT",
|
||||
}
|
||||
|
||||
local MODIFIER_MAP = {
|
||||
|
||||
@@ -291,6 +291,14 @@ local ctx = {
|
||||
actionType = "mpv-command",
|
||||
command = { "sub-seek", -1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "WHEEL_UP",
|
||||
modifiers = { "ctrl" },
|
||||
},
|
||||
actionType = "mpv-command",
|
||||
command = { "add", "sub-scale", 0.1 },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyW",
|
||||
@@ -386,6 +394,7 @@ local expected_mpv_bindings = {
|
||||
{ keys = "q", command = { "quit" } },
|
||||
{ keys = "Ctrl+w", command = { "quit" } },
|
||||
{ keys = "MBTN_BACK", command = { "sub-seek", -1 } },
|
||||
{ keys = "Ctrl+WHEEL_UP", command = { "add", "sub-scale", 0.1 } },
|
||||
}
|
||||
|
||||
for _, expected in ipairs(expected_mpv_bindings) do
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
+18
-12
@@ -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];
|
||||
return key ? mouseInputToConfigKey(input, key, mode) : null;
|
||||
}
|
||||
|
||||
const key = MPV_MOUSE_BUTTON_BY_BUTTON[input.button];
|
||||
if (!key) {
|
||||
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