mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-20 05:16:18 -07:00
feat(overlay): discover unclaimed mpv key bindings (#246)
This commit is contained in:
@@ -61,6 +61,7 @@ export const IPC_CHANNELS = {
|
||||
getSubtitleStyle: 'get-subtitle-style',
|
||||
getMecabStatus: 'get-mecab-status',
|
||||
getKeybindings: 'get-keybindings',
|
||||
getMpvInputBindings: 'get-mpv-input-bindings',
|
||||
getSessionBindings: 'get-session-bindings',
|
||||
getConfigShortcuts: 'get-config-shortcuts',
|
||||
getStatsToggleKey: 'get-stats-toggle-key',
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
keyboardEventToMpvKey,
|
||||
normalizeMpvInputKey,
|
||||
parseMpvInputBindingKeys,
|
||||
} from './mpv-input-bindings';
|
||||
|
||||
test('mpv discovery validates entries and excludes inactive, mouse, 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: 'q', cmd: 'quit', priority: -1 },
|
||||
{ key: 's', cmd: 'screenshot', priority: 1 },
|
||||
{ key: 's', cmd: 'script-binding subminer/session', priority: 5, owner: 'subminer' },
|
||||
{ key: 't', cmd: 'script-message subminer-toggle', priority: 1 },
|
||||
{ key: 'x', cmd: 'ignore', priority: NaN },
|
||||
{ key: 'z', cmd: 5, priority: 1 },
|
||||
null,
|
||||
]),
|
||||
['r', 'ctrl+A'],
|
||||
);
|
||||
assert.deepEqual(parseMpvInputBindingKeys({ key: 'r' }), []);
|
||||
});
|
||||
|
||||
test('mpv keys retain printable characters and normalize modifiers', () => {
|
||||
assert.equal(normalizeMpvInputKey('Alt+Ctrl+Shift+a'), 'ctrl+alt+A');
|
||||
assert.equal(normalizeMpvInputKey('Ctrl++'), 'ctrl++');
|
||||
assert.equal(normalizeMpvInputKey('Shift+LEFT'), 'shift+LEFT');
|
||||
assert.equal(normalizeMpvInputKey('F12'), 'F12');
|
||||
assert.equal(normalizeMpvInputKey('UNMAPPED'), null);
|
||||
});
|
||||
|
||||
test('keyboard conversion respects layout characters and skips composition and AltGr', () => {
|
||||
const event = {
|
||||
key: 'A',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
isComposing: false,
|
||||
getModifierState: () => false,
|
||||
};
|
||||
assert.equal(keyboardEventToMpvKey(event), 'ctrl+A');
|
||||
assert.equal(keyboardEventToMpvKey({ ...event, key: '#', ctrlKey: false }), 'SHARP');
|
||||
assert.equal(keyboardEventToMpvKey({ ...event, key: 'ArrowLeft', ctrlKey: false }), 'shift+LEFT');
|
||||
assert.equal(keyboardEventToMpvKey({ ...event, key: 'Dead' }), null);
|
||||
assert.equal(keyboardEventToMpvKey({ ...event, isComposing: true }), null);
|
||||
assert.equal(
|
||||
keyboardEventToMpvKey({ ...event, getModifierState: (key) => key === 'AltGraph' }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('only the highest-priority active binding determines SubMiner ownership', () => {
|
||||
const user = { key: 'r', cmd: 'script-binding replay/run', is_weak: false, priority: 12 };
|
||||
const plugin = {
|
||||
key: 'r',
|
||||
cmd: 'script-binding subminer/run',
|
||||
owner: 'subminer',
|
||||
is_weak: true,
|
||||
priority: 2,
|
||||
};
|
||||
assert.deepEqual(parseMpvInputBindingKeys([plugin, user]), ['r']);
|
||||
assert.deepEqual(parseMpvInputBindingKeys([user, plugin]), ['r']);
|
||||
assert.deepEqual(parseMpvInputBindingKeys([user, { ...plugin, priority: -1 }]), ['r']);
|
||||
assert.deepEqual(
|
||||
parseMpvInputBindingKeys([user, { ...plugin, is_weak: false, priority: 15 }]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('SubMiner ownership excludes only its script commands and respects explicit owners', () => {
|
||||
assert.deepEqual(
|
||||
parseMpvInputBindingKeys([
|
||||
{ key: 'a', cmd: 'show-text "subminer/readme"', priority: 1 },
|
||||
{ key: 'b', cmd: 'run subminer-helper', priority: 1 },
|
||||
{ key: 'c', cmd: 'script-message subminer-toggle', owner: 'other-script', priority: 1 },
|
||||
{ key: 'd', cmd: 'script-binding subminer/action', owner: 'other-script', priority: 1 },
|
||||
{ key: 'e', cmd: ' script-binding "subminer/action"', priority: 1 },
|
||||
{ key: 'f', cmd: 'script-message subminer-toggle', priority: 1 },
|
||||
{ key: 'g', cmd: 'ignore', owner: 'subminer', priority: 1 },
|
||||
]),
|
||||
['a', 'b', 'c', 'd'],
|
||||
);
|
||||
});
|
||||
|
||||
test('SubMiner ownership recognizes leading mpv prefixes without matching command arguments', () => {
|
||||
assert.deepEqual(
|
||||
parseMpvInputBindingKeys([
|
||||
{ key: 'a', cmd: 'no-osd script-binding subminer/action', priority: 1 },
|
||||
{ key: 'b', cmd: ' repeatable\tasync raw script-binding "subminer/action"', priority: 1 },
|
||||
{ key: 'c', cmd: 'osd-msg-bar sync script-message subminer-toggle', priority: 1 },
|
||||
{ key: 'd', cmd: 'no-osd show-text "script-binding subminer/action"', priority: 1 },
|
||||
{ key: 'e', cmd: 'show-text "no-osd script-binding subminer/action"', priority: 1 },
|
||||
{ key: 'f', cmd: 'no-osd script-binding other/action', priority: 1 },
|
||||
{ key: 'g', cmd: 'no-osd script-binding subminer/action', owner: 'other', priority: 1 },
|
||||
]),
|
||||
['d', 'e', 'f', 'g'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
const SPECIAL_KEYS: Record<string, string> = {
|
||||
' ': 'SPACE',
|
||||
'#': 'SHARP',
|
||||
Enter: 'ENTER',
|
||||
Escape: 'ESC',
|
||||
Backspace: 'BS',
|
||||
Tab: 'TAB',
|
||||
Delete: 'DEL',
|
||||
Insert: 'INS',
|
||||
Home: 'HOME',
|
||||
End: 'END',
|
||||
PageUp: 'PGUP',
|
||||
PageDown: 'PGDWN',
|
||||
ArrowLeft: 'LEFT',
|
||||
ArrowRight: 'RIGHT',
|
||||
ArrowUp: 'UP',
|
||||
ArrowDown: 'DOWN',
|
||||
};
|
||||
const MPV_SPECIAL_KEYS = new Set(Object.values(SPECIAL_KEYS));
|
||||
// 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.
|
||||
export function normalizeMpvInputKey(value: string): string | null {
|
||||
const modifiers = new Set<string>();
|
||||
let key = value;
|
||||
let modifier = /^(Shift|Ctrl|Alt|Meta)\+/i.exec(key);
|
||||
while (modifier?.[1]) {
|
||||
modifiers.add(modifier[1].toLowerCase());
|
||||
key = key.slice(modifier[0].length);
|
||||
modifier = /^(Shift|Ctrl|Alt|Meta)\+/i.exec(key);
|
||||
}
|
||||
if (key === 'SHARP') modifiers.delete('shift');
|
||||
if (!MPV_SPECIAL_KEYS.has(key) && !/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(key)) {
|
||||
if ([...key].length !== 1) return null;
|
||||
if (modifiers.has('shift') && /^[a-z]$/i.test(key)) key = key.toUpperCase();
|
||||
modifiers.delete('shift');
|
||||
}
|
||||
return [...['ctrl', 'alt', 'shift', 'meta'].filter((item) => modifiers.has(item)), key].join('+');
|
||||
}
|
||||
|
||||
export function keyboardEventToMpvKey(
|
||||
event: Pick<
|
||||
KeyboardEvent,
|
||||
'key' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey' | 'isComposing' | 'getModifierState'
|
||||
>,
|
||||
): string | null {
|
||||
if (event.isComposing || event.key === 'Dead' || event.getModifierState?.('AltGraph'))
|
||||
return null;
|
||||
const key = SPECIAL_KEYS[event.key] ?? event.key;
|
||||
const modifiers = [
|
||||
...(event.ctrlKey ? ['ctrl'] : []),
|
||||
...(event.altKey ? ['alt'] : []),
|
||||
...(event.shiftKey ? ['shift'] : []),
|
||||
...(event.metaKey ? ['meta'] : []),
|
||||
];
|
||||
return normalizeMpvInputKey([...modifiers, key].join('+'));
|
||||
}
|
||||
|
||||
export function parseMpvInputBindingKeys(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const bindings = new Map<string, { priority: number; owned: boolean }>();
|
||||
for (const candidate of value) {
|
||||
const entry: unknown = candidate;
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry !== 'object' ||
|
||||
!('key' in entry) ||
|
||||
typeof entry.key !== 'string' ||
|
||||
!('cmd' in entry) ||
|
||||
typeof entry.cmd !== 'string' ||
|
||||
!('priority' in entry) ||
|
||||
typeof entry.priority !== 'number' ||
|
||||
!Number.isFinite(entry.priority) ||
|
||||
entry.priority < 0
|
||||
)
|
||||
continue;
|
||||
const key = normalizeMpvInputKey(entry.key);
|
||||
if (!key) continue;
|
||||
const owner = 'owner' in entry ? entry.owner : undefined;
|
||||
const owned =
|
||||
owner === 'subminer' ||
|
||||
(owner === undefined &&
|
||||
/^(?:script-binding\s+["']?subminer\/|script-message\s+["']?subminer-)/.test(
|
||||
entry.cmd.trimStart().replace(MPV_COMMAND_PREFIXES, ''),
|
||||
));
|
||||
const previous = bindings.get(key);
|
||||
// mpv's reported priority already ranks active non-weak bindings above weak
|
||||
// bindings. Only the winning binding determines whether the key is imported.
|
||||
if (
|
||||
!previous ||
|
||||
entry.priority > previous.priority ||
|
||||
(entry.priority === previous.priority && owned)
|
||||
) {
|
||||
bindings.set(key, { priority: entry.priority, owned });
|
||||
}
|
||||
}
|
||||
return [...bindings].filter(([, binding]) => !binding.owned).map(([key]) => key);
|
||||
}
|
||||
Reference in New Issue
Block a user