diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 5a7f25d1..aea79ec5 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -258,7 +258,7 @@ export function createKeyboardHandlers( } if (binding.actionType === 'mpv-command') { - dispatchConfiguredMpvCommand(binding.command, { + void dispatchConfiguredMpvCommand(binding.command, { getPlaybackPaused: options.getPlaybackPaused, sendMpvCommand: (command) => window.electronAPI.sendMpvCommand(command), }); diff --git a/src/renderer/modals/session-help-commands.test.ts b/src/renderer/modals/session-help-commands.test.ts index 3c2c9536..11ae7450 100644 --- a/src/renderer/modals/session-help-commands.test.ts +++ b/src/renderer/modals/session-help-commands.test.ts @@ -143,11 +143,13 @@ function withFakeDom( }; } -type HarnessOptions = { failActions?: boolean }; +type HarnessOptions = { failActions?: boolean; bindings?: CompiledSessionBinding[] }; function createHarness(options: HarnessOptions) { const mpvCommands: (string | number)[][] = []; const sessionActions: string[] = []; + /** IPC in send order; main destroys the modal window on `closed`, dropping anything after it. */ + const ipcLog: string[] = []; const define = (key: string, value: unknown) => Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); @@ -163,15 +165,20 @@ function createHarness(options: HarnessOptions) { electronAPI: { focusMainWindow: async () => {}, setIgnoreMouseEvents: () => {}, - notifyOverlayModalClosed: () => {}, - getSessionBindings: async () => SESSION_BINDINGS, + notifyOverlayModalClosed: () => ipcLog.push('closed'), + getSessionBindings: async () => options.bindings ?? SESSION_BINDINGS, getSubtitleStyle: async () => ({}), getMarkWatchedKey: async () => null, getSubtitleSidebarSnapshot: async () => ({ config: { toggleKey: null } }), getRuntimeOptions: async () => [], - sendMpvCommand: (command: (string | number)[]) => mpvCommands.push(command), + getPlaybackPaused: async () => true, + sendMpvCommand: (command: (string | number)[]) => { + mpvCommands.push(command); + ipcLog.push(`mpv:${command.join(' ')}`); + }, dispatchSessionAction: async (actionId: string) => { sessionActions.push(actionId); + ipcLog.push(`action:${actionId}`); if (options.failActions) throw new Error('boom'); }, }, @@ -239,7 +246,7 @@ function createHarness(options: HarnessOptions) { } const rows = () => dom.sessionHelpContent.querySelectorAll('.session-help-item'); - return { dom, state, open, pressEnter, rows, mpvCommands, sessionActions }; + return { dom, state, open, pressEnter, rows, mpvCommands, sessionActions, ipcLog }; } test('session help rows carry runnable commands except numeric and self-opening actions', () => { @@ -294,11 +301,39 @@ test( harness.pressEnter(); - assert.deepEqual(harness.sessionActions, ['toggleStatsOverlay']); + assert.deepEqual(harness.ipcLog, ['action:toggleStatsOverlay', 'closed']); assert.equal(harness.state.sessionHelpModalOpen, false); }), ); +test( + 'session help sends a paused subtitle seek before closing', + withFakeDom( + async (harness) => { + await harness.open(true); + // Playback rows live on another tab; a filter searches every tab. + harness.dom.sessionHelpFilter.value = 'next subtitle'; + harness.dom.sessionHelpFilter.dispatch('input', {}); + + harness.dom.sessionHelpContent.dispatch('dblclick', { target: harness.rows()[0] }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(harness.ipcLog, ['mpv:sub-seek 1', 'mpv:set_property pause yes', 'closed']); + }, + { + bindings: [ + { + sourcePath: 'keybindings[0].key', + originalKey: 'Shift+KeyL', + key: { code: 'KeyL', modifiers: ['shift'] }, + actionType: 'mpv-command', + command: ['sub-seek', 1], + }, + ], + }, + ), +); + test( 'session help ignores Enter when no video is playing', withFakeDom(async (harness) => { diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts index c7f08f3c..3a754138 100644 --- a/src/renderer/modals/session-help.ts +++ b/src/renderer/modals/session-help.ts @@ -309,29 +309,30 @@ export function createSessionHelpModal( } /** - * Closes help first so a command that opens another modal (or needs mpv focus) - * lands after help has released the overlay. + * Sends the command before closing help. On Linux and Windows, main destroys the + * dedicated modal window once its last modal closes, so IPC sent after the close + * is dropped. Main still handles the command first, so a command that opens another + * modal keeps the modal window alive. */ function runSelectedCommand(): void { if (!commandsEnabled) return; const command = visibleRows[ctx.state.sessionHelpSelectedIndex]?.command; if (!command) return; - closeSessionHelpModal(); if (command.actionType === 'mpv-command') { - dispatchConfiguredMpvCommand(command.command, { + void dispatchConfiguredMpvCommand(command.command, { getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(), sendMpvCommand: (mpvCommand) => window.electronAPI.sendMpvCommand(mpvCommand), - }); + }).finally(closeSessionHelpModal); return; } - // Help is already closed, so surface failures on the mpv OSD. - void window.electronAPI - .dispatchSessionAction(command.actionId, command.payload) - .catch((error: unknown) => { - console.error(`Session help could not run ${command.actionId}`, error); - window.electronAPI.sendMpvCommand(['show-text', 'Command failed to run', '3000']); - }); + const dispatched = window.electronAPI.dispatchSessionAction(command.actionId, command.payload); + closeSessionHelpModal(); + // Help is closed by the time this settles, so surface failures on the mpv OSD. + void dispatched.catch((error: unknown) => { + console.error(`Session help could not run ${command.actionId}`, error); + window.electronAPI.sendMpvCommand(['show-text', 'Command failed to run', '3000']); + }); } function getRowIndex(target: EventTarget | null): number | null { diff --git a/src/renderer/utils/mpv-command-dispatch.ts b/src/renderer/utils/mpv-command-dispatch.ts index b6f61228..d8175d8f 100644 --- a/src/renderer/utils/mpv-command-dispatch.ts +++ b/src/renderer/utils/mpv-command-dispatch.ts @@ -7,6 +7,7 @@ function isSubtitleSeekCommand(command: MpvCommand): command is [string, number] /** * Sends a configured mpv command. Subtitle seeks re-pause afterwards unless playback * is known to be running, so stepping lines from a paused video stays paused. + * Resolves once every command has been sent; it never rejects. */ export function dispatchConfiguredMpvCommand( command: MpvCommand, @@ -14,14 +15,14 @@ export function dispatchConfiguredMpvCommand( getPlaybackPaused: () => Promise; sendMpvCommand: (command: MpvCommand) => void; }, -): void { +): Promise { if (!isSubtitleSeekCommand(command)) { deps.sendMpvCommand(command); - return; + return Promise.resolve(); } // The fallback only covers a failed pause lookup, so a failed re-pause never resends the seek. - void deps + return deps .getPlaybackPaused() .then( (paused) => {