From 0cac4467256a73ce1e58ddbb443cb139311fdb36 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 5 Mar 2026 18:39:40 -0800 Subject: [PATCH] fix: preserve keyboard subtitle navigation state --- plugin/subminer/process.lua | 30 +-- scripts/test-plugin-start-gate.lua | 20 +- src/core/services/tokenizer.test.ts | 107 ++++++++- .../tokenizer/parser-selection-stage.test.ts | 33 ++- .../tokenizer/parser-selection-stage.ts | 11 +- src/main.ts | 10 +- src/renderer/handlers/keyboard.test.ts | 206 +++++++++++++++--- src/renderer/handlers/keyboard.ts | 99 ++++++--- src/renderer/renderer.ts | 1 + 9 files changed, 434 insertions(+), 83 deletions(-) diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 6ae0b66..0bb0ea5 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -16,6 +16,7 @@ function M.create(ctx) local subminer_log = ctx.log.subminer_log local show_osd = ctx.log.show_osd local normalize_log_level = ctx.log.normalize_log_level + local run_control_command_async local function resolve_visible_overlay_startup() local raw_visible_overlay = opts.auto_start_visible_overlay @@ -132,6 +133,11 @@ function M.create(ctx) local function notify_auto_play_ready() release_auto_play_ready_gate("tokenization-ready") + if state.overlay_running and resolve_visible_overlay_startup() then + run_control_command_async("show-visible-overlay", { + socket_path = opts.socket_path, + }) + end end local function build_command_args(action, overrides) @@ -156,22 +162,18 @@ function M.create(ctx) table.insert(args, "--socket") table.insert(args, socket_path) - -- Keep auto-start --start requests idempotent for second-instance handling. - -- Visibility is applied as a separate control command after startup. - if overrides.auto_start_trigger ~= true then - local should_show_visible = resolve_visible_overlay_startup() - if should_show_visible then - table.insert(args, "--show-visible-overlay") - else - table.insert(args, "--hide-visible-overlay") - end + local should_show_visible = resolve_visible_overlay_startup() + if should_show_visible then + table.insert(args, "--show-visible-overlay") + else + table.insert(args, "--hide-visible-overlay") end end return args end - local function run_control_command_async(action, overrides, callback) + run_control_command_async = function(action, overrides, callback) local args = build_command_args(action, overrides) subminer_log("debug", "process", "Control command: " .. table.concat(args, " ")) mp.command_native_async({ @@ -290,6 +292,7 @@ function M.create(ctx) and "show-visible-overlay" or "hide-visible-overlay" run_control_command_async(visibility_action, { + socket_path = socket_path, log_level = overrides.log_level, }) return @@ -360,9 +363,10 @@ function M.create(ctx) local visibility_action = resolve_visible_overlay_startup() and "show-visible-overlay" or "hide-visible-overlay" - run_control_command_async(visibility_action, { - log_level = overrides.log_level, - }) + run_control_command_async(visibility_action, { + socket_path = socket_path, + log_level = overrides.log_level, + }) end end) diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 9d80731..eb9e67e 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -507,12 +507,12 @@ do local start_call = find_start_call(recorded.async_calls) assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true( - not call_has_arg(start_call, "--show-visible-overlay"), - "auto-start should keep --start command free of --show-visible-overlay" + call_has_arg(start_call, "--show-visible-overlay"), + "auto-start with visible overlay enabled should include --show-visible-overlay on --start" ) assert_true( not call_has_arg(start_call, "--hide-visible-overlay"), - "auto-start should keep --start command free of --hide-visible-overlay" + "auto-start with visible overlay enabled should not include --hide-visible-overlay on --start" ) assert_true( find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil, @@ -583,8 +583,8 @@ do "duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running" ) assert_true( - count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, - "duplicate pause-until-ready auto-start should still re-assert visible overlay state" + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 4, + "duplicate pause-until-ready auto-start should re-assert visible overlay on both start and ready events" ) assert_true( count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 2, @@ -644,6 +644,10 @@ do has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"), "autoplay-ready should show loaded OSD message" ) + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, + "autoplay-ready should re-assert visible overlay state" + ) assert_true( #recorded.periodic_timers == 1, "pause-until-ready auto-start should create periodic loading OSD refresher" @@ -703,12 +707,12 @@ do local start_call = find_start_call(recorded.async_calls) assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true( - not call_has_arg(start_call, "--hide-visible-overlay"), - "auto-start should keep --start command free of --hide-visible-overlay" + call_has_arg(start_call, "--hide-visible-overlay"), + "auto-start with visible overlay disabled should include --hide-visible-overlay on --start" ) assert_true( not call_has_arg(start_call, "--show-visible-overlay"), - "auto-start should keep --start command free of --show-visible-overlay" + "auto-start with visible overlay disabled should not include --show-visible-overlay on --start" ) assert_true( find_control_call(recorded.async_calls, "--hide-visible-overlay") ~= nil, diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index 5a9e42a..5b75e7d 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -1171,6 +1171,106 @@ test('tokenizeSubtitle returns null tokens when Yomitan parsing is unavailable', assert.deepEqual(result, { text: '猫です', tokens: null }); }); +test('tokenizeSubtitle skips token payload and annotations when Yomitan parse has no dictionary matches', async () => { + let frequencyRequested = false; + let jlptLookupCalls = 0; + let mecabCalls = 0; + + const result = await tokenizeSubtitle( + 'これはテスト', + makeDeps({ + getFrequencyDictionaryEnabled: () => true, + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async (script: string) => { + if (script.includes('getTermFrequencies')) { + frequencyRequested = true; + return []; + } + + return [ + { + source: 'scanning-parser', + index: 0, + content: [ + [{ text: 'これは', reading: 'これは' }], + [{ text: 'テスト', reading: 'てすと' }], + ], + }, + ]; + }, + }, + }) as unknown as Electron.BrowserWindow, + tokenizeWithMecab: async () => { + mecabCalls += 1; + return null; + }, + getJlptLevel: () => { + jlptLookupCalls += 1; + return 'N5'; + }, + }), + ); + + assert.deepEqual(result, { text: 'これはテスト', tokens: null }); + assert.equal(frequencyRequested, false); + assert.equal(jlptLookupCalls, 0); + assert.equal(mecabCalls, 0); +}); + +test('tokenizeSubtitle excludes Yomitan token groups without dictionary headwords from annotation paths', async () => { + let jlptLookupCalls = 0; + let frequencyLookupCalls = 0; + + const result = await tokenizeSubtitle( + '(ダクネスの荒い息) 猫', + makeDeps({ + getFrequencyDictionaryEnabled: () => true, + getYomitanExt: () => ({ id: 'dummy-ext' }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async (script: string) => { + if (script.includes('getTermFrequencies')) { + return []; + } + + return [ + { + source: 'scanning-parser', + index: 0, + content: [ + [{ text: '(ダクネスの荒い息)', reading: 'だくねすのあらいいき' }], + [{ text: '猫', reading: 'ねこ', headwords: [[{ term: '猫' }]] }], + ], + }, + ]; + }, + }, + }) as unknown as Electron.BrowserWindow, + getJlptLevel: (text) => { + jlptLookupCalls += 1; + return text === '猫' ? 'N5' : null; + }, + getFrequencyRank: () => { + frequencyLookupCalls += 1; + return 12; + }, + tokenizeWithMecab: async () => null, + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.surface, '猫'); + assert.equal(result.tokens?.[0]?.headword, '猫'); + assert.equal(jlptLookupCalls, 1); + assert.equal(frequencyLookupCalls, 1); +}); + test('tokenizeSubtitle returns null tokens when mecab throws', async () => { const result = await tokenizeSubtitle( '猫です', @@ -1184,7 +1284,7 @@ test('tokenizeSubtitle returns null tokens when mecab throws', async () => { assert.deepEqual(result, { text: '猫です', tokens: null }); }); -test('tokenizeSubtitle uses Yomitan parser result when available', async () => { +test('tokenizeSubtitle uses Yomitan parser result when available and drops no-headword groups', async () => { const parserWindow = { isDestroyed: () => false, webContents: { @@ -1222,13 +1322,10 @@ test('tokenizeSubtitle uses Yomitan parser result when available', async () => { ); assert.equal(result.text, '猫です'); - assert.equal(result.tokens?.length, 2); + assert.equal(result.tokens?.length, 1); assert.equal(result.tokens?.[0]?.surface, '猫'); assert.equal(result.tokens?.[0]?.reading, 'ねこ'); assert.equal(result.tokens?.[0]?.isKnown, false); - assert.equal(result.tokens?.[1]?.surface, 'です'); - assert.equal(result.tokens?.[1]?.reading, 'です'); - assert.equal(result.tokens?.[1]?.isKnown, false); }); test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled', async () => { diff --git a/src/core/services/tokenizer/parser-selection-stage.test.ts b/src/core/services/tokenizer/parser-selection-stage.test.ts index 9856d10..a34ac2f 100644 --- a/src/core/services/tokenizer/parser-selection-stage.test.ts +++ b/src/core/services/tokenizer/parser-selection-stage.test.ts @@ -51,7 +51,7 @@ test('prefers scanning parser when scanning candidate has more than one token', test('keeps scanning parser candidate when scanning candidate is single token', () => { const parseResults = [ makeParseItem('scanning-parser', [ - [{ text: '俺は公園にいきたい', reading: 'おれはこうえんにいきたい' }], + [{ text: '俺は公園にいきたい', reading: 'おれはこうえんにいきたい', headword: '行きたい' }], ]), makeParseItem('mecab', [ [{ text: '俺', reading: 'おれ', headword: '俺' }], @@ -96,3 +96,34 @@ test('returns null when only mecab-source candidates are present', () => { const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword'); assert.equal(tokens, null); }); + +test('returns null when scanning parser candidates have no dictionary headwords', () => { + const parseResults = [ + makeParseItem('scanning-parser', [ + [{ text: 'これは', reading: 'これは' }], + [{ text: 'テスト', reading: 'てすと' }], + ]), + ]; + + const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword'); + assert.equal(tokens, null); +}); + +test('drops scanning parser tokens which have no dictionary headword', () => { + const parseResults = [ + makeParseItem('scanning-parser', [ + [{ text: '(ダクネスの荒い息)', reading: 'だくねすのあらいいき' }], + [{ text: 'アクア', reading: 'あくあ', headword: 'アクア' }], + [{ text: 'トラウマ', reading: 'とらうま', headword: 'トラウマ' }], + ]), + ]; + + const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword'); + assert.deepEqual( + tokens?.map((token) => ({ surface: token.surface, headword: token.headword })), + [ + { surface: 'アクア', headword: 'アクア' }, + { surface: 'トラウマ', headword: 'トラウマ' }, + ], + ); +}); diff --git a/src/core/services/tokenizer/parser-selection-stage.ts b/src/core/services/tokenizer/parser-selection-stage.ts index 43fe9ac..572a3bb 100644 --- a/src/core/services/tokenizer/parser-selection-stage.ts +++ b/src/core/services/tokenizer/parser-selection-stage.ts @@ -130,6 +130,7 @@ export function mapYomitanParseResultItemToMergedTokens( const tokens: MergedToken[] = []; let charOffset = 0; let validLineCount = 0; + let hasDictionaryMatch = false; for (const line of content) { if (!isYomitanParseLine(line)) { @@ -163,7 +164,13 @@ export function mapYomitanParseResultItemToMergedTokens( const start = charOffset; const end = start + combinedSurface.length; charOffset = end; - const headword = combinedHeadword || combinedSurface; + if (!combinedHeadword) { + // No dictionary-backed headword for this merged unit; skip it entirely so + // downstream keyboard/frequency/JLPT flows only operate on lookup-backed tokens. + continue; + } + hasDictionaryMatch = true; + const headword = combinedHeadword; tokens.push({ surface: combinedSurface, @@ -182,7 +189,7 @@ export function mapYomitanParseResultItemToMergedTokens( }); } - if (validLineCount === 0 || tokens.length === 0) { + if (validLineCount === 0 || tokens.length === 0 || !hasDictionaryMatch) { return null; } diff --git a/src/main.ts b/src/main.ts index 824ca7b..2896075 100644 --- a/src/main.ts +++ b/src/main.ts @@ -871,12 +871,18 @@ function maybeSignalPluginAutoplayReady( if (duplicateMediaSignal && !allowDuplicateWhilePaused) { return; } - autoPlayReadySignalMediaPath = mediaPath; - const playbackGeneration = ++autoPlayReadySignalGeneration; const signalPluginAutoplayReady = (): void => { logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`); sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']); }; + if (duplicateMediaSignal && allowDuplicateWhilePaused) { + // Keep re-notifying the plugin while paused (for startup visibility sync), but + // do not run local unpause fallback on duplicates to avoid resuming user-paused playback. + signalPluginAutoplayReady(); + return; + } + autoPlayReadySignalMediaPath = mediaPath; + const playbackGeneration = ++autoPlayReadySignalGeneration; signalPluginAutoplayReady(); const isPlaybackPaused = async (client: { requestProperty: (property: string) => Promise; diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index ccbf41a..553780c 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -44,6 +44,8 @@ function installKeyboardTestGlobals() { const documentListeners = new Map void>>(); const commandEvents: CommandEventDetail[] = []; + const mpvCommands: Array> = []; + let playbackPausedResponse: boolean | null = false; let popupVisible = false; @@ -112,7 +114,10 @@ function installKeyboardTestGlobals() { }, electronAPI: { getKeybindings: async () => [], - sendMpvCommand: () => {}, + sendMpvCommand: (command: Array) => { + mpvCommands.push(command); + }, + getPlaybackPaused: async () => playbackPausedResponse, toggleDevTools: () => {}, focusMainWindow: () => { focusMainWindowCalls += 1; @@ -211,6 +216,7 @@ function installKeyboardTestGlobals() { return { commandEvents, + mpvCommands, overlay, overlayFocusCalls, focusMainWindowCalls: () => focusMainWindowCalls, @@ -220,6 +226,10 @@ function installKeyboardTestGlobals() { setPopupVisible: (value: boolean) => { popupVisible = value; }, + getPlaybackPaused: async () => playbackPausedResponse, + setPlaybackPausedResponse: (value: boolean | null) => { + playbackPausedResponse = value; + }, restore, }; } @@ -228,23 +238,12 @@ function createKeyboardHandlerHarness() { const testGlobals = installKeyboardTestGlobals(); const subtitleRootClassList = createClassList(); - const wordNodes = [ - { - classList: createClassList(), - getBoundingClientRect: () => ({ left: 10, top: 10, width: 30, height: 20 }), - dispatchEvent: () => true, - }, - { - classList: createClassList(), - getBoundingClientRect: () => ({ left: 80, top: 10, width: 30, height: 20 }), - dispatchEvent: () => true, - }, - { - classList: createClassList(), - getBoundingClientRect: () => ({ left: 150, top: 10, width: 30, height: 20 }), - dispatchEvent: () => true, - }, - ]; + const createWordNode = (left: number) => ({ + classList: createClassList(), + getBoundingClientRect: () => ({ left, top: 10, width: 30, height: 20 }), + dispatchEvent: () => true, + }); + let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)]; const ctx = { dom: { @@ -273,9 +272,17 @@ function createKeyboardHandlerHarness() { handleSessionHelpKeydown: () => false, openSessionHelpModal: () => {}, appendClipboardVideoToQueue: () => {}, + getPlaybackPaused: () => testGlobals.getPlaybackPaused(), }); - return { ctx, handlers, testGlobals }; + return { + ctx, + handlers, + testGlobals, + setWordCount: (count: number) => { + wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70)); + }, + }; } test('keyboard mode: left and right move token selection while popup remains open', async () => { @@ -306,7 +313,7 @@ test('keyboard mode: left and right move token selection while popup remains ope } }); -test('keyboard mode: up and j open yomitan lookup for selected token', async () => { +test('keyboard mode: up/down/j/k do not open or close lookup when popup is closed', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); try { @@ -314,19 +321,25 @@ test('keyboard mode: up and j open yomitan lookup for selected token', async () handlers.handleKeyboardModeToggleRequested(); testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' }); + testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' }); testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ' }); + testGlobals.dispatchKeydown({ key: 'k', code: 'KeyK' }); - await wait(80); + await wait(0); const openEvents = testGlobals.commandEvents.filter((event) => event.type === 'scanSelectedText'); - assert.equal(openEvents.length, 2); + assert.equal(openEvents.length, 0); + const closeEvents = testGlobals.commandEvents.filter( + (event) => event.type === 'setVisible' && event.visible === false, + ); + assert.equal(closeEvents.length, 0); } finally { ctx.state.keyboardDrivenModeEnabled = false; testGlobals.restore(); } }); -test('keyboard mode: down closes yomitan lookup window', async () => { +test('keyboard mode: up/down/j/k forward keydown to yomitan popup when open', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); try { @@ -336,13 +349,26 @@ test('keyboard mode: down closes yomitan lookup window', async () => { ctx.state.yomitanPopupVisible = true; testGlobals.setPopupVisible(true); + testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' }); testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' }); - await wait(0); + testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ' }); + testGlobals.dispatchKeydown({ key: 'k', code: 'KeyK' }); + const forwarded = testGlobals.commandEvents.filter( + (event) => event.type === 'forwardKeyDown', + ); + assert.equal(forwarded.length, 4); + assert.equal(forwarded.some((event) => event.code === 'ArrowUp'), true); + assert.equal(forwarded.some((event) => event.code === 'ArrowDown'), true); + assert.equal(forwarded.some((event) => event.code === 'KeyJ'), true); + assert.equal(forwarded.some((event) => event.code === 'KeyK'), true); + + const openEvents = testGlobals.commandEvents.filter((event) => event.type === 'scanSelectedText'); + assert.equal(openEvents.length, 0); const closeEvents = testGlobals.commandEvents.filter( (event) => event.type === 'setVisible' && event.visible === false, ); - assert.equal(closeEvents.length, 1); + assert.equal(closeEvents.length, 0); } finally { ctx.state.keyboardDrivenModeEnabled = false; testGlobals.restore(); @@ -407,7 +433,7 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () = await handlers.setupMpvInputForwarding(); handlers.handleKeyboardModeToggleRequested(); - testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' }); + testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true }); await wait(0); assert.equal(testGlobals.focusMainWindowCalls() > 0, true); @@ -419,6 +445,134 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () = } }); +test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => { + const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + setWordCount(3); + ctx.state.keyboardSelectedWordIndex = 2; + handlers.syncKeyboardTokenSelection(); + + testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' }); + await wait(0); + assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]); + + setWordCount(2); + handlers.syncKeyboardTokenSelection(); + assert.equal(ctx.state.keyboardSelectedWordIndex, 0); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: moving left beyond start jumps previous subtitle and sets selector to end', async () => { + const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + setWordCount(3); + ctx.state.keyboardSelectedWordIndex = 0; + handlers.syncKeyboardTokenSelection(); + + testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' }); + await wait(0); + assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]); + + setWordCount(4); + handlers.syncKeyboardTokenSelection(); + assert.equal(ctx.state.keyboardSelectedWordIndex, 3); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => { + const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + setWordCount(2); + ctx.state.keyboardSelectedWordIndex = 1; + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + handlers.syncKeyboardTokenSelection(); + + testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' }); + await wait(0); + assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]); + + setWordCount(3); + handlers.syncKeyboardTokenSelection(); + await wait(80); + + assert.equal(ctx.state.keyboardSelectedWordIndex, 0); + const openEvents = testGlobals.commandEvents.filter((event) => event.type === 'scanSelectedText'); + assert.equal(openEvents.length > 0, true); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => { + const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + setWordCount(2); + ctx.state.keyboardSelectedWordIndex = 1; + handlers.syncKeyboardTokenSelection(); + testGlobals.setPlaybackPausedResponse(true); + + testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' }); + await wait(0); + + assert.deepEqual(testGlobals.mpvCommands.slice(-2), [ + ['sub-seek', 1], + ['set_property', 'pause', 'yes'], + ]); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: edge jump with unknown pause state re-applies pause conservatively', async () => { + const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + + setWordCount(2); + ctx.state.keyboardSelectedWordIndex = 1; + handlers.syncKeyboardTokenSelection(); + testGlobals.setPlaybackPausedResponse(null); + + testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' }); + await wait(0); + + assert.deepEqual(testGlobals.mpvCommands.slice(-2), [ + ['sub-seek', 1], + ['set_property', 'pause', 'yes'], + ]); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + test('keyboard mode: popup iframe focusin reclaims overlay keyboard focus', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 5dd4bfa..64f0b13 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -22,11 +22,14 @@ export function createKeyboardHandlers( fallbackUnavailable: boolean; }) => void; appendClipboardVideoToQueue: () => void; + getPlaybackPaused: () => Promise; }, ) { // Timeout for the modal chord capture window (e.g. Y followed by H/K). const CHORD_TIMEOUT_MS = 1000; const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected'; + let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null; + let pendingLookupRefreshAfterSubtitleSeek = false; const CHORD_MAP = new Map< string, @@ -138,9 +141,28 @@ export function createKeyboardHandlers( if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) { ctx.state.keyboardSelectedWordIndex = null; + if (!ctx.state.keyboardDrivenModeEnabled) { + pendingSelectionAnchorAfterSubtitleSeek = null; + pendingLookupRefreshAfterSubtitleSeek = false; + } return; } + if (pendingSelectionAnchorAfterSubtitleSeek) { + ctx.state.keyboardSelectedWordIndex = + pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1; + pendingSelectionAnchorAfterSubtitleSeek = null; + const shouldRefreshLookup = + pendingLookupRefreshAfterSubtitleSeek && + (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)); + pendingLookupRefreshAfterSubtitleSeek = false; + if (shouldRefreshLookup) { + queueMicrotask(() => { + triggerLookupForSelectedWord(); + }); + } + } + const selectedIndex = Math.min( Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0), wordNodes.length - 1, @@ -156,6 +178,8 @@ export function createKeyboardHandlers( ctx.state.keyboardDrivenModeEnabled = enabled; if (!enabled) { ctx.state.keyboardSelectedWordIndex = null; + pendingSelectionAnchorAfterSubtitleSeek = null; + pendingLookupRefreshAfterSubtitleSeek = false; } syncKeyboardTokenSelection(); } @@ -164,19 +188,45 @@ export function createKeyboardHandlers( setKeyboardDrivenModeEnabled(!ctx.state.keyboardDrivenModeEnabled); } - function moveKeyboardSelection(delta: -1 | 1): boolean { + function moveKeyboardSelection(delta: -1 | 1): 'moved' | 'start-boundary' | 'end-boundary' | 'no-words' { const wordNodes = getSubtitleWordNodes(); if (wordNodes.length === 0) { ctx.state.keyboardSelectedWordIndex = null; syncKeyboardTokenSelection(); - return true; + return 'no-words'; } - const currentIndex = ctx.state.keyboardSelectedWordIndex ?? 0; - const nextIndex = Math.min(Math.max(currentIndex + delta, 0), wordNodes.length - 1); + const currentIndex = Math.min( + Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0), + wordNodes.length - 1, + ); + if (delta < 0 && currentIndex <= 0) { + return 'start-boundary'; + } + if (delta > 0 && currentIndex >= wordNodes.length - 1) { + return 'end-boundary'; + } + + const nextIndex = currentIndex + delta; ctx.state.keyboardSelectedWordIndex = nextIndex; syncKeyboardTokenSelection(); - return true; + return 'moved'; + } + + function seekAdjacentSubtitleAndQueueSelection(delta: -1 | 1, popupVisible: boolean): void { + pendingSelectionAnchorAfterSubtitleSeek = delta > 0 ? 'start' : 'end'; + pendingLookupRefreshAfterSubtitleSeek = popupVisible; + void options + .getPlaybackPaused() + .then((paused) => { + window.electronAPI.sendMpvCommand(['sub-seek', delta]); + if (paused !== false) { + window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']); + } + }) + .catch(() => { + window.electronAPI.sendMpvCommand(['sub-seek', delta]); + }); } type ScanModifierState = { @@ -346,10 +396,18 @@ export function createKeyboardHandlers( const key = e.code; if (key === 'ArrowLeft') { - return moveKeyboardSelection(-1); + const result = moveKeyboardSelection(-1); + if (result === 'start-boundary') { + seekAdjacentSubtitleAndQueueSelection(-1, false); + } + return result !== 'no-words'; } if (key === 'ArrowRight' || key === 'KeyL') { - return moveKeyboardSelection(1); + const result = moveKeyboardSelection(1); + if (result === 'end-boundary') { + seekAdjacentSubtitleAndQueueSelection(1, false); + } + return result !== 'no-words'; } return false; } @@ -364,32 +422,21 @@ export function createKeyboardHandlers( const key = e.code; const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document); - if (key === 'ArrowUp' || key === 'KeyJ') { - triggerLookupForSelectedWord(); - return true; - } - - if (key === 'ArrowDown') { - if (popupVisible) { - dispatchYomitanPopupVisibility(false); - queueMicrotask(() => { - restoreOverlayKeyboardFocus(); - }); - } - return true; - } - if (key === 'ArrowLeft' || key === 'KeyH') { - moveKeyboardSelection(-1); - if (popupVisible) { + const result = moveKeyboardSelection(-1); + if (result === 'start-boundary') { + seekAdjacentSubtitleAndQueueSelection(-1, popupVisible); + } else if (popupVisible && result === 'moved') { triggerLookupForSelectedWord(); } return true; } if (key === 'ArrowRight' || key === 'KeyL') { - moveKeyboardSelection(1); - if (popupVisible) { + const result = moveKeyboardSelection(1); + if (result === 'end-boundary') { + seekAdjacentSubtitleAndQueueSelection(1, popupVisible); + } else if (popupVisible && result === 'moved') { triggerLookupForSelectedWord(); } return true; diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 59767fb..40d8f00 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -114,6 +114,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, { appendClipboardVideoToQueue: () => { void window.electronAPI.appendClipboardVideoToQueue(); }, + getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(), }); const mouseHandlers = createMouseHandlers(ctx, { modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },