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