fix: delegate multi-line digit selection to visible overlay

- Focus Electron overlay for copy/mine multi-line shortcuts instead of binding number keys in mpv plugin
- Fix animated AVIF lead-in double-counting sentence audio padding
- Fix manual YouTube card update to use resolved mpv stream URLs for media generation
This commit is contained in:
2026-05-22 21:15:30 -07:00
parent c6328eef09
commit 7b5c1ccf85
21 changed files with 354 additions and 122 deletions
@@ -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.
@@ -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.
@@ -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.
+1 -1
View File
@@ -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
+10 -80
View File
@@ -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(
+3 -14
View File
@@ -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")
@@ -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 () => {
+1 -9
View File
@@ -39,14 +39,6 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'med
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
}
function resolveSentenceAudioStartOffsetSeconds(config: Pick<AnkiConnectConfig, 'media'>): 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<TNoteInfo extends NoteIn
totalLeadInSeconds += durationSeconds;
}
return totalLeadInSeconds + resolveSentenceAudioStartOffsetSeconds(config);
return totalLeadInSeconds;
}
@@ -175,3 +175,99 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
assert.equal(mergeCalls.length, 0);
});
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
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 ?? '', /^<img src="image_\d+\.jpg">$/);
});
+18 -11
View File
@@ -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);
+15
View File
@@ -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,
@@ -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']);
});
@@ -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);
},
};
}
@@ -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, []);
});
@@ -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;
}
+7
View File
@@ -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<SessionNumericSelectionStartPayload>(
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<ClipboardAppendResult> =>
+15
View File
@@ -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();
+5 -2
View File
@@ -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 {
+6
View File
@@ -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();
+1
View File
@@ -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',
+8
View File
@@ -378,6 +378,11 @@ export interface CharacterDictionarySelectionResult {
staleMediaIds: number[];
}
export interface SessionNumericSelectionStartPayload {
actionId: Extract<SessionActionId, 'copySubtitleMultiple' | 'mineSentenceMultiple'>;
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<ClipboardAppendResult>;