diff --git a/config.example.jsonc b/config.example.jsonc index 2d9c1c67..92474731 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -177,7 +177,7 @@ "openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting. "openControllerSelect": "Alt+C", // Open controller select 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. // ========================================== diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 0eb3f330..ea28a111 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -540,7 +540,7 @@ See `config.example.jsonc` for detailed configuration options. "openControllerSelect": "Alt+C", "openControllerDebug": "Alt+Shift+C", "openJimaku": "Ctrl+Shift+J", - "toggleSubtitleSidebar": "\\", + "toggleSubtitleSidebar": "Backslash", "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"`) | | `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | | `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. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 2d9c1c67..92474731 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -177,7 +177,7 @@ "openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting. "openControllerSelect": "Alt+C", // Open controller select 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. // ========================================== diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 0b7ab18a..85f59272 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -50,6 +50,7 @@ test('loads defaults when config is missing', () => { assert.equal(config.startupWarmups.yomitanExtension, true); assert.equal(config.startupWarmups.subtitleDictionaries, true); assert.equal(config.startupWarmups.jellyfinRemoteSession, true); + assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash'); assert.equal(config.discordPresence.enabled, true); assert.equal(config.discordPresence.updateIntervalMs, 3_000); assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)'); diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index e57830bb..30507bcf 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -91,7 +91,7 @@ export const CORE_DEFAULT_CONFIG: Pick< openSessionHelp: 'CommandOrControl+Shift+H', openControllerSelect: 'Alt+C', openControllerDebug: 'Alt+Shift+C', - toggleSubtitleSidebar: '\\', + toggleSubtitleSidebar: 'Backslash', }, secondarySub: { secondarySubLanguages: [], diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index a263052a..cdd61f44 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -278,7 +278,7 @@ export function handleCliCommand( osdLabel: string, ): void => { runAsyncWithOsd( - () => deps.dispatchSessionAction?.(request) ?? Promise.resolve(), + () => deps.dispatchSessionAction(request), deps, logLabel, osdLabel, diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 7b35248b..c265a07b 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility'; type WindowTrackerStub = { @@ -15,7 +16,9 @@ function createMainWindowRecorder() { let visible = false; let focused = false; let opacity = 1; + let contentReady = true; const window = { + webContents: {}, isDestroyed: () => false, isVisible: () => visible, isFocused: () => focused, @@ -50,11 +53,24 @@ function createMainWindowRecorder() { calls.push('move-top'); }, }; + ( + window as { + [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean; + } + )[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady; return { window, calls, getOpacity: () => opacity, + setContentReady: (nextContentReady: boolean) => { + contentReady = nextContentReady; + ( + window as { + [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean; + } + )[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady; + }, setFocused: (nextFocused: boolean) => { focused = nextFocused; }, @@ -285,6 +301,54 @@ test('Windows visible overlay restores opacity after the deferred reveal delay', 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', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts index 770b41fe..cb162d4f 100644 --- a/src/core/services/session-actions.ts +++ b/src/core/services/session-actions.ts @@ -34,6 +34,10 @@ function resolveCount(count: number | undefined): number { 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( request: SessionActionDispatchRequest, deps: SessionActionExecutorDeps, @@ -121,5 +125,7 @@ export async function dispatchSessionAction( } return; } + default: + return assertUnreachableSessionAction(request.actionId); } } diff --git a/src/main.ts b/src/main.ts index 3e019b8d..377444d2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1544,8 +1544,8 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp setKeybindings: (keybindings) => { appState.keybindings = keybindings; }, - setSessionBindings: (sessionBindings) => { - persistSessionBindings(sessionBindings); + setSessionBindings: (sessionBindings, sessionBindingWarnings) => { + persistSessionBindings(sessionBindings, sessionBindingWarnings); }, refreshGlobalAndOverlayShortcuts: () => { refreshGlobalAndOverlayShortcuts(); @@ -3631,7 +3631,7 @@ function ensureOverlayStartupPrereqs(): void { if (appState.keybindings.length === 0) { appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); refreshCurrentSessionBindings(); - } else if (appState.sessionBindings.length === 0) { + } else if (!appState.sessionBindingsInitialized) { refreshCurrentSessionBindings(); } if (!appState.mpvClient) { @@ -4261,6 +4261,7 @@ function persistSessionBindings( }); writeSessionBindingsArtifact(CONFIG_DIR, artifact); appState.sessionBindings = bindings; + appState.sessionBindingsInitialized = true; if (appState.mpvClient?.connected) { sendMpvCommandRuntime(appState.mpvClient, [ 'script-message', diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index cd19356c..fe63f53d 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -33,6 +33,8 @@ function createMockWindow(): MockWindow & { hide: () => void; destroy: () => void; focus: () => void; + emitDidFinishLoad: () => void; + emitReadyToShow: () => void; once: (event: 'ready-to-show', cb: () => void) => void; webContents: { focused: boolean; @@ -89,6 +91,14 @@ function createMockWindow(): MockWindow & { focus: () => { state.focused = true; }, + emitDidFinishLoad: () => { + const callback = state.loadCallbacks.shift(); + callback?.(); + }, + emitReadyToShow: () => { + const callback = state.readyToShowCallbacks.shift(); + callback?.(); + }, once: (_event: 'ready-to-show', cb: () => void) => { state.readyToShowCallbacks.push(cb); }, @@ -269,16 +279,13 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co assert.equal(sent, true); assert.deepEqual(window.sent, []); - - assert.equal(window.loadCallbacks.length, 1); - assert.equal(window.readyToShowCallbacks.length, 1); window.loading = false; window.url = 'file:///overlay/index.html?layer=modal'; - window.loadCallbacks[0]!(); + window.emitDidFinishLoad(); assert.deepEqual(window.sent, []); window.contentReady = true; - window.readyToShowCallbacks[0]!(); + window.emitReadyToShow(); runtime.notifyOverlayModalOpened('runtime-options'); 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 () => { const window = createMockWindow(); + let scheduledReveal: (() => void) | null = null; const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, 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 }), setModalWindowBounds: () => {}, + }, { + scheduleRevealFallback: (callback) => { + scheduledReveal = callback; + return { scheduled: true } as never; + }, + clearRevealFallback: () => { + scheduledReveal = null; + }, }); window.loading = true; @@ -568,10 +584,11 @@ test('modal fallback reveal skips showing window when content is not ready', asy }); assert.equal(sent, true); - - await new Promise((resolve) => { - setTimeout(resolve, 260); - }); + if (scheduledReveal === null) { + throw new Error('expected reveal callback'); + } + const runScheduledReveal: () => void = scheduledReveal; + runScheduledReveal(); assert.equal(window.getShowCount(), 0); @@ -599,14 +616,11 @@ test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering assert.equal(sent, true); assert.deepEqual(window.sent, []); - assert.equal(window.loadCallbacks.length, 1); - assert.equal(window.readyToShowCallbacks.length, 1); - - window.loadCallbacks[0]!(); + window.emitDidFinishLoad(); assert.deepEqual(window.sent, []); window.contentReady = true; - window.readyToShowCallbacks[0]!(); + window.emitReadyToShow(); 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({ getMainWindow: () => null, - getModalWindow: () => - currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null, + getModalWindow: () => currentModal as never, createModalWindow: () => { currentModal = secondWindow; return secondWindow as never; @@ -653,8 +666,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif const runtime = createOverlayModalRuntimeService( { getMainWindow: () => null, - getModalWindow: () => - currentModal && !currentModal.isDestroyed() ? (currentModal as never) : null, + getModalWindow: () => currentModal as never, createModalWindow: () => { currentModal = secondWindow; return secondWindow as never; diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 0f8312ef..242e54ea 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -50,8 +50,15 @@ export interface OverlayModalRuntime { getRestoreVisibleOverlayOnModalClose: () => Set; } +type RevealFallbackHandle = NonNullable[0]>; + export interface OverlayModalRuntimeOptions { onModalStateChange?: (isActive: boolean) => void; + scheduleRevealFallback?: ( + callback: () => void, + delayMs: number, + ) => RevealFallbackHandle; + clearRevealFallback?: (timeout: RevealFallbackHandle) => void; } export function createOverlayModalRuntimeService( @@ -65,7 +72,14 @@ export function createOverlayModalRuntimeService( let mainWindowHiddenByModal = false; let modalWindowPrimedForImmediateShow = false; let pendingModalWindowReveal: BrowserWindow | null = null; - let pendingModalWindowRevealTimeout: ReturnType | 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 => { if (modalActive === nextState) return; @@ -207,7 +221,7 @@ export function createOverlayModalRuntimeService( return; } - clearTimeout(pendingModalWindowRevealTimeout); + clearRevealFallback(pendingModalWindowRevealTimeout); pendingModalWindowRevealTimeout = null; pendingModalWindowReveal = null; }; @@ -266,7 +280,7 @@ export function createOverlayModalRuntimeService( return; } - pendingModalWindowRevealTimeout = setTimeout(() => { + pendingModalWindowRevealTimeout = scheduleRevealFallback(() => { const targetWindow = pendingModalWindowReveal; clearPendingModalWindowReveal(); if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) { diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index d504eb85..9ad4e1c6 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -11,10 +11,14 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { const config = deepCloneConfig(DEFAULT_CONFIG); const calls: string[] = []; const ankiPatches: Array<{ enabled: boolean }> = []; + const sessionBindingWarnings: string[][] = []; const applyHotReload = createConfigHotReloadAppliedHandler({ 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'), setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`), 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.includes('broadcast:config:hot-reload:object')); 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', () => { @@ -70,6 +80,34 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie 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', () => { const calls: string[] = []; const handleMessage = createConfigHotReloadMessageHandler({ diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index a25906ad..f33f7246 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -7,7 +7,10 @@ import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '. type ConfigHotReloadAppliedDeps = { setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; - setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void; + setSessionBindings: ( + sessionBindings: ConfigHotReloadPayload['sessionBindings'], + sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'], + ) => void; refreshGlobalAndOverlayShortcuts: () => void; setSecondarySubMode: (mode: SecondarySubMode) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; @@ -37,7 +40,7 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) { export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload { const keybindings = resolveKeybindings(config, DEFAULT_KEYBINDINGS); - const { bindings: sessionBindings } = compileSessionBindings({ + const { bindings: sessionBindings, warnings: sessionBindingWarnings } = compileSessionBindings({ keybindings, shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG), platform: @@ -51,6 +54,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe return { keybindings, sessionBindings, + sessionBindingWarnings, subtitleStyle: resolveSubtitleStyleForRenderer(config), subtitleSidebar: config.subtitleSidebar, secondarySubMode: config.secondarySub.defaultMode, @@ -61,7 +65,7 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => { const payload = buildConfigHotReloadPayload(config); deps.setKeybindings(payload.keybindings); - deps.setSessionBindings(payload.sessionBindings); + deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings); if (diff.hotReloadFields.includes('shortcuts')) { deps.refreshGlobalAndOverlayShortcuts(); diff --git a/src/main/runtime/config-hot-reload-main-deps.test.ts b/src/main/runtime/config-hot-reload-main-deps.test.ts index 21957a47..c5a83271 100644 --- a/src/main/runtime/config-hot-reload-main-deps.test.ts +++ b/src/main/runtime/config-hot-reload-main-deps.test.ts @@ -86,9 +86,13 @@ test('config hot reload message main deps builder maps notifications', () => { test('config hot reload applied main deps builder maps callbacks', () => { const calls: string[] = []; + const warningCounts: number[] = []; const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({ 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'), setSecondarySubMode: () => calls.push('set-secondary'), broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), @@ -97,7 +101,7 @@ test('config hot reload applied main deps builder maps callbacks', () => { const deps = buildDeps(); deps.setKeybindings([]); - deps.setSessionBindings([]); + deps.setSessionBindings([], []); deps.refreshGlobalAndOverlayShortcuts(); deps.setSecondarySubMode('hover'); deps.broadcastToOverlayWindows('config:hot-reload', {}); @@ -110,6 +114,7 @@ test('config hot reload applied main deps builder maps callbacks', () => { 'broadcast:config:hot-reload', 'apply-anki', ]); + assert.deepEqual(warningCounts, [0]); }); test('config hot reload runtime main deps builder maps runtime callbacks', () => { diff --git a/src/main/runtime/config-hot-reload-main-deps.ts b/src/main/runtime/config-hot-reload-main-deps.ts index 009a52b2..dfff9da9 100644 --- a/src/main/runtime/config-hot-reload-main-deps.ts +++ b/src/main/runtime/config-hot-reload-main-deps.ts @@ -62,7 +62,10 @@ export function createBuildConfigHotReloadMessageMainDepsHandler( export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; - setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => void; + setSessionBindings: ( + sessionBindings: ConfigHotReloadPayload['sessionBindings'], + sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'], + ) => void; refreshGlobalAndOverlayShortcuts: () => void; setSecondarySubMode: (mode: SecondarySubMode) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; @@ -73,8 +76,10 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { return () => ({ setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => deps.setKeybindings(keybindings), - setSessionBindings: (sessionBindings: ConfigHotReloadPayload['sessionBindings']) => - deps.setSessionBindings(sessionBindings), + setSessionBindings: ( + sessionBindings: ConfigHotReloadPayload['sessionBindings'], + sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'], + ) => deps.setSessionBindings(sessionBindings, sessionBindingWarnings), refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(), setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), broadcastToOverlayWindows: (channel: string, payload: unknown) => diff --git a/src/main/state.test.ts b/src/main/state.test.ts index 6ddf7f34..295ba67d 100644 --- a/src/main/state.test.ts +++ b/src/main/state.test.ts @@ -113,3 +113,12 @@ test('applyStartupState preserves cleared startup-only runtime flags', () => { 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); +}); diff --git a/src/main/state.ts b/src/main/state.ts index 4a0be88a..af3408be 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -172,6 +172,7 @@ export interface AppState { mecabTokenizer: MecabTokenizer | null; keybindings: Keybinding[]; sessionBindings: CompiledSessionBinding[]; + sessionBindingsInitialized: boolean; subtitleTimingTracker: SubtitleTimingTracker | null; immersionTracker: ImmersionTrackerService | null; ankiIntegration: AnkiIntegration | null; @@ -255,6 +256,7 @@ export function createAppState(values: AppStateInitialValues): AppState { mecabTokenizer: null, keybindings: [], sessionBindings: [], + sessionBindingsInitialized: false, subtitleTimingTracker: null, immersionTracker: null, ankiIntegration: null, diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 73d43d1c..f4935fdb 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -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 () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 1a858b2c..391e82ca 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -80,12 +80,27 @@ export function createKeyboardHandlers( return parts.join('+'); } - function updateConfiguredShortcuts(shortcuts: Required): void { + function updateConfiguredShortcuts( + shortcuts: Required, + statsToggleKey?: string, + markWatchedKey?: string, + ): void { 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 { - 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 { @@ -912,9 +927,7 @@ export function createKeyboardHandlers( window.electronAPI.getMarkWatchedKey(), ]); updateSessionBindings(sessionBindings); - updateConfiguredShortcuts(shortcuts); - ctx.state.statsToggleKey = statsToggleKey; - ctx.state.markWatchedKey = markWatchedKey; + updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey); syncKeyboardTokenSelection(); const subtitleMutationObserver = new MutationObserver(() => { diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 2c9d4b36..fa8f60a6 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -9,6 +9,7 @@ import type { CompiledSessionBinding, SessionActionId, SessionActionPayload, + SessionBindingWarning, } from './session-bindings'; import type { JimakuApiResponse, @@ -327,6 +328,7 @@ export interface ClipboardAppendResult { export interface ConfigHotReloadPayload { keybindings: Keybinding[]; sessionBindings: CompiledSessionBinding[]; + sessionBindingWarnings: SessionBindingWarning[]; subtitleStyle: SubtitleStyleConfig | null; subtitleSidebar: Required; secondarySubMode: SecondarySubMode; diff --git a/src/window-trackers/base-tracker.ts b/src/window-trackers/base-tracker.ts index 87d3c93a..15b5d9f5 100644 --- a/src/window-trackers/base-tracker.ts +++ b/src/window-trackers/base-tracker.ts @@ -79,11 +79,11 @@ export abstract class BaseWindowTracker { this.updateTargetWindowFocused(focused); } - protected updateGeometry(newGeometry: WindowGeometry | null): void { + protected updateGeometry(newGeometry: WindowGeometry | null, initialFocused = true): void { if (newGeometry) { if (!this.windowFound) { this.windowFound = true; - this.updateTargetWindowFocused(true); + this.updateTargetWindowFocused(initialFocused); if (this.onWindowFound) this.onWindowFound(newGeometry); } diff --git a/src/window-trackers/windows-tracker.test.ts b/src/window-trackers/windows-tracker.test.ts index 0751a94d..da353a27 100644 --- a/src/window-trackers/windows-tracker.test.ts +++ b/src/window-trackers/windows-tracker.test.ts @@ -70,6 +70,22 @@ test('WindowsWindowTracker updates geometry from poll output', () => { 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', () => { const tracker = new WindowsWindowTracker(undefined, { pollMpvWindows: () => mpvNotFound, diff --git a/src/window-trackers/windows-tracker.ts b/src/window-trackers/windows-tracker.ts index 9fb1c7a3..14c8cb86 100644 --- a/src/window-trackers/windows-tracker.ts +++ b/src/window-trackers/windows-tracker.ts @@ -154,8 +154,8 @@ export class WindowsWindowTracker extends BaseWindowTracker { this.resetTrackingLossState(); this.targetWindowMinimized = false; this.currentTargetWindowHwnd = best.hwnd; + this.updateGeometry(best.geometry, best.focused); this.updateTargetWindowFocused(best.focused); - this.updateGeometry(best.geometry); return; }