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+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` |
| `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` | | `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 ## Overlay Controls
+10 -80
View File
@@ -134,7 +134,10 @@ function M.create(ctx)
elseif action_id == "copySubtitle" then elseif action_id == "copySubtitle" then
return { "--copy-subtitle" } return { "--copy-subtitle" }
elseif action_id == "copySubtitleMultiple" then 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 elseif action_id == "updateLastCardFromClipboard" then
return { "--update-last-card-from-clipboard" } return { "--update-last-card-from-clipboard" }
elseif action_id == "triggerFieldGrouping" then elseif action_id == "triggerFieldGrouping" then
@@ -144,7 +147,10 @@ function M.create(ctx)
elseif action_id == "mineSentence" then elseif action_id == "mineSentence" then
return { "--mine-sentence" } return { "--mine-sentence" }
elseif action_id == "mineSentenceMultiple" then 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 elseif action_id == "toggleSecondarySub" then
return { "--toggle-secondary-sub" } return { "--toggle-secondary-sub" }
elseif action_id == "toggleSubtitleSidebar" then elseif action_id == "toggleSubtitleSidebar" then
@@ -232,73 +238,6 @@ function M.create(ctx)
end) end)
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) local function execute_mpv_command(command)
if type(command) ~= "table" or command[1] == nil then if type(command) ~= "table" or command[1] == nil then
return return
@@ -306,17 +245,12 @@ function M.create(ctx)
mp.commandv(unpack_fn(command)) mp.commandv(unpack_fn(command))
end end
local function handle_binding(binding, numeric_selection_timeout_ms) local function handle_binding(binding)
if binding.actionType == "mpv-command" then if binding.actionType == "mpv-command" then
execute_mpv_command(binding.command) execute_mpv_command(binding.command)
return return
end 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) invoke_cli_action(binding.actionId, binding.payload)
end end
@@ -339,7 +273,6 @@ function M.create(ctx)
end end
local function clear_bindings() local function clear_bindings()
clear_numeric_selection(false)
remove_binding_names(state.session_binding_names) remove_binding_names(state.session_binding_names)
end end
@@ -350,21 +283,18 @@ function M.create(ctx)
return false return false
end end
clear_numeric_selection(false)
local previous_binding_names = state.session_binding_names local previous_binding_names = state.session_binding_names
local next_binding_names = {} local next_binding_names = {}
state.session_binding_generation = (state.session_binding_generation or 0) + 1 state.session_binding_generation = (state.session_binding_generation or 0) + 1
local generation = state.session_binding_generation local generation = state.session_binding_generation
local timeout_ms = tonumber(artifact.numericSelectionTimeoutMs) or 3000
for index, binding in ipairs(artifact.bindings) do for index, binding in ipairs(artifact.bindings) do
local key_name = key_spec_to_mpv_binding(binding.key) local key_name = key_spec_to_mpv_binding(binding.key)
if key_name then if key_name then
local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index) local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index)
next_binding_names[#next_binding_names + 1] = name next_binding_names[#next_binding_names + 1] = name
mp.add_forced_key_binding(key_name, name, function() mp.add_forced_key_binding(key_name, name, function()
handle_binding(binding, timeout_ms) handle_binding(binding)
end) end)
else else
subminer_log( subminer_log(
+3 -14
View File
@@ -351,21 +351,10 @@ assert_true(
starter.fn() 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] 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[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[2] == "--mine-sentence-multiple", "CLI action should enter mine sentence count selector")
assert_true(call[3] == "3", "CLI action should pass selected count") assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count")
print("plugin session binding regression tests: OK") print("plugin session binding regression tests: OK")
@@ -50,7 +50,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
assert.equal(leadInSeconds, 1.25); 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({ const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: { config: {
fields: { fields: {
@@ -87,7 +87,7 @@ test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audi
logWarn: () => undefined, logWarn: () => undefined,
}); });
assert.equal(leadInSeconds, 1.75); assert.equal(leadInSeconds, 1.25);
}); });
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => { 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; 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( export async function probeAudioDurationSeconds(
buffer: Buffer, buffer: Buffer,
filename: string, filename: string,
@@ -135,5 +127,5 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
totalLeadInSeconds += durationSeconds; 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.deepEqual(updatedFields[0], { Sentence: '字幕' });
assert.equal(mergeCalls.length, 0); 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`, `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) { if (this.deps.getConfig().media?.generateAudio) {
try { try {
const audioFilename = this.generateAudioFilename(); const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.mediaGenerateAudio( const audioBuffer = audioSourcePath
mpvClient.currentVideoPath, ? await this.mediaGenerateAudio(audioSourcePath, rangeStart, rangeEnd)
rangeStart, : null;
rangeEnd,
);
if (audioBuffer) { if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer); await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
@@ -271,12 +276,14 @@ export class CardCreationService {
try { try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.generateImageFilename(); const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer( const imageBuffer = videoPath
mpvClient.currentVideoPath, ? await this.generateImageBuffer(
rangeStart, videoPath,
rangeEnd, rangeStart,
animatedLeadInSeconds, rangeEnd,
); animatedLeadInSeconds,
)
: null;
if (imageBuffer) { if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer); await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
+15
View File
@@ -460,6 +460,7 @@ import {
composeStartupLifecycleHandlers, composeStartupLifecycleHandlers,
} from './main/runtime/composers'; } from './main/runtime/composers';
import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers'; import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers';
import { tryBeginVisibleOverlayNumericSelection } from './main/runtime/overlay-numeric-selection';
import { createStartupBootstrapRuntimeDeps } from './main/startup'; import { createStartupBootstrapRuntimeDeps } from './main/startup';
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle'; import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
import { import {
@@ -4823,6 +4824,20 @@ const {
numericSessions: { numericSessions: {
onMultiCopyDigit: (count) => handleMultiCopyDigit(count), onMultiCopyDigit: (count) => handleMultiCopyDigit(count),
onMineSentenceDigit: (count) => handleMineSentenceDigit(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: { overlayShortcutsRuntimeMainDeps: {
overlayShortcutsRuntime, overlayShortcutsRuntime,
@@ -33,3 +33,28 @@ test('numeric shortcut session runtime handlers compose cancel/start handlers',
'mine-sentence:digit:3', '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']; mineSentenceSession: CancelNumericShortcutSessionMainDeps['session'];
onMultiCopyDigit: (count: number) => void; onMultiCopyDigit: (count: number) => void;
onMineSentenceDigit: (count: number) => void; onMineSentenceDigit: (count: number) => void;
tryBeginMultiCopyOverlaySelection?: (timeoutMs: number) => boolean;
tryBeginMineSentenceOverlaySelection?: (timeoutMs: number) => boolean;
}) { }) {
const cancelPendingMultiCopyMainDeps = createBuildCancelNumericShortcutSessionMainDepsHandler({ const cancelPendingMultiCopyMainDeps = createBuildCancelNumericShortcutSessionMainDepsHandler({
session: deps.multiCopySession, session: deps.multiCopySession,
@@ -61,9 +63,14 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
return { return {
cancelPendingMultiCopy: () => cancelPendingMultiCopyHandler(), cancelPendingMultiCopy: () => cancelPendingMultiCopyHandler(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopyHandler(timeoutMs), startPendingMultiCopy: (timeoutMs: number) => {
if (deps.tryBeginMultiCopyOverlaySelection?.(timeoutMs)) return;
startPendingMultiCopyHandler(timeoutMs);
},
cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultipleHandler(), cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultipleHandler(),
startPendingMineSentenceMultiple: (timeoutMs: number) => startPendingMineSentenceMultiple: (timeoutMs: number) => {
startPendingMineSentenceMultipleHandler(timeoutMs), 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, ControllerConfigUpdate,
ControllerPreferenceUpdate, ControllerPreferenceUpdate,
ResolvedControllerConfig, ResolvedControllerConfig,
SessionNumericSelectionStartPayload,
YoutubePickerOpenPayload, YoutubePickerOpenPayload,
YoutubePickerResolveRequest, YoutubePickerResolveRequest,
YoutubePickerResolveResult, YoutubePickerResolveResult,
@@ -171,6 +172,11 @@ const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.pl
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener( const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.youtubePickerCancel, IPC_CHANNELS.event.youtubePickerCancel,
); );
const onSessionNumericSelectionStartEvent =
createQueuedIpcListenerWithPayload<SessionNumericSelectionStartPayload>(
IPC_CHANNELS.event.sessionNumericSelectionStart,
(payload) => payload as SessionNumericSelectionStartPayload,
);
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener( const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
IPC_CHANNELS.event.keyboardModeToggleRequested, IPC_CHANNELS.event.keyboardModeToggleRequested,
); );
@@ -385,6 +391,7 @@ const electronAPI: ElectronAPI = {
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent, onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent, onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
onSessionNumericSelectionStart: onSessionNumericSelectionStartEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> => 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 () => { test('keyboard mode: left and right move token selection while popup remains open', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
+5 -2
View File
@@ -147,6 +147,7 @@ export function createKeyboardHandlers(
function startPendingNumericSelection( function startPendingNumericSelection(
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple', actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
timeoutMs: number = ctx.state.sessionActionTimeoutMs,
): void { ): void {
cancelPendingNumericSelection(false); cancelPendingNumericSelection(false);
const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout'; const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout';
@@ -159,15 +160,17 @@ export function createKeyboardHandlers(
timeout: setTimeout(() => { timeout: setTimeout(() => {
pendingNumericSelection = null; pendingNumericSelection = null;
showSessionSelectionMessage(timeoutMessage); showSessionSelectionMessage(timeoutMessage);
}, ctx.state.sessionActionTimeoutMs), }, timeoutMs),
}; };
showSessionSelectionMessage(promptMessage); showSessionSelectionMessage(promptMessage);
} }
function beginSessionNumericSelection( function beginSessionNumericSelection(
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple', actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
timeoutMs?: number,
): void { ): void {
startPendingNumericSelection(actionId); startPendingNumericSelection(actionId, timeoutMs);
restoreOverlayKeyboardFocus();
} }
function handlePendingNumericSelection(e: KeyboardEvent): boolean { function handlePendingNumericSelection(e: KeyboardEvent): boolean {
+6
View File
@@ -530,6 +530,12 @@ function registerModalOpenHandlers(): void {
} }
function registerKeyboardCommandHandlers(): void { function registerKeyboardCommandHandlers(): void {
window.electronAPI.onSessionNumericSelectionStart((payload) => {
runGuarded('session:numeric-selection-start', () => {
keyboardHandlers.beginSessionNumericSelection(payload.actionId, payload.timeoutMs);
});
});
window.electronAPI.onKeyboardModeToggleRequested(() => { window.electronAPI.onKeyboardModeToggleRequested(() => {
runGuarded('keyboard-mode-toggle:requested', () => { runGuarded('keyboard-mode-toggle:requested', () => {
keyboardHandlers.handleKeyboardModeToggleRequested(); keyboardHandlers.handleKeyboardModeToggleRequested();
+1
View File
@@ -123,6 +123,7 @@ export const IPC_CHANNELS = {
youtubePickerOpen: 'youtube:picker-open', youtubePickerOpen: 'youtube:picker-open',
youtubePickerCancel: 'youtube:picker-cancel', youtubePickerCancel: 'youtube:picker-cancel',
playlistBrowserOpen: 'playlist-browser:open', playlistBrowserOpen: 'playlist-browser:open',
sessionNumericSelectionStart: 'session:numeric-selection-start',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open', sessionHelpOpen: 'session-help:open',
+8
View File
@@ -378,6 +378,11 @@ export interface CharacterDictionarySelectionResult {
staleMediaIds: number[]; staleMediaIds: number[];
} }
export interface SessionNumericSelectionStartPayload {
actionId: Extract<SessionActionId, 'copySubtitleMultiple' | 'mineSentenceMultiple'>;
timeoutMs: number;
}
export interface ElectronAPI { export interface ElectronAPI {
getOverlayLayer: () => 'visible' | 'modal' | null; getOverlayLayer: () => 'visible' | 'modal' | null;
onSubtitle: (callback: (data: SubtitleData) => void) => void; onSubtitle: (callback: (data: SubtitleData) => void) => void;
@@ -451,6 +456,9 @@ export interface ElectronAPI {
onSubtitleSidebarToggle: (callback: () => void) => void; onSubtitleSidebarToggle: (callback: () => void) => void;
onPrimarySubtitleBarToggle: (callback: () => void) => void; onPrimarySubtitleBarToggle: (callback: () => void) => void;
onCancelYoutubeTrackPicker: (callback: () => void) => void; onCancelYoutubeTrackPicker: (callback: () => void) => void;
onSessionNumericSelectionStart: (
callback: (payload: SessionNumericSelectionStartPayload) => void,
) => void;
onKeyboardModeToggleRequested: (callback: () => void) => void; onKeyboardModeToggleRequested: (callback: () => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>; appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;