diff --git a/changes/fix-animated-avif-word-audio-sync.md b/changes/fix-animated-avif-word-audio-sync.md new file mode 100644 index 00000000..b30d5d0e --- /dev/null +++ b/changes/fix-animated-avif-word-audio-sync.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Fixed animated AVIF word-audio sync so the frozen lead-in matches the word audio duration without adding sentence audio padding a second time. diff --git a/changes/fix-multiline-copy-overlay-focus.md b/changes/fix-multiline-copy-overlay-focus.md new file mode 100644 index 00000000..94b1e02c --- /dev/null +++ b/changes/fix-multiline-copy-overlay-focus.md @@ -0,0 +1,4 @@ +type: fixed +area: shortcuts + +- Focus the visible overlay when entering multi-line copy/mine selection so number keys choose the line count on macOS and Windows. diff --git a/changes/fix-youtube-manual-card-update-media.md b/changes/fix-youtube-manual-card-update-media.md new file mode 100644 index 00000000..88773dba --- /dev/null +++ b/changes/fix-youtube-manual-card-update-media.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Fixed manual clipboard card updates from YouTube playback so generated audio and images use mpv's resolved stream URLs instead of the YouTube page URL. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 00eaf2d5..ff4f7f1c 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -29,7 +29,7 @@ These work when the overlay window has focus. | `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` | | `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` | -The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select how many recent subtitle lines to combine. +The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select how many recent subtitle lines to combine. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin. ## Overlay Controls diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index 47de9f81..194aec56 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -134,7 +134,10 @@ function M.create(ctx) elseif action_id == "copySubtitle" then return { "--copy-subtitle" } elseif action_id == "copySubtitleMultiple" then - return { "--copy-subtitle-count", tostring(payload and payload.count or 1) } + if payload and payload.count then + return { "--copy-subtitle-count", tostring(payload.count) } + end + return { "--copy-subtitle-multiple" } elseif action_id == "updateLastCardFromClipboard" then return { "--update-last-card-from-clipboard" } elseif action_id == "triggerFieldGrouping" then @@ -144,7 +147,10 @@ function M.create(ctx) elseif action_id == "mineSentence" then return { "--mine-sentence" } elseif action_id == "mineSentenceMultiple" then - return { "--mine-sentence-count", tostring(payload and payload.count or 1) } + if payload and payload.count then + return { "--mine-sentence-count", tostring(payload.count) } + end + return { "--mine-sentence-multiple" } elseif action_id == "toggleSecondarySub" then return { "--toggle-secondary-sub" } elseif action_id == "toggleSubtitleSidebar" then @@ -232,73 +238,6 @@ function M.create(ctx) end) end - local function clear_numeric_selection(show_cancelled) - if state.session_numeric_selection and state.session_numeric_selection.timeout then - state.session_numeric_selection.timeout:kill() - end - state.session_numeric_selection = nil - remove_binding_names(state.session_numeric_binding_names) - if show_cancelled then - show_osd("Cancelled") - end - end - - local function build_modifier_prefixes(modifiers) - local prefixes = { "" } - if type(modifiers) ~= "table" then - return prefixes - end - - for _, modifier in ipairs(modifiers) do - local mapped = MODIFIER_MAP[modifier] - if mapped then - local existing_count = #prefixes - for index = 1, existing_count do - prefixes[#prefixes + 1] = prefixes[index] .. mapped .. "+" - end - end - end - return prefixes - end - - local function start_numeric_selection(action_id, timeout_ms, starter_modifiers) - clear_numeric_selection(false) - local modifier_prefixes = build_modifier_prefixes(starter_modifiers) - for digit = 1, 9 do - local digit_string = tostring(digit) - for _, prefix in ipairs(modifier_prefixes) do - local key_name = prefix .. digit_string - local modifier_name = prefix:gsub("[^%w]", "-") - local name = "subminer-session-digit-" .. modifier_name .. digit_string - state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name - mp.add_forced_key_binding(key_name, name, function() - clear_numeric_selection(false) - invoke_cli_action(action_id, { count = digit }) - end) - end - end - - state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = - "subminer-session-digit-cancel" - mp.add_forced_key_binding("ESC", "subminer-session-digit-cancel", function() - clear_numeric_selection(true) - end) - - state.session_numeric_selection = { - action_id = action_id, - timeout = mp.add_timeout((timeout_ms or 3000) / 1000, function() - clear_numeric_selection(false) - show_osd(action_id == "copySubtitleMultiple" and "Copy timeout" or "Mine timeout") - end), - } - - show_osd( - action_id == "copySubtitleMultiple" - and "Copy how many lines? Press 1-9 (Esc to cancel)" - or "Mine how many lines? Press 1-9 (Esc to cancel)" - ) - end - local function execute_mpv_command(command) if type(command) ~= "table" or command[1] == nil then return @@ -306,17 +245,12 @@ function M.create(ctx) mp.commandv(unpack_fn(command)) end - local function handle_binding(binding, numeric_selection_timeout_ms) + local function handle_binding(binding) if binding.actionType == "mpv-command" then execute_mpv_command(binding.command) return end - if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then - start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers) - return - end - invoke_cli_action(binding.actionId, binding.payload) end @@ -339,7 +273,6 @@ function M.create(ctx) end local function clear_bindings() - clear_numeric_selection(false) remove_binding_names(state.session_binding_names) end @@ -350,21 +283,18 @@ function M.create(ctx) return false end - clear_numeric_selection(false) - local previous_binding_names = state.session_binding_names local next_binding_names = {} state.session_binding_generation = (state.session_binding_generation or 0) + 1 local generation = state.session_binding_generation - local timeout_ms = tonumber(artifact.numericSelectionTimeoutMs) or 3000 for index, binding in ipairs(artifact.bindings) do local key_name = key_spec_to_mpv_binding(binding.key) if key_name then local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index) next_binding_names[#next_binding_names + 1] = name mp.add_forced_key_binding(key_name, name, function() - handle_binding(binding, timeout_ms) + handle_binding(binding) end) else subminer_log( diff --git a/scripts/test-plugin-session-bindings.lua b/scripts/test-plugin-session-bindings.lua index 69d800e4..437cfa6a 100644 --- a/scripts/test-plugin-session-bindings.lua +++ b/scripts/test-plugin-session-bindings.lua @@ -351,21 +351,10 @@ assert_true( starter.fn() -local modified_digit = nil -for _, binding in ipairs(recorded.bindings) do - if binding.keys == "Ctrl+Shift+3" then - modified_digit = binding - break - end -end -assert_true(modified_digit ~= nil, "numeric selection should bind Ctrl+Shift+3") - -modified_digit.fn() - local call = recorded.async_calls[#recorded.async_calls] -assert_true(call ~= nil, "modified digit should invoke CLI action") +assert_true(call ~= nil, "multi-line shortcut should invoke CLI action") assert_true(call[1] == "/tmp/subminer", "CLI action should use configured binary") -assert_true(call[2] == "--mine-sentence-count", "CLI action should mine sentence count") -assert_true(call[3] == "3", "CLI action should pass selected count") +assert_true(call[2] == "--mine-sentence-multiple", "CLI action should enter mine sentence count selector") +assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count") print("plugin session binding regression tests: OK") diff --git a/src/anki-integration/animated-image-sync.test.ts b/src/anki-integration/animated-image-sync.test.ts index a34225fa..6f18cba5 100644 --- a/src/anki-integration/animated-image-sync.test.ts +++ b/src/anki-integration/animated-image-sync.test.ts @@ -50,7 +50,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for assert.equal(leadInSeconds, 1.25); }); -test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audio duration', async () => { +test('resolveAnimatedImageLeadInSeconds does not double-count sentence audio padding', async () => { const leadInSeconds = await resolveAnimatedImageLeadInSeconds({ config: { fields: { @@ -87,7 +87,7 @@ test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audi logWarn: () => undefined, }); - assert.equal(leadInSeconds, 1.75); + assert.equal(leadInSeconds, 1.25); }); test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => { diff --git a/src/anki-integration/animated-image-sync.ts b/src/anki-integration/animated-image-sync.ts index 3931dfbc..25282873 100644 --- a/src/anki-integration/animated-image-sync.ts +++ b/src/anki-integration/animated-image-sync.ts @@ -39,14 +39,6 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick): number { - const configuredPadding = config.media?.audioPadding; - if (typeof configuredPadding === 'number' && Number.isFinite(configuredPadding)) { - return configuredPadding; - } - return DEFAULT_ANKI_CONNECT_CONFIG.media.audioPadding; -} - export async function probeAudioDurationSeconds( buffer: Buffer, filename: string, @@ -135,5 +127,5 @@ export async function resolveAnimatedImageLeadInSeconds { + const audioPaths: string[] = []; + const imagePaths: string[] = []; + const edlSource = [ + 'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', + '!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', + '!global_tags,title=test', + ].join(';'); + + const { service, updatedFields, storedMedia } = createManualUpdateService({ + getConfig: () => + ({ + deck: 'Mining', + fields: { + word: 'Expression', + sentence: 'Sentence', + audio: 'ExpressionAudio', + image: 'Picture', + }, + media: { + generateAudio: true, + generateImage: true, + imageFormat: 'jpg', + maxMediaDuration: 30, + }, + behavior: { + overwriteAudio: false, + overwriteImage: false, + }, + ai: false, + }) as AnkiConnectConfig, + getTimingTracker: () => + ({ + findTiming: (text: string) => { + if (text === '一行目') return { startTime: 10, endTime: 12 }; + if (text === '二行目') return { startTime: 12.5, endTime: 14 }; + return null; + }, + }) as never, + getMpvClient: () => + ({ + currentVideoPath: 'https://www.youtube.com/watch?v=abc123', + currentTimePos: 13, + currentAudioStreamIndex: 0, + requestProperty: async (name: string) => { + assert.equal(name, 'stream-open-filename'); + return edlSource; + }, + }) as never, + client: { + addNote: async () => 0, + addTags: async () => undefined, + notesInfo: async () => [ + { + noteId: 42, + fields: { + Expression: { value: '単語' }, + Sentence: { value: '' }, + ExpressionAudio: { value: '[sound:auto-expression.mp3]' }, + SentenceAudio: { value: '[sound:auto-sentence.mp3]' }, + Picture: { value: '' }, + }, + }, + ], + updateNoteFields: async (_noteId, fields) => { + updatedFields.push(fields); + }, + storeMediaFile: async (filename) => { + storedMedia.push(filename); + }, + findNotes: async () => [42], + retrieveMediaFile: async () => '', + }, + mediaGenerator: { + generateAudio: async (path) => { + audioPaths.push(path); + return Buffer.from('audio'); + }, + generateScreenshot: async (path) => { + imagePaths.push(path); + return Buffer.from('image'); + }, + generateAnimatedImage: async () => null, + }, + }); + + await service.updateLastAddedFromClipboard('一行目\n\n二行目'); + + assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']); + assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']); + assert.equal(storedMedia.length, 2); + assert.equal(updatedFields.length, 1); + assert.equal(updatedFields[0]?.Sentence, '一行目 二行目'); + assert.match(updatedFields[0]?.Picture ?? '', /^$/); +}); diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index a5349fd8..9de9bac0 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -237,14 +237,19 @@ export class CardCreationService { `Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`, ); + const audioSourcePath = this.deps.getConfig().media?.generateAudio + ? await resolveMediaGenerationInputPath(mpvClient, 'audio') + : null; + const videoPath = this.deps.getConfig().media?.generateImage + ? await resolveMediaGenerationInputPath(mpvClient, 'video') + : null; + if (this.deps.getConfig().media?.generateAudio) { try { const audioFilename = this.generateAudioFilename(); - const audioBuffer = await this.mediaGenerateAudio( - mpvClient.currentVideoPath, - rangeStart, - rangeEnd, - ); + const audioBuffer = audioSourcePath + ? await this.mediaGenerateAudio(audioSourcePath, rangeStart, rangeEnd) + : null; if (audioBuffer) { await this.deps.client.storeMediaFile(audioFilename, audioBuffer); @@ -271,12 +276,14 @@ export class CardCreationService { try { const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const imageFilename = this.generateImageFilename(); - const imageBuffer = await this.generateImageBuffer( - mpvClient.currentVideoPath, - rangeStart, - rangeEnd, - animatedLeadInSeconds, - ); + const imageBuffer = videoPath + ? await this.generateImageBuffer( + videoPath, + rangeStart, + rangeEnd, + animatedLeadInSeconds, + ) + : null; if (imageBuffer) { await this.deps.client.storeMediaFile(imageFilename, imageBuffer); diff --git a/src/main.ts b/src/main.ts index 629600a8..463c17ab 100644 --- a/src/main.ts +++ b/src/main.ts @@ -460,6 +460,7 @@ import { composeStartupLifecycleHandlers, } from './main/runtime/composers'; import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers'; +import { tryBeginVisibleOverlayNumericSelection } from './main/runtime/overlay-numeric-selection'; import { createStartupBootstrapRuntimeDeps } from './main/startup'; import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle'; import { @@ -4823,6 +4824,20 @@ const { numericSessions: { onMultiCopyDigit: (count) => handleMultiCopyDigit(count), onMineSentenceDigit: (count) => handleMineSentenceDigit(count), + tryBeginMultiCopyOverlaySelection: (timeoutMs) => + tryBeginVisibleOverlayNumericSelection({ + actionId: 'copySubtitleMultiple', + timeoutMs, + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + }), + tryBeginMineSentenceOverlaySelection: (timeoutMs) => + tryBeginVisibleOverlayNumericSelection({ + actionId: 'mineSentenceMultiple', + timeoutMs, + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + }), }, overlayShortcutsRuntimeMainDeps: { overlayShortcutsRuntime, diff --git a/src/main/runtime/numeric-shortcut-session-runtime-handlers.test.ts b/src/main/runtime/numeric-shortcut-session-runtime-handlers.test.ts index 65f60137..f83230a9 100644 --- a/src/main/runtime/numeric-shortcut-session-runtime-handlers.test.ts +++ b/src/main/runtime/numeric-shortcut-session-runtime-handlers.test.ts @@ -33,3 +33,28 @@ test('numeric shortcut session runtime handlers compose cancel/start handlers', 'mine-sentence:digit:3', ]); }); + +test('numeric shortcut session runtime handlers prefer overlay digit selection when available', () => { + const calls: string[] = []; + const createSession = (name: string) => ({ + start: () => calls.push(`${name}:start`), + cancel: () => calls.push(`${name}:cancel`), + }); + + const runtime = createNumericShortcutSessionRuntimeHandlers({ + multiCopySession: createSession('multi-copy'), + mineSentenceSession: createSession('mine-sentence'), + onMultiCopyDigit: () => calls.push('multi-copy:digit'), + onMineSentenceDigit: () => calls.push('mine-sentence:digit'), + tryBeginMultiCopyOverlaySelection: (timeoutMs) => { + calls.push(`multi-copy:overlay:${timeoutMs}`); + return true; + }, + tryBeginMineSentenceOverlaySelection: () => false, + }); + + runtime.startPendingMultiCopy(500); + runtime.startPendingMineSentenceMultiple(700); + + assert.deepEqual(calls, ['multi-copy:overlay:500', 'mine-sentence:start']); +}); diff --git a/src/main/runtime/numeric-shortcut-session-runtime-handlers.ts b/src/main/runtime/numeric-shortcut-session-runtime-handlers.ts index 946ea77b..77a0932b 100644 --- a/src/main/runtime/numeric-shortcut-session-runtime-handlers.ts +++ b/src/main/runtime/numeric-shortcut-session-runtime-handlers.ts @@ -16,6 +16,8 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: { mineSentenceSession: CancelNumericShortcutSessionMainDeps['session']; onMultiCopyDigit: (count: number) => void; onMineSentenceDigit: (count: number) => void; + tryBeginMultiCopyOverlaySelection?: (timeoutMs: number) => boolean; + tryBeginMineSentenceOverlaySelection?: (timeoutMs: number) => boolean; }) { const cancelPendingMultiCopyMainDeps = createBuildCancelNumericShortcutSessionMainDepsHandler({ session: deps.multiCopySession, @@ -61,9 +63,14 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: { return { cancelPendingMultiCopy: () => cancelPendingMultiCopyHandler(), - startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopyHandler(timeoutMs), + startPendingMultiCopy: (timeoutMs: number) => { + if (deps.tryBeginMultiCopyOverlaySelection?.(timeoutMs)) return; + startPendingMultiCopyHandler(timeoutMs); + }, cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultipleHandler(), - startPendingMineSentenceMultiple: (timeoutMs: number) => - startPendingMineSentenceMultipleHandler(timeoutMs), + startPendingMineSentenceMultiple: (timeoutMs: number) => { + if (deps.tryBeginMineSentenceOverlaySelection?.(timeoutMs)) return; + startPendingMineSentenceMultipleHandler(timeoutMs); + }, }; } diff --git a/src/main/runtime/overlay-numeric-selection.test.ts b/src/main/runtime/overlay-numeric-selection.test.ts new file mode 100644 index 00000000..3332edf3 --- /dev/null +++ b/src/main/runtime/overlay-numeric-selection.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { tryBeginVisibleOverlayNumericSelection } from './overlay-numeric-selection'; + +function createWindowStub( + options: { + destroyed?: boolean; + visible?: boolean; + focused?: boolean; + webContentsFocused?: boolean; + } = {}, +) { + const calls: string[] = []; + return { + calls, + window: { + isDestroyed: () => options.destroyed === true, + isVisible: () => options.visible !== false, + isFocused: () => options.focused === true, + setIgnoreMouseEvents: (ignore: boolean) => { + calls.push(`mouse:${ignore}`); + }, + focus: () => { + calls.push('focus'); + }, + webContents: { + isFocused: () => options.webContentsFocused === true, + focus: () => { + calls.push('web-focus'); + }, + send: (channel: string, payload: unknown) => { + calls.push(`send:${channel}:${JSON.stringify(payload)}`); + }, + }, + }, + }; +} + +test('tryBeginVisibleOverlayNumericSelection focuses visible overlay and sends selector event', () => { + const { window, calls } = createWindowStub(); + + const handled = tryBeginVisibleOverlayNumericSelection({ + actionId: 'copySubtitleMultiple', + timeoutMs: 1234, + getMainWindow: () => window, + getVisibleOverlayVisible: () => true, + }); + + assert.equal(handled, true); + assert.deepEqual(calls, [ + 'mouse:false', + 'focus', + 'web-focus', + `send:${IPC_CHANNELS.event.sessionNumericSelectionStart}:{"actionId":"copySubtitleMultiple","timeoutMs":1234}`, + ]); +}); + +test('tryBeginVisibleOverlayNumericSelection skips hidden visible overlay', () => { + const { window, calls } = createWindowStub({ visible: false }); + + const handled = tryBeginVisibleOverlayNumericSelection({ + actionId: 'mineSentenceMultiple', + timeoutMs: 3000, + getMainWindow: () => window, + getVisibleOverlayVisible: () => true, + }); + + assert.equal(handled, false); + assert.deepEqual(calls, []); +}); diff --git a/src/main/runtime/overlay-numeric-selection.ts b/src/main/runtime/overlay-numeric-selection.ts new file mode 100644 index 00000000..19344e54 --- /dev/null +++ b/src/main/runtime/overlay-numeric-selection.ts @@ -0,0 +1,47 @@ +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import type { SessionNumericSelectionStartPayload } from '../../types/runtime'; + +type OverlayNumericSelectionWindow = { + isDestroyed: () => boolean; + isVisible: () => boolean; + isFocused?: () => boolean; + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; + focus: () => void; + webContents: { + isFocused?: () => boolean; + focus: () => void; + send: (channel: string, payload: SessionNumericSelectionStartPayload) => void; + }; +}; + +export function tryBeginVisibleOverlayNumericSelection(options: { + actionId: SessionNumericSelectionStartPayload['actionId']; + timeoutMs: number; + getMainWindow: () => OverlayNumericSelectionWindow | null; + getVisibleOverlayVisible: () => boolean; +}): boolean { + if (!options.getVisibleOverlayVisible()) { + return false; + } + + const mainWindow = options.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) { + return false; + } + + mainWindow.setIgnoreMouseEvents(false); + if (typeof mainWindow.isFocused !== 'function' || !mainWindow.isFocused()) { + mainWindow.focus(); + } + if ( + typeof mainWindow.webContents.isFocused !== 'function' || + !mainWindow.webContents.isFocused() + ) { + mainWindow.webContents.focus(); + } + mainWindow.webContents.send(IPC_CHANNELS.event.sessionNumericSelectionStart, { + actionId: options.actionId, + timeoutMs: options.timeoutMs, + }); + return true; +} diff --git a/src/preload.ts b/src/preload.ts index dce58d64..3622c31d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -54,6 +54,7 @@ import type { ControllerConfigUpdate, ControllerPreferenceUpdate, ResolvedControllerConfig, + SessionNumericSelectionStartPayload, YoutubePickerOpenPayload, YoutubePickerResolveRequest, YoutubePickerResolveResult, @@ -171,6 +172,11 @@ const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.pl const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener( IPC_CHANNELS.event.youtubePickerCancel, ); +const onSessionNumericSelectionStartEvent = + createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.sessionNumericSelectionStart, + (payload) => payload as SessionNumericSelectionStartPayload, + ); const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener( IPC_CHANNELS.event.keyboardModeToggleRequested, ); @@ -385,6 +391,7 @@ const electronAPI: ElectronAPI = { onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent, onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, + onSessionNumericSelectionStart: onSessionNumericSelectionStartEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, appendClipboardVideoToQueue: (): Promise => diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 650eb8b0..e3b88908 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -670,6 +670,21 @@ test('numeric selection ignores non-digit keys instead of falling through to oth } }); +test('numeric selection start focuses overlay for follow-up digit keys', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.beginSessionNumericSelection('copySubtitleMultiple'); + + assert.equal(testGlobals.focusMainWindowCalls() > 0, true); + assert.equal(testGlobals.windowFocusCalls() > 0, true); + assert.equal(testGlobals.overlayFocusCalls.length > 0, true); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: left and right move token selection while popup remains open', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 585710e1..cefdd995 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -147,6 +147,7 @@ export function createKeyboardHandlers( function startPendingNumericSelection( actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple', + timeoutMs: number = ctx.state.sessionActionTimeoutMs, ): void { cancelPendingNumericSelection(false); const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout'; @@ -159,15 +160,17 @@ export function createKeyboardHandlers( timeout: setTimeout(() => { pendingNumericSelection = null; showSessionSelectionMessage(timeoutMessage); - }, ctx.state.sessionActionTimeoutMs), + }, timeoutMs), }; showSessionSelectionMessage(promptMessage); } function beginSessionNumericSelection( actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple', + timeoutMs?: number, ): void { - startPendingNumericSelection(actionId); + startPendingNumericSelection(actionId, timeoutMs); + restoreOverlayKeyboardFocus(); } function handlePendingNumericSelection(e: KeyboardEvent): boolean { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 380e3dea..84cc5e1c 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -530,6 +530,12 @@ function registerModalOpenHandlers(): void { } function registerKeyboardCommandHandlers(): void { + window.electronAPI.onSessionNumericSelectionStart((payload) => { + runGuarded('session:numeric-selection-start', () => { + keyboardHandlers.beginSessionNumericSelection(payload.actionId, payload.timeoutMs); + }); + }); + window.electronAPI.onKeyboardModeToggleRequested(() => { runGuarded('keyboard-mode-toggle:requested', () => { keyboardHandlers.handleKeyboardModeToggleRequested(); diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 816dda44..3f880cc4 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -123,6 +123,7 @@ export const IPC_CHANNELS = { youtubePickerOpen: 'youtube:picker-open', youtubePickerCancel: 'youtube:picker-cancel', playlistBrowserOpen: 'playlist-browser:open', + sessionNumericSelectionStart: 'session:numeric-selection-start', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested', sessionHelpOpen: 'session-help:open', diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 188cef24..4a1bb0ae 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -378,6 +378,11 @@ export interface CharacterDictionarySelectionResult { staleMediaIds: number[]; } +export interface SessionNumericSelectionStartPayload { + actionId: Extract; + timeoutMs: number; +} + export interface ElectronAPI { getOverlayLayer: () => 'visible' | 'modal' | null; onSubtitle: (callback: (data: SubtitleData) => void) => void; @@ -451,6 +456,9 @@ export interface ElectronAPI { onSubtitleSidebarToggle: (callback: () => void) => void; onPrimarySubtitleBarToggle: (callback: () => void) => void; onCancelYoutubeTrackPicker: (callback: () => void) => void; + onSessionNumericSelectionStart: ( + callback: (payload: SessionNumericSelectionStartPayload) => void, + ) => void; onKeyboardModeToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void; appendClipboardVideoToQueue: () => Promise;