fix: address remaining CodeRabbit PR feedback

This commit is contained in:
2026-04-11 11:46:28 -07:00
parent f7fbffd4f5
commit 953f4c362b
23 changed files with 262 additions and 47 deletions

View File

@@ -177,7 +177,7 @@
"openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting. "openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting.
"openControllerSelect": "Alt+C", // Open controller select setting. "openControllerSelect": "Alt+C", // Open controller select setting.
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting. "openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
"toggleSubtitleSidebar": "\\" // Toggle subtitle sidebar setting. "toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================

View File

@@ -540,7 +540,7 @@ See `config.example.jsonc` for detailed configuration options.
"openControllerSelect": "Alt+C", "openControllerSelect": "Alt+C",
"openControllerDebug": "Alt+Shift+C", "openControllerDebug": "Alt+Shift+C",
"openJimaku": "Ctrl+Shift+J", "openJimaku": "Ctrl+Shift+J",
"toggleSubtitleSidebar": "\\", "toggleSubtitleSidebar": "Backslash",
"multiCopyTimeoutMs": 3000 "multiCopyTimeoutMs": 3000
} }
} }
@@ -564,7 +564,7 @@ See `config.example.jsonc` for detailed configuration options.
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | | `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | | `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | | `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"\\"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | | `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
**See `config.example.jsonc`** for the complete list of shortcut configuration options. **See `config.example.jsonc`** for the complete list of shortcut configuration options.

View File

@@ -177,7 +177,7 @@
"openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting. "openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting.
"openControllerSelect": "Alt+C", // Open controller select setting. "openControllerSelect": "Alt+C", // Open controller select setting.
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting. "openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
"toggleSubtitleSidebar": "\\" // Toggle subtitle sidebar setting. "toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================

View File

@@ -50,6 +50,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.startupWarmups.yomitanExtension, true); assert.equal(config.startupWarmups.yomitanExtension, true);
assert.equal(config.startupWarmups.subtitleDictionaries, true); assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, true); assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
assert.equal(config.discordPresence.enabled, true); assert.equal(config.discordPresence.enabled, true);
assert.equal(config.discordPresence.updateIntervalMs, 3_000); assert.equal(config.discordPresence.updateIntervalMs, 3_000);
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)'); assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');

View File

@@ -91,7 +91,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
openSessionHelp: 'CommandOrControl+Shift+H', openSessionHelp: 'CommandOrControl+Shift+H',
openControllerSelect: 'Alt+C', openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C', openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: '\\', toggleSubtitleSidebar: 'Backslash',
}, },
secondarySub: { secondarySub: {
secondarySubLanguages: [], secondarySubLanguages: [],

View File

@@ -278,7 +278,7 @@ export function handleCliCommand(
osdLabel: string, osdLabel: string,
): void => { ): void => {
runAsyncWithOsd( runAsyncWithOsd(
() => deps.dispatchSessionAction?.(request) ?? Promise.resolve(), () => deps.dispatchSessionAction(request),
deps, deps,
logLabel, logLabel,
osdLabel, osdLabel,

View File

@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility'; import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
type WindowTrackerStub = { type WindowTrackerStub = {
@@ -15,7 +16,9 @@ function createMainWindowRecorder() {
let visible = false; let visible = false;
let focused = false; let focused = false;
let opacity = 1; let opacity = 1;
let contentReady = true;
const window = { const window = {
webContents: {},
isDestroyed: () => false, isDestroyed: () => false,
isVisible: () => visible, isVisible: () => visible,
isFocused: () => focused, isFocused: () => focused,
@@ -50,11 +53,24 @@ function createMainWindowRecorder() {
calls.push('move-top'); calls.push('move-top');
}, },
}; };
(
window as {
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
}
)[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady;
return { return {
window, window,
calls, calls,
getOpacity: () => opacity, getOpacity: () => opacity,
setContentReady: (nextContentReady: boolean) => {
contentReady = nextContentReady;
(
window as {
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
}
)[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady;
},
setFocused: (nextFocused: boolean) => { setFocused: (nextFocused: boolean) => {
focused = nextFocused; focused = nextFocused;
}, },
@@ -285,6 +301,54 @@ test('Windows visible overlay restores opacity after the deferred reveal delay',
assert.ok(calls.includes('opacity:1')); assert.ok(calls.includes('opacity:1'));
}); });
test('Windows visible overlay waits for content-ready before first reveal', () => {
const { window, calls, setContentReady } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
setContentReady(false);
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
run();
assert.ok(!calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
setContentReady(true);
run();
assert.ok(calls.includes('show-inactive'));
});
test('tracked Windows overlay refresh rebinds while already visible', () => { test('tracked Windows overlay refresh rebinds while already visible', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {

View File

@@ -34,6 +34,10 @@ function resolveCount(count: number | undefined): number {
return Math.min(9, Math.max(1, normalized)); return Math.min(9, Math.max(1, normalized));
} }
function assertUnreachableSessionAction(actionId: never): never {
throw new Error(`Unhandled session action: ${String(actionId)}`);
}
export async function dispatchSessionAction( export async function dispatchSessionAction(
request: SessionActionDispatchRequest, request: SessionActionDispatchRequest,
deps: SessionActionExecutorDeps, deps: SessionActionExecutorDeps,
@@ -121,5 +125,7 @@ export async function dispatchSessionAction(
} }
return; return;
} }
default:
return assertUnreachableSessionAction(request.actionId);
} }
} }

View File

@@ -1544,8 +1544,8 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
setKeybindings: (keybindings) => { setKeybindings: (keybindings) => {
appState.keybindings = keybindings; appState.keybindings = keybindings;
}, },
setSessionBindings: (sessionBindings) => { setSessionBindings: (sessionBindings, sessionBindingWarnings) => {
persistSessionBindings(sessionBindings); persistSessionBindings(sessionBindings, sessionBindingWarnings);
}, },
refreshGlobalAndOverlayShortcuts: () => { refreshGlobalAndOverlayShortcuts: () => {
refreshGlobalAndOverlayShortcuts(); refreshGlobalAndOverlayShortcuts();
@@ -3631,7 +3631,7 @@ function ensureOverlayStartupPrereqs(): void {
if (appState.keybindings.length === 0) { if (appState.keybindings.length === 0) {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
refreshCurrentSessionBindings(); refreshCurrentSessionBindings();
} else if (appState.sessionBindings.length === 0) { } else if (!appState.sessionBindingsInitialized) {
refreshCurrentSessionBindings(); refreshCurrentSessionBindings();
} }
if (!appState.mpvClient) { if (!appState.mpvClient) {
@@ -4261,6 +4261,7 @@ function persistSessionBindings(
}); });
writeSessionBindingsArtifact(CONFIG_DIR, artifact); writeSessionBindingsArtifact(CONFIG_DIR, artifact);
appState.sessionBindings = bindings; appState.sessionBindings = bindings;
appState.sessionBindingsInitialized = true;
if (appState.mpvClient?.connected) { if (appState.mpvClient?.connected) {
sendMpvCommandRuntime(appState.mpvClient, [ sendMpvCommandRuntime(appState.mpvClient, [
'script-message', 'script-message',

View File

@@ -33,6 +33,8 @@ function createMockWindow(): MockWindow & {
hide: () => void; hide: () => void;
destroy: () => void; destroy: () => void;
focus: () => void; focus: () => void;
emitDidFinishLoad: () => void;
emitReadyToShow: () => void;
once: (event: 'ready-to-show', cb: () => void) => void; once: (event: 'ready-to-show', cb: () => void) => void;
webContents: { webContents: {
focused: boolean; focused: boolean;
@@ -89,6 +91,14 @@ function createMockWindow(): MockWindow & {
focus: () => { focus: () => {
state.focused = true; state.focused = true;
}, },
emitDidFinishLoad: () => {
const callback = state.loadCallbacks.shift();
callback?.();
},
emitReadyToShow: () => {
const callback = state.readyToShowCallbacks.shift();
callback?.();
},
once: (_event: 'ready-to-show', cb: () => void) => { once: (_event: 'ready-to-show', cb: () => void) => {
state.readyToShowCallbacks.push(cb); state.readyToShowCallbacks.push(cb);
}, },
@@ -269,16 +279,13 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
assert.equal(sent, true); assert.equal(sent, true);
assert.deepEqual(window.sent, []); assert.deepEqual(window.sent, []);
assert.equal(window.loadCallbacks.length, 1);
assert.equal(window.readyToShowCallbacks.length, 1);
window.loading = false; window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal'; window.url = 'file:///overlay/index.html?layer=modal';
window.loadCallbacks[0]!(); window.emitDidFinishLoad();
assert.deepEqual(window.sent, []); assert.deepEqual(window.sent, []);
window.contentReady = true; window.contentReady = true;
window.readyToShowCallbacks[0]!(); window.emitReadyToShow();
runtime.notifyOverlayModalOpened('runtime-options'); runtime.notifyOverlayModalOpened('runtime-options');
assert.deepEqual(window.sent, [['runtime-options:open']]); assert.deepEqual(window.sent, [['runtime-options:open']]);
@@ -549,6 +556,7 @@ test('handleOverlayModalClosed destroys modal window for single kiku modal', ()
test('modal fallback reveal skips showing window when content is not ready', async () => { test('modal fallback reveal skips showing window when content is not ready', async () => {
const window = createMockWindow(); const window = createMockWindow();
let scheduledReveal: (() => void) | null = null;
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
getModalWindow: () => window as never, getModalWindow: () => window as never,
@@ -557,6 +565,14 @@ test('modal fallback reveal skips showing window when content is not ready', asy
}, },
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {}, setModalWindowBounds: () => {},
}, {
scheduleRevealFallback: (callback) => {
scheduledReveal = callback;
return { scheduled: true } as never;
},
clearRevealFallback: () => {
scheduledReveal = null;
},
}); });
window.loading = true; window.loading = true;
@@ -568,10 +584,11 @@ test('modal fallback reveal skips showing window when content is not ready', asy
}); });
assert.equal(sent, true); assert.equal(sent, true);
if (scheduledReveal === null) {
await new Promise<void>((resolve) => { throw new Error('expected reveal callback');
setTimeout(resolve, 260); }
}); const runScheduledReveal: () => void = scheduledReveal;
runScheduledReveal();
assert.equal(window.getShowCount(), 0); assert.equal(window.getShowCount(), 0);
@@ -599,14 +616,11 @@ test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering
assert.equal(sent, true); assert.equal(sent, true);
assert.deepEqual(window.sent, []); assert.deepEqual(window.sent, []);
assert.equal(window.loadCallbacks.length, 1); window.emitDidFinishLoad();
assert.equal(window.readyToShowCallbacks.length, 1);
window.loadCallbacks[0]!();
assert.deepEqual(window.sent, []); assert.deepEqual(window.sent, []);
window.contentReady = true; window.contentReady = true;
window.readyToShowCallbacks[0]!(); window.emitReadyToShow();
assert.deepEqual(window.sent, [['runtime-options:open']]); assert.deepEqual(window.sent, [['runtime-options:open']]);
}); });
@@ -617,8 +631,7 @@ test('modal reopen creates a fresh window after close destroys the previous one'
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
getModalWindow: () => getModalWindow: () => currentModal as never,
currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null,
createModalWindow: () => { createModalWindow: () => {
currentModal = secondWindow; currentModal = secondWindow;
return secondWindow as never; return secondWindow as never;
@@ -653,8 +666,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
const runtime = createOverlayModalRuntimeService( const runtime = createOverlayModalRuntimeService(
{ {
getMainWindow: () => null, getMainWindow: () => null,
getModalWindow: () => getModalWindow: () => currentModal as never,
currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null,
createModalWindow: () => { createModalWindow: () => {
currentModal = secondWindow; currentModal = secondWindow;
return secondWindow as never; return secondWindow as never;

View File

@@ -50,8 +50,15 @@ export interface OverlayModalRuntime {
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>; getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
} }
type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
export interface OverlayModalRuntimeOptions { export interface OverlayModalRuntimeOptions {
onModalStateChange?: (isActive: boolean) => void; onModalStateChange?: (isActive: boolean) => void;
scheduleRevealFallback?: (
callback: () => void,
delayMs: number,
) => RevealFallbackHandle;
clearRevealFallback?: (timeout: RevealFallbackHandle) => void;
} }
export function createOverlayModalRuntimeService( export function createOverlayModalRuntimeService(
@@ -65,7 +72,14 @@ export function createOverlayModalRuntimeService(
let mainWindowHiddenByModal = false; let mainWindowHiddenByModal = false;
let modalWindowPrimedForImmediateShow = false; let modalWindowPrimedForImmediateShow = false;
let pendingModalWindowReveal: BrowserWindow | null = null; let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null; let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
const scheduleRevealFallback = (
callback: () => void,
delayMs: number,
): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
(options.clearRevealFallback ?? globalThis.clearTimeout)(timeout);
const notifyModalStateChange = (nextState: boolean): void => { const notifyModalStateChange = (nextState: boolean): void => {
if (modalActive === nextState) return; if (modalActive === nextState) return;
@@ -207,7 +221,7 @@ export function createOverlayModalRuntimeService(
return; return;
} }
clearTimeout(pendingModalWindowRevealTimeout); clearRevealFallback(pendingModalWindowRevealTimeout);
pendingModalWindowRevealTimeout = null; pendingModalWindowRevealTimeout = null;
pendingModalWindowReveal = null; pendingModalWindowReveal = null;
}; };
@@ -266,7 +280,7 @@ export function createOverlayModalRuntimeService(
return; return;
} }
pendingModalWindowRevealTimeout = setTimeout(() => { pendingModalWindowRevealTimeout = scheduleRevealFallback(() => {
const targetWindow = pendingModalWindowReveal; const targetWindow = pendingModalWindowReveal;
clearPendingModalWindowReveal(); clearPendingModalWindowReveal();
if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) { if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) {

View File

@@ -11,10 +11,14 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
const config = deepCloneConfig(DEFAULT_CONFIG); const config = deepCloneConfig(DEFAULT_CONFIG);
const calls: string[] = []; const calls: string[] = [];
const ankiPatches: Array<{ enabled: boolean }> = []; const ankiPatches: Array<{ enabled: boolean }> = [];
const sessionBindingWarnings: string[][] = [];
const applyHotReload = createConfigHotReloadAppliedHandler({ const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => calls.push('set:keybindings'), setKeybindings: () => calls.push('set:keybindings'),
setSessionBindings: () => calls.push('set:session-bindings'), setSessionBindings: (_sessionBindings, warnings) => {
calls.push('set:session-bindings');
sessionBindingWarnings.push(warnings.map((warning) => warning.message));
},
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'), refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`), setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`),
broadcastToOverlayWindows: (channel, payload) => broadcastToOverlayWindows: (channel, payload) =>
@@ -44,6 +48,12 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:'))); assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
assert.ok(calls.includes('broadcast:config:hot-reload:object')); assert.ok(calls.includes('broadcast:config:hot-reload:object'));
assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]); assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]);
assert.equal(sessionBindingWarnings.length, 1);
assert.ok(
sessionBindingWarnings[0]?.some((message) =>
message.includes('Rename shortcuts.toggleVisibleOverlayGlobal'),
),
);
}); });
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => { test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
@@ -70,6 +80,34 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
assert.deepEqual(calls, ['set:keybindings', 'set:session-bindings']); assert.deepEqual(calls, ['set:keybindings', 'set:session-bindings']);
}); });
test('createConfigHotReloadAppliedHandler forwards compiled session-binding warnings', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
config.shortcuts.openSessionHelp = 'Ctrl+?';
const warnings: string[][] = [];
const applyHotReload = createConfigHotReloadAppliedHandler({
setKeybindings: () => {},
setSessionBindings: (_sessionBindings, sessionBindingWarnings) => {
warnings.push(sessionBindingWarnings.map((warning) => warning.message));
},
refreshGlobalAndOverlayShortcuts: () => {},
setSecondarySubMode: () => {},
broadcastToOverlayWindows: () => {},
applyAnkiRuntimeConfigPatch: () => {},
});
applyHotReload(
{
hotReloadFields: ['shortcuts'],
restartRequiredFields: [],
},
config,
);
assert.equal(warnings.length, 1);
assert.ok(warnings[0]?.some((message) => message.includes('Unsupported accelerator key token')));
});
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => { test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
const calls: string[] = []; const calls: string[] = [];
const handleMessage = createConfigHotReloadMessageHandler({ const handleMessage = createConfigHotReloadMessageHandler({

View File

@@ -7,7 +7,10 @@ import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '.
type ConfigHotReloadAppliedDeps = { type ConfigHotReloadAppliedDeps = {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void; setSessionBindings: (
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
) => void;
refreshGlobalAndOverlayShortcuts: () => void; refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void; setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
@@ -37,7 +40,7 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload { export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
const keybindings = resolveKeybindings(config, DEFAULT_KEYBINDINGS); const keybindings = resolveKeybindings(config, DEFAULT_KEYBINDINGS);
const { bindings: sessionBindings } = compileSessionBindings({ const { bindings: sessionBindings, warnings: sessionBindingWarnings } = compileSessionBindings({
keybindings, keybindings,
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG), shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
platform: platform:
@@ -51,6 +54,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
return { return {
keybindings, keybindings,
sessionBindings, sessionBindings,
sessionBindingWarnings,
subtitleStyle: resolveSubtitleStyleForRenderer(config), subtitleStyle: resolveSubtitleStyleForRenderer(config),
subtitleSidebar: config.subtitleSidebar, subtitleSidebar: config.subtitleSidebar,
secondarySubMode: config.secondarySub.defaultMode, secondarySubMode: config.secondarySub.defaultMode,
@@ -61,7 +65,7 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => { return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
const payload = buildConfigHotReloadPayload(config); const payload = buildConfigHotReloadPayload(config);
deps.setKeybindings(payload.keybindings); deps.setKeybindings(payload.keybindings);
deps.setSessionBindings(payload.sessionBindings); deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
if (diff.hotReloadFields.includes('shortcuts')) { if (diff.hotReloadFields.includes('shortcuts')) {
deps.refreshGlobalAndOverlayShortcuts(); deps.refreshGlobalAndOverlayShortcuts();

View File

@@ -86,9 +86,13 @@ test('config hot reload message main deps builder maps notifications', () => {
test('config hot reload applied main deps builder maps callbacks', () => { test('config hot reload applied main deps builder maps callbacks', () => {
const calls: string[] = []; const calls: string[] = [];
const warningCounts: number[] = [];
const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({ const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({
setKeybindings: () => calls.push('keybindings'), setKeybindings: () => calls.push('keybindings'),
setSessionBindings: () => calls.push('session-bindings'), setSessionBindings: (_sessionBindings, warnings) => {
calls.push('session-bindings');
warningCounts.push(warnings.length);
},
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'), refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'),
setSecondarySubMode: () => calls.push('set-secondary'), setSecondarySubMode: () => calls.push('set-secondary'),
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
@@ -97,7 +101,7 @@ test('config hot reload applied main deps builder maps callbacks', () => {
const deps = buildDeps(); const deps = buildDeps();
deps.setKeybindings([]); deps.setKeybindings([]);
deps.setSessionBindings([]); deps.setSessionBindings([], []);
deps.refreshGlobalAndOverlayShortcuts(); deps.refreshGlobalAndOverlayShortcuts();
deps.setSecondarySubMode('hover'); deps.setSecondarySubMode('hover');
deps.broadcastToOverlayWindows('config:hot-reload', {}); deps.broadcastToOverlayWindows('config:hot-reload', {});
@@ -110,6 +114,7 @@ test('config hot reload applied main deps builder maps callbacks', () => {
'broadcast:config:hot-reload', 'broadcast:config:hot-reload',
'apply-anki', 'apply-anki',
]); ]);
assert.deepEqual(warningCounts, [0]);
}); });
test('config hot reload runtime main deps builder maps runtime callbacks', () => { test('config hot reload runtime main deps builder maps runtime callbacks', () => {

View File

@@ -62,7 +62,10 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void; setSessionBindings: (
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
) => void;
refreshGlobalAndOverlayShortcuts: () => void; refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void; setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
@@ -73,8 +76,10 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
return () => ({ return () => ({
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
deps.setKeybindings(keybindings), deps.setKeybindings(keybindings),
setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => setSessionBindings: (
deps.setSessionBindings(sessionBindings), sessionBindings: ConfigHotReloadPayload['sessionBindings'],
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
) => deps.setSessionBindings(sessionBindings, sessionBindingWarnings),
refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(), refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(),
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
broadcastToOverlayWindows: (channel: string, payload: unknown) => broadcastToOverlayWindows: (channel: string, payload: unknown) =>

View File

@@ -113,3 +113,12 @@ test('applyStartupState preserves cleared startup-only runtime flags', () => {
assert.equal(appState.initialArgs?.settings, true); assert.equal(appState.initialArgs?.settings, true);
}); });
test('createAppState starts with session bindings marked uninitialized', () => {
const appState = createAppState({
mpvSocketPath: '/tmp/mpv.sock',
texthookerPort: 4000,
});
assert.equal(appState.sessionBindingsInitialized, false);
});

View File

@@ -172,6 +172,7 @@ export interface AppState {
mecabTokenizer: MecabTokenizer | null; mecabTokenizer: MecabTokenizer | null;
keybindings: Keybinding[]; keybindings: Keybinding[];
sessionBindings: CompiledSessionBinding[]; sessionBindings: CompiledSessionBinding[];
sessionBindingsInitialized: boolean;
subtitleTimingTracker: SubtitleTimingTracker | null; subtitleTimingTracker: SubtitleTimingTracker | null;
immersionTracker: ImmersionTrackerService | null; immersionTracker: ImmersionTrackerService | null;
ankiIntegration: AnkiIntegration | null; ankiIntegration: AnkiIntegration | null;
@@ -255,6 +256,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
mecabTokenizer: null, mecabTokenizer: null,
keybindings: [], keybindings: [],
sessionBindings: [], sessionBindings: [],
sessionBindingsInitialized: false,
subtitleTimingTracker: null, subtitleTimingTracker: null,
immersionTracker: null, immersionTracker: null,
ankiIntegration: null, ankiIntegration: null,

View File

@@ -944,6 +944,29 @@ test('keyboard mode: configured stats toggle works even while popup is open', as
} }
}); });
test('refreshConfiguredShortcuts updates refreshed stats and mark-watched keys', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.setStatsToggleKey('KeyG');
testGlobals.setMarkWatchedKey('KeyM');
await handlers.refreshConfiguredShortcuts();
const beforeMarkWatchedCalls = testGlobals.markActiveVideoWatchedCalls();
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
testGlobals.dispatchKeydown({ key: 'm', code: 'KeyM' });
await wait(10);
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
assert.equal(testGlobals.markActiveVideoWatchedCalls(), beforeMarkWatchedCalls + 1);
} finally {
testGlobals.restore();
}
});
test('youtube picker: unhandled keys still dispatch mpv keybindings', async () => { test('youtube picker: unhandled keys still dispatch mpv keybindings', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();

View File

@@ -80,12 +80,27 @@ export function createKeyboardHandlers(
return parts.join('+'); return parts.join('+');
} }
function updateConfiguredShortcuts(shortcuts: Required<ShortcutsConfig>): void { function updateConfiguredShortcuts(
shortcuts: Required<ShortcutsConfig>,
statsToggleKey?: string,
markWatchedKey?: string,
): void {
ctx.state.sessionActionTimeoutMs = shortcuts.multiCopyTimeoutMs; ctx.state.sessionActionTimeoutMs = shortcuts.multiCopyTimeoutMs;
if (typeof statsToggleKey === 'string' && statsToggleKey.length > 0) {
ctx.state.statsToggleKey = statsToggleKey;
}
if (typeof markWatchedKey === 'string' && markWatchedKey.length > 0) {
ctx.state.markWatchedKey = markWatchedKey;
}
} }
async function refreshConfiguredShortcuts(): Promise<void> { async function refreshConfiguredShortcuts(): Promise<void> {
updateConfiguredShortcuts(await window.electronAPI.getConfiguredShortcuts()); const [shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
window.electronAPI.getConfiguredShortcuts(),
window.electronAPI.getStatsToggleKey(),
window.electronAPI.getMarkWatchedKey(),
]);
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
} }
function updateSessionBindings(bindings: CompiledSessionBinding[]): void { function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
@@ -912,9 +927,7 @@ export function createKeyboardHandlers(
window.electronAPI.getMarkWatchedKey(), window.electronAPI.getMarkWatchedKey(),
]); ]);
updateSessionBindings(sessionBindings); updateSessionBindings(sessionBindings);
updateConfiguredShortcuts(shortcuts); updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
ctx.state.statsToggleKey = statsToggleKey;
ctx.state.markWatchedKey = markWatchedKey;
syncKeyboardTokenSelection(); syncKeyboardTokenSelection();
const subtitleMutationObserver = new MutationObserver(() => { const subtitleMutationObserver = new MutationObserver(() => {

View File

@@ -9,6 +9,7 @@ import type {
CompiledSessionBinding, CompiledSessionBinding,
SessionActionId, SessionActionId,
SessionActionPayload, SessionActionPayload,
SessionBindingWarning,
} from './session-bindings'; } from './session-bindings';
import type { import type {
JimakuApiResponse, JimakuApiResponse,
@@ -327,6 +328,7 @@ export interface ClipboardAppendResult {
export interface ConfigHotReloadPayload { export interface ConfigHotReloadPayload {
keybindings: Keybinding[]; keybindings: Keybinding[];
sessionBindings: CompiledSessionBinding[]; sessionBindings: CompiledSessionBinding[];
sessionBindingWarnings: SessionBindingWarning[];
subtitleStyle: SubtitleStyleConfig | null; subtitleStyle: SubtitleStyleConfig | null;
subtitleSidebar: Required<SubtitleSidebarConfig>; subtitleSidebar: Required<SubtitleSidebarConfig>;
secondarySubMode: SecondarySubMode; secondarySubMode: SecondarySubMode;

View File

@@ -79,11 +79,11 @@ export abstract class BaseWindowTracker {
this.updateTargetWindowFocused(focused); this.updateTargetWindowFocused(focused);
} }
protected updateGeometry(newGeometry: WindowGeometry | null): void { protected updateGeometry(newGeometry: WindowGeometry | null, initialFocused = true): void {
if (newGeometry) { if (newGeometry) {
if (!this.windowFound) { if (!this.windowFound) {
this.windowFound = true; this.windowFound = true;
this.updateTargetWindowFocused(true); this.updateTargetWindowFocused(initialFocused);
if (this.onWindowFound) this.onWindowFound(newGeometry); if (this.onWindowFound) this.onWindowFound(newGeometry);
} }

View File

@@ -70,6 +70,22 @@ test('WindowsWindowTracker updates geometry from poll output', () => {
assert.equal(tracker.isTargetWindowFocused(), true); assert.equal(tracker.isTargetWindowFocused(), true);
}); });
test('WindowsWindowTracker preserves an unfocused initial match', () => {
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => mpvVisible({ x: 10, y: 20, width: 1280, height: 720, focused: false }),
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.deepEqual(tracker.getGeometry(), {
x: 10,
y: 20,
width: 1280,
height: 720,
});
assert.equal(tracker.isTargetWindowFocused(), false);
});
test('WindowsWindowTracker clears geometry for poll misses', () => { test('WindowsWindowTracker clears geometry for poll misses', () => {
const tracker = new WindowsWindowTracker(undefined, { const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => mpvNotFound, pollMpvWindows: () => mpvNotFound,

View File

@@ -154,8 +154,8 @@ export class WindowsWindowTracker extends BaseWindowTracker {
this.resetTrackingLossState(); this.resetTrackingLossState();
this.targetWindowMinimized = false; this.targetWindowMinimized = false;
this.currentTargetWindowHwnd = best.hwnd; this.currentTargetWindowHwnd = best.hwnd;
this.updateGeometry(best.geometry, best.focused);
this.updateTargetWindowFocused(best.focused); this.updateTargetWindowFocused(best.focused);
this.updateGeometry(best.geometry);
return; return;
} }