mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
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:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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">$/);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user