feat(config): add configuration window (#70)

This commit is contained in:
2026-05-21 04:16:21 -07:00
committed by GitHub
parent a54f03f0cd
commit dc52bc2fba
287 changed files with 14507 additions and 8134 deletions
+114 -1
View File
@@ -1,4 +1,6 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js';
@@ -108,6 +110,7 @@ function installKeyboardTestGlobals() {
const mpvCommands: Array<Array<string | number>> = [];
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
let sessionBindings: CompiledSessionBinding[] = [];
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = async () => sessionBindings;
let playbackPausedResponse: boolean | null = false;
let statsToggleKey = 'Backquote';
let markWatchedKey = 'KeyW';
@@ -216,7 +219,7 @@ function installKeyboardTestGlobals() {
},
electronAPI: {
getKeybindings: async () => [],
getSessionBindings: async () => sessionBindings,
getSessionBindings: () => getSessionBindingsImpl(),
getConfiguredShortcuts: async () => configuredShortcuts,
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
@@ -366,6 +369,9 @@ function installKeyboardTestGlobals() {
setSessionBindings: (value: CompiledSessionBinding[]) => {
sessionBindings = value;
},
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
getSessionBindingsImpl = value;
},
setMarkActiveVideoWatchedResult: (value: boolean) => {
markActiveVideoWatchedResult = value;
},
@@ -462,6 +468,19 @@ function createKeyboardHandlerHarness() {
};
}
test('renderer installs keyboard forwarding before startup subtitle IPC awaits', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'src', 'renderer', 'renderer.ts'),
'utf8',
);
const keyboardSetupIndex = source.indexOf('await keyboardHandlers.setupMpvInputForwarding();');
const subtitleRequestIndex = source.indexOf('await window.electronAPI.getCurrentSubtitle();');
assert.notEqual(keyboardSetupIndex, -1);
assert.notEqual(subtitleRequestIndex, -1);
assert.equal(keyboardSetupIndex < subtitleRequestIndex, true);
});
test('primary subtitle visibility key cycles modes with primary OSD without mpv sub-visibility', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -498,6 +517,76 @@ test('primary subtitle visibility key cycles modes with primary OSD without mpv
}
});
test('mpv input forwarding installs local key handling when session binding IPC stalls', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetSessionBindings(() => new Promise<CompiledSessionBinding[]>(() => {}));
const setupResult = await Promise.race([
handlers.setupMpvInputForwarding().then(() => 'resolved'),
wait(75).then(() => 'pending'),
]);
assert.equal(setupResult, 'resolved');
testGlobals.dispatchKeydown({ key: '`', code: 'Backquote' });
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
} finally {
testGlobals.restore();
}
});
test('mpv input forwarding waits for session bindings before resolving setup', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetSessionBindings(async () => {
await wait(20);
return [
{
sourcePath: 'keybindings[0].key',
originalKey: 'KeyH',
key: { code: 'KeyH', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
] as CompiledSessionBinding[];
});
await handlers.setupMpvInputForwarding();
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
bindingKey: 'KeyK',
fallbackUsed: true,
fallbackUnavailable: false,
});
} finally {
testGlobals.restore();
}
});
test('mpv input forwarding retries a transient keyboard config IPC failure', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
let calls = 0;
try {
testGlobals.setGetSessionBindings(async () => {
calls += 1;
if (calls === 1) {
throw new Error('transient');
}
return [];
});
await handlers.setupMpvInputForwarding();
await wait(25);
assert.equal(calls, 2);
} finally {
testGlobals.restore();
}
});
test('session help chord resolver follows remapped session bindings', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -1295,6 +1384,30 @@ test('session binding: Ctrl+Shift+O dispatches runtime options locally', async (
}
});
test('session binding: remapped mark watched dispatches locally with modifiers', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'stats.markWatchedKey',
originalKey: 'Ctrl+Shift+KeyW',
key: { code: 'KeyW', modifiers: ['ctrl', 'shift'] },
actionType: 'session-action',
actionId: 'markWatched',
},
] as never);
testGlobals.dispatchKeydown({ key: 'W', code: 'KeyW', ctrlKey: true, shiftKey: true });
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'markWatched', payload: undefined }]);
assert.equal(testGlobals.markActiveVideoWatchedCalls(), 0);
} finally {
testGlobals.restore();
}
});
test('session binding: copy subtitle multiple captures follow-up digit locally', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
+52 -1
View File
@@ -35,6 +35,7 @@ export function createKeyboardHandlers(
) {
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
const CHORD_TIMEOUT_MS = 1000;
const MPV_INPUT_FORWARDING_CONFIG_LOAD_TIMEOUT_MS = 50;
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
let pendingLookupRefreshAfterSubtitleSeek = false;
@@ -44,6 +45,7 @@ export function createKeyboardHandlers(
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple';
timeout: ReturnType<typeof setTimeout> | null;
} | null = null;
let mpvInputForwardingListenersInstalled = false;
const CHORD_MAP = new Map<
string,
@@ -940,7 +942,7 @@ export function createKeyboardHandlers(
}
}
async function setupMpvInputForwarding(): Promise<void> {
async function loadMpvInputForwardingConfig(): Promise<void> {
const [sessionBindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
window.electronAPI.getSessionBindings(),
window.electronAPI.getConfiguredShortcuts(),
@@ -950,6 +952,55 @@ export function createKeyboardHandlers(
updateSessionBindings(sessionBindings);
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
syncKeyboardTokenSelection();
}
async function loadMpvInputForwardingConfigWithRetry(): Promise<void> {
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await loadMpvInputForwardingConfig();
return;
} catch (error) {
lastError = error;
if (attempt < 2) {
await new Promise<void>((resolve) => {
setTimeout(resolve, 10 * (attempt + 1));
});
}
}
}
throw lastError;
}
async function setupMpvInputForwarding(): Promise<void> {
installMpvInputForwardingListeners();
syncKeyboardTokenSelection();
let configLoadError: unknown = null;
const configLoad = loadMpvInputForwardingConfigWithRetry().then(
() => {},
(error) => {
configLoadError = error;
console.error('Failed to load overlay keyboard configuration.', error);
},
);
await Promise.race([
configLoad,
new Promise<void>((resolve) => {
setTimeout(resolve, MPV_INPUT_FORWARDING_CONFIG_LOAD_TIMEOUT_MS);
}),
]);
if (configLoadError) {
return;
}
}
function installMpvInputForwardingListeners(): void {
if (mpvInputForwardingListenersInstalled) {
return;
}
mpvInputForwardingListenersInstalled = true;
const subtitleMutationObserver = new MutationObserver(() => {
syncKeyboardTokenSelection();