fix: address CodeRabbit follow-ups

This commit is contained in:
2026-04-10 18:41:26 -07:00
committed by sudacode
parent 5711e1cb49
commit 735fc26525
29 changed files with 822 additions and 78 deletions

View File

@@ -229,6 +229,22 @@ function M.create(ctx)
end) end)
end end
local function run_binary_command_async(args, callback)
subminer_log("debug", "process", "Binary command: " .. table.concat(args, " "))
mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
local ok = success and (result == nil or result.status == 0)
if callback then
callback(ok, result, error)
end
end)
end
local function parse_start_script_message_overrides(...) local function parse_start_script_message_overrides(...)
local overrides = {} local overrides = {}
for i = 1, select("#", ...) do for i = 1, select("#", ...) do
@@ -528,6 +544,7 @@ function M.create(ctx)
build_command_args = build_command_args, build_command_args = build_command_args,
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
run_control_command_async = run_control_command_async, run_control_command_async = run_control_command_async,
run_binary_command_async = run_binary_command_async,
parse_start_script_message_overrides = parse_start_script_message_overrides, parse_start_script_message_overrides = parse_start_script_message_overrides,
ensure_texthooker_running = ensure_texthooker_running, ensure_texthooker_running = ensure_texthooker_running,
start_overlay = start_overlay, start_overlay = start_overlay,

View File

@@ -108,6 +108,8 @@ function M.create(ctx)
local function build_cli_args(action_id, payload) local function build_cli_args(action_id, payload)
if action_id == "toggleVisibleOverlay" then if action_id == "toggleVisibleOverlay" then
return { "--toggle-visible-overlay" } return { "--toggle-visible-overlay" }
elseif action_id == "toggleStatsOverlay" then
return { "--toggle-stats-overlay" }
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
@@ -142,6 +144,13 @@ function M.create(ctx)
return { "--shift-sub-delay-prev-line" } return { "--shift-sub-delay-prev-line" }
elseif action_id == "shiftSubDelayNextLine" then elseif action_id == "shiftSubDelayNextLine" then
return { "--shift-sub-delay-next-line" } return { "--shift-sub-delay-next-line" }
elseif action_id == "cycleRuntimeOption" then
local runtime_option_id = payload and payload.runtimeOptionId or nil
if type(runtime_option_id) ~= "string" or runtime_option_id == "" then
return nil
end
local direction = payload and payload.direction == -1 and "prev" or "next"
return { "--cycle-runtime-option", runtime_option_id .. ":" .. direction }
end end
return nil return nil
@@ -163,7 +172,24 @@ function M.create(ctx)
for _, arg in ipairs(cli_args) do for _, arg in ipairs(cli_args) do
args[#args + 1] = arg args[#args + 1] = arg
end end
process.run_binary_command_async(args, function(ok, result, error) local runner = process.run_binary_command_async
if type(runner) ~= "function" then
runner = function(binary_args, callback)
mp.command_native_async({
name = "subprocess",
args = binary_args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
local ok = success and (result == nil or result.status == 0)
if callback then
callback(ok, result, error)
end
end)
end
end
runner(args, function(ok, result, error)
if ok then if ok then
return return
end end

View File

@@ -75,6 +75,7 @@ test('parseArgs captures youtube startup forwarding flags', () => {
test('parseArgs captures session action forwarding flags', () => { test('parseArgs captures session action forwarding flags', () => {
const args = parseArgs([ const args = parseArgs([
'--toggle-stats-overlay',
'--open-jimaku', '--open-jimaku',
'--open-youtube-picker', '--open-youtube-picker',
'--open-playlist-browser', '--open-playlist-browser',
@@ -82,11 +83,14 @@ test('parseArgs captures session action forwarding flags', () => {
'--play-next-subtitle', '--play-next-subtitle',
'--shift-sub-delay-prev-line', '--shift-sub-delay-prev-line',
'--shift-sub-delay-next-line', '--shift-sub-delay-next-line',
'--cycle-runtime-option',
'anki.autoUpdateNewCards:prev',
'--copy-subtitle-count', '--copy-subtitle-count',
'3', '3',
'--mine-sentence-count=2', '--mine-sentence-count=2',
]); ]);
assert.equal(args.toggleStatsOverlay, true);
assert.equal(args.openJimaku, true); assert.equal(args.openJimaku, true);
assert.equal(args.openYoutubePicker, true); assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true); assert.equal(args.openPlaylistBrowser, true);
@@ -94,6 +98,8 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.playNextSubtitle, true); assert.equal(args.playNextSubtitle, true);
assert.equal(args.shiftSubDelayPrevLine, true); assert.equal(args.shiftSubDelayPrevLine, true);
assert.equal(args.shiftSubDelayNextLine, true); assert.equal(args.shiftSubDelayNextLine, true);
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
assert.equal(args.cycleRuntimeOptionDirection, -1);
assert.equal(args.copySubtitleCount, 3); assert.equal(args.copySubtitleCount, 3);
assert.equal(args.mineSentenceCount, 2); assert.equal(args.mineSentenceCount, 2);
assert.equal(hasExplicitCommand(args), true); assert.equal(hasExplicitCommand(args), true);
@@ -199,6 +205,21 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(anilistRetryQueue), true); assert.equal(hasExplicitCommand(anilistRetryQueue), true);
assert.equal(shouldStartApp(anilistRetryQueue), false); assert.equal(shouldStartApp(anilistRetryQueue), false);
const toggleStatsOverlay = parseArgs(['--toggle-stats-overlay']);
assert.equal(toggleStatsOverlay.toggleStatsOverlay, true);
assert.equal(hasExplicitCommand(toggleStatsOverlay), true);
assert.equal(shouldStartApp(toggleStatsOverlay), true);
const cycleRuntimeOption = parseArgs([
'--cycle-runtime-option',
'anki.autoUpdateNewCards:next',
]);
assert.equal(cycleRuntimeOption.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
assert.equal(cycleRuntimeOption.cycleRuntimeOptionDirection, 1);
assert.equal(hasExplicitCommand(cycleRuntimeOption), true);
assert.equal(shouldStartApp(cycleRuntimeOption), true);
assert.equal(commandNeedsOverlayRuntime(cycleRuntimeOption), true);
const dictionary = parseArgs(['--dictionary']); const dictionary = parseArgs(['--dictionary']);
assert.equal(dictionary.dictionary, true); assert.equal(dictionary.dictionary, true);
assert.equal(hasExplicitCommand(dictionary), true); assert.equal(hasExplicitCommand(dictionary), true);

View File

@@ -24,6 +24,7 @@ export interface CliArgs {
triggerFieldGrouping: boolean; triggerFieldGrouping: boolean;
triggerSubsync: boolean; triggerSubsync: boolean;
markAudioCard: boolean; markAudioCard: boolean;
toggleStatsOverlay: boolean;
openRuntimeOptions: boolean; openRuntimeOptions: boolean;
openJimaku: boolean; openJimaku: boolean;
openYoutubePicker: boolean; openYoutubePicker: boolean;
@@ -32,6 +33,8 @@ export interface CliArgs {
playNextSubtitle: boolean; playNextSubtitle: boolean;
shiftSubDelayPrevLine: boolean; shiftSubDelayPrevLine: boolean;
shiftSubDelayNextLine: boolean; shiftSubDelayNextLine: boolean;
cycleRuntimeOptionId?: string;
cycleRuntimeOptionDirection?: 1 | -1;
copySubtitleCount?: number; copySubtitleCount?: number;
mineSentenceCount?: number; mineSentenceCount?: number;
anilistStatus: boolean; anilistStatus: boolean;
@@ -111,6 +114,7 @@ export function parseArgs(argv: string[]): CliArgs {
triggerFieldGrouping: false, triggerFieldGrouping: false,
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
toggleStatsOverlay: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false, openJimaku: false,
openYoutubePicker: false, openYoutubePicker: false,
@@ -154,6 +158,24 @@ export function parseArgs(argv: string[]): CliArgs {
return value; return value;
}; };
const parseCycleRuntimeOption = (
value: string | undefined,
): { id: string; direction: 1 | -1 } | null => {
if (!value) return null;
const separatorIndex = value.lastIndexOf(':');
if (separatorIndex <= 0 || separatorIndex === value.length - 1) return null;
const id = value.slice(0, separatorIndex).trim();
const rawDirection = value.slice(separatorIndex + 1).trim().toLowerCase();
if (!id) return null;
if (rawDirection === 'next' || rawDirection === '1') {
return { id, direction: 1 };
}
if (rawDirection === 'prev' || rawDirection === '-1') {
return { id, direction: -1 };
}
return null;
};
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]; const arg = argv[i];
if (!arg || !arg.startsWith('--')) continue; if (!arg || !arg.startsWith('--')) continue;
@@ -195,6 +217,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--trigger-field-grouping') args.triggerFieldGrouping = true; else if (arg === '--trigger-field-grouping') args.triggerFieldGrouping = true;
else if (arg === '--trigger-subsync') args.triggerSubsync = true; else if (arg === '--trigger-subsync') args.triggerSubsync = true;
else if (arg === '--mark-audio-card') args.markAudioCard = true; else if (arg === '--mark-audio-card') args.markAudioCard = true;
else if (arg === '--toggle-stats-overlay') args.toggleStatsOverlay = true;
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true; else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
else if (arg === '--open-jimaku') args.openJimaku = true; else if (arg === '--open-jimaku') args.openJimaku = true;
else if (arg === '--open-youtube-picker') args.openYoutubePicker = true; else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
@@ -203,6 +226,19 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true; else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true; else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true; else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
else if (arg.startsWith('--cycle-runtime-option=')) {
const parsed = parseCycleRuntimeOption(arg.split('=', 2)[1]);
if (parsed) {
args.cycleRuntimeOptionId = parsed.id;
args.cycleRuntimeOptionDirection = parsed.direction;
}
} else if (arg === '--cycle-runtime-option') {
const parsed = parseCycleRuntimeOption(readValue(argv[i + 1]));
if (parsed) {
args.cycleRuntimeOptionId = parsed.id;
args.cycleRuntimeOptionDirection = parsed.direction;
}
}
else if (arg.startsWith('--copy-subtitle-count=')) { else if (arg.startsWith('--copy-subtitle-count=')) {
const value = Number(arg.split('=', 2)[1]); const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value)) args.copySubtitleCount = value; if (Number.isInteger(value)) args.copySubtitleCount = value;
@@ -407,6 +443,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.triggerFieldGrouping || args.triggerFieldGrouping ||
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.toggleStatsOverlay ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openJimaku || args.openJimaku ||
args.openYoutubePicker || args.openYoutubePicker ||
@@ -415,6 +452,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine || args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine || args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined || args.mineSentenceCount !== undefined ||
args.anilistStatus || args.anilistStatus ||
@@ -468,6 +506,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.triggerFieldGrouping && !args.triggerFieldGrouping &&
!args.triggerSubsync && !args.triggerSubsync &&
!args.markAudioCard && !args.markAudioCard &&
!args.toggleStatsOverlay &&
!args.openRuntimeOptions && !args.openRuntimeOptions &&
!args.openJimaku && !args.openJimaku &&
!args.openYoutubePicker && !args.openYoutubePicker &&
@@ -476,6 +515,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.playNextSubtitle && !args.playNextSubtitle &&
!args.shiftSubDelayPrevLine && !args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine && !args.shiftSubDelayNextLine &&
args.cycleRuntimeOptionId === undefined &&
args.copySubtitleCount === undefined && args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined && args.mineSentenceCount === undefined &&
!args.anilistStatus && !args.anilistStatus &&
@@ -520,6 +560,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.triggerFieldGrouping || args.triggerFieldGrouping ||
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.toggleStatsOverlay ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openJimaku || args.openJimaku ||
args.openYoutubePicker || args.openYoutubePicker ||
@@ -528,6 +569,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine || args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine || args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined || args.mineSentenceCount !== undefined ||
args.dictionary || args.dictionary ||
@@ -567,6 +609,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.triggerFieldGrouping && !args.triggerFieldGrouping &&
!args.triggerSubsync && !args.triggerSubsync &&
!args.markAudioCard && !args.markAudioCard &&
!args.toggleStatsOverlay &&
!args.openRuntimeOptions && !args.openRuntimeOptions &&
!args.openJimaku && !args.openJimaku &&
!args.openYoutubePicker && !args.openYoutubePicker &&
@@ -575,6 +618,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.playNextSubtitle && !args.playNextSubtitle &&
!args.shiftSubDelayPrevLine && !args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine && !args.shiftSubDelayNextLine &&
args.cycleRuntimeOptionId === undefined &&
args.copySubtitleCount === undefined && args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined && args.mineSentenceCount === undefined &&
!args.anilistStatus && !args.anilistStatus &&
@@ -627,6 +671,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.playNextSubtitle || args.playNextSubtitle ||
args.shiftSubDelayPrevLine || args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine || args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined args.mineSentenceCount !== undefined
); );

View File

@@ -28,6 +28,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerFieldGrouping: false, triggerFieldGrouping: false,
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
toggleStatsOverlay: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false, openJimaku: false,
openYoutubePicker: false, openYoutubePicker: false,
@@ -36,6 +37,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false, shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false, shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,

View File

@@ -76,9 +76,7 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
); );
assert.ok(calls.includes('startBackgroundWarmups')); assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok( assert.ok(
calls.includes( calls.includes('log:Runtime ready: immersion tracker startup requested.'),
'log:Runtime ready: immersion tracker startup deferred until first media activity.',
),
); );
}); });
@@ -103,6 +101,17 @@ test('runAppReadyRuntime starts texthooker on startup when enabled in config', a
); );
}); });
test('runAppReadyRuntime creates immersion tracker during heavy startup', async () => {
const { deps, calls } = makeDeps({
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('createImmersionTracker'));
assert.ok(calls.indexOf('createImmersionTracker') < calls.indexOf('handleInitialArgs'));
});
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => { test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
const { deps, calls } = makeDeps({ const { deps, calls } = makeDeps({
getResolvedConfig: () => ({ getResolvedConfig: () => ({

View File

@@ -29,6 +29,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerFieldGrouping: false, triggerFieldGrouping: false,
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
toggleStatsOverlay: false,
refreshKnownWords: false, refreshKnownWords: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false, openJimaku: false,
@@ -38,6 +39,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false, shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false, shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,
@@ -509,6 +512,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
expected: 'startPendingMineSentenceMultiple:2500', expected: 'startPendingMineSentenceMultiple:2500',
}, },
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' }, { args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
{ {
args: { openRuntimeOptions: true }, args: { openRuntimeOptions: true },
expected: 'openRuntimeOptionsPalette', expected: 'openRuntimeOptionsPalette',
@@ -528,6 +532,33 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
} }
}); });
test('handleCliCommand dispatches cycle-runtime-option session action', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(
makeArgs({
cycleRuntimeOptionId: 'anki.autoUpdateNewCards',
cycleRuntimeOptionDirection: -1,
}),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: 'anki.autoUpdateNewCards',
direction: -1,
},
});
});
test('handleCliCommand logs AniList status details', () => { test('handleCliCommand logs AniList status details', () => {
const { deps, calls } = createDeps(); const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps); handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);

View File

@@ -396,6 +396,12 @@ export function handleCliCommand(
'markLastCardAsAudioCard', 'markLastCardAsAudioCard',
'Audio card failed', 'Audio card failed',
); );
} else if (args.toggleStatsOverlay) {
dispatchCliSessionAction(
{ actionId: 'toggleStatsOverlay' },
'toggleStatsOverlay',
'Stats toggle failed',
);
} else if (args.openRuntimeOptions) { } else if (args.openRuntimeOptions) {
deps.openRuntimeOptionsPalette(); deps.openRuntimeOptionsPalette();
} else if (args.openJimaku) { } else if (args.openJimaku) {
@@ -436,6 +442,18 @@ export function handleCliCommand(
'shiftSubDelayNextLine', 'shiftSubDelayNextLine',
'Shift subtitle delay failed', 'Shift subtitle delay failed',
); );
} else if (args.cycleRuntimeOptionId !== undefined) {
dispatchCliSessionAction(
{
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: args.cycleRuntimeOptionId,
direction: args.cycleRuntimeOptionDirection ?? 1,
},
},
'cycleRuntimeOption',
'Runtime option change failed',
);
} else if (args.copySubtitleCount !== undefined) { } else if (args.copySubtitleCount !== undefined) {
dispatchCliSessionAction( dispatchCliSessionAction(
{ actionId: 'copySubtitleMultiple', payload: { count: args.copySubtitleCount } }, { actionId: 'copySubtitleMultiple', payload: { count: args.copySubtitleCount } },

View File

@@ -35,7 +35,10 @@ import {
const { ipcMain } = electron; const { ipcMain } = electron;
export interface IpcServiceDeps { export interface IpcServiceDeps {
onOverlayModalClosed: (modal: OverlayHostedModal) => void; onOverlayModalClosed: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayModalOpened?: ( onOverlayModalOpened?: (
modal: OverlayHostedModal, modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null, senderWindow: ElectronBrowserWindow | null,
@@ -160,7 +163,10 @@ interface IpcMainRegistrar {
export interface IpcDepsRuntimeOptions { export interface IpcDepsRuntimeOptions {
getMainWindow: () => WindowLike | null; getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean; getVisibleOverlayVisibility: () => boolean;
onOverlayModalClosed: (modal: OverlayHostedModal) => void; onOverlayModalClosed: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayModalOpened?: ( onOverlayModalOpened?: (
modal: OverlayHostedModal, modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null, senderWindow: ElectronBrowserWindow | null,
@@ -321,10 +327,12 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
}, },
); );
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (_event: unknown, modal: unknown) => { ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal); const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return; if (!parsedModal) return;
deps.onOverlayModalClosed(parsedModal); const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalClosed(parsedModal, senderWindow);
}); });
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => { ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal); const parsedModal = parseOverlayHostedModal(modal);

View File

@@ -320,22 +320,7 @@ test('shouldActivateOverlayShortcuts preserves non-macOS behavior', () => {
test('registerOverlayShortcutsRuntime reports active shortcuts when configured', () => { test('registerOverlayShortcutsRuntime reports active shortcuts when configured', () => {
const result = registerOverlayShortcutsRuntime({ const result = registerOverlayShortcutsRuntime({
getConfiguredShortcuts: () => getConfiguredShortcuts: () => makeShortcuts({ openJimaku: 'Ctrl+J' }),
({
toggleVisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: 'Ctrl+J',
}) as never,
getOverlayHandlers: () => ({ getOverlayHandlers: () => ({
copySubtitle: () => {}, copySubtitle: () => {},
copySubtitleMultiple: () => {}, copySubtitleMultiple: () => {},
@@ -359,22 +344,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active', () => { test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active', () => {
const calls: string[] = []; const calls: string[] = [];
const result = unregisterOverlayShortcutsRuntime(true, { const result = unregisterOverlayShortcutsRuntime(true, {
getConfiguredShortcuts: () => getConfiguredShortcuts: () => makeShortcuts(),
({
toggleVisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
}) as never,
getOverlayHandlers: () => ({ getOverlayHandlers: () => ({
copySubtitle: () => {}, copySubtitle: () => {},
copySubtitleMultiple: () => {}, copySubtitleMultiple: () => {},

View File

@@ -664,6 +664,80 @@ test('tracked Windows overlay stays interactive while the overlay window itself
assert.ok(!calls.includes('enforce-order')); assert.ok(!calls.includes('enforce-order'));
}); });
test('tracked Windows overlay reshows click-through even if focus state is stale after a modal closes', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
calls.length = 0;
window.hide();
calls.length = 0;
setFocused(true);
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncWindowsOverlayToMpvZOrder: () => {
calls.push('sync-windows-z-order');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
});
test('tracked Windows overlay binds above mpv even when tracker focus lags', () => { test('tracked Windows overlay binds above mpv even when tracker focus lags', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {

View File

@@ -92,6 +92,7 @@ export function updateVisibleOverlayVisibility(args: {
const showPassiveVisibleOverlay = (): void => { const showPassiveVisibleOverlay = (): void => {
const forceMousePassthrough = args.forceMousePassthrough === true; const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible();
const shouldDefaultToPassthrough = const shouldDefaultToPassthrough =
args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough; args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough;
const isVisibleOverlayFocused = const isVisibleOverlayFocused =
@@ -116,8 +117,10 @@ export function updateVisibleOverlayVisibility(args: {
windowsForegroundProcessName === windowsOverlayProcessName)) && windowsForegroundProcessName === windowsOverlayProcessName)) &&
!isTrackedWindowsTargetMinimized && !isTrackedWindowsTargetMinimized &&
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null); (args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
const shouldIgnoreMouseEvents = const shouldIgnoreMouseEvents =
forceMousePassthrough || (shouldDefaultToPassthrough && !isVisibleOverlayFocused); forceMousePassthrough ||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker; const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
const shouldKeepTrackedWindowsOverlayTopmost = const shouldKeepTrackedWindowsOverlayTopmost =
!args.isWindowsPlatform || !args.isWindowsPlatform ||
@@ -126,8 +129,6 @@ export function updateVisibleOverlayVisibility(args: {
isTrackedWindowsTargetFocused || isTrackedWindowsTargetFocused ||
shouldPreserveWindowsOverlayDuringFocusHandoff || shouldPreserveWindowsOverlayDuringFocusHandoff ||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv'); (hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
const wasVisible = mainWindow.isVisible();
if (shouldIgnoreMouseEvents) { if (shouldIgnoreMouseEvents) {
mainWindow.setIgnoreMouseEvents(true, { forward: true }); mainWindow.setIgnoreMouseEvents(true, { forward: true });
} else { } else {

View File

@@ -97,6 +97,9 @@ export function createOverlayWindow(
}, },
): BrowserWindow { ): BrowserWindow {
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options)); const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = false;
if (!(process.platform === 'win32' && kind === 'visible')) { if (!(process.platform === 'win32' && kind === 'visible')) {
options.ensureOverlayWindowLevel(window); options.ensureOverlayWindowLevel(window);

View File

@@ -97,7 +97,26 @@ function normalizeCodeToken(token: string): string | null {
/^arrow(?:up|down|left|right)$/i.test(normalized) || /^arrow(?:up|down|left|right)$/i.test(normalized) ||
/^f\d{1,2}$/i.test(normalized) /^f\d{1,2}$/i.test(normalized)
) { ) {
return normalized[0]!.toUpperCase() + normalized.slice(1); const keyMatch = normalized.match(/^key([a-z])$/i);
if (keyMatch) {
return `Key${keyMatch[1]!.toUpperCase()}`;
}
const digitMatch = normalized.match(/^digit([0-9])$/i);
if (digitMatch) {
return `Digit${digitMatch[1]}`;
}
const arrowMatch = normalized.match(/^arrow(up|down|left|right)$/i);
if (arrowMatch) {
const direction = arrowMatch[1]!;
return `Arrow${direction[0]!.toUpperCase()}${direction.slice(1).toLowerCase()}`;
}
const functionKeyMatch = normalized.match(/^f(\d{1,2})$/i);
if (functionKeyMatch) {
return `F${functionKeyMatch[1]}`;
}
} }
return null; return null;
} }

View File

@@ -28,6 +28,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerFieldGrouping: false, triggerFieldGrouping: false,
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
toggleStatsOverlay: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false, openJimaku: false,
openYoutubePicker: false, openYoutubePicker: false,
@@ -36,6 +37,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false, shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false, shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,

View File

@@ -311,7 +311,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.createSubtitleTimingTracker(); deps.createSubtitleTimingTracker();
if (deps.createImmersionTracker) { if (deps.createImmersionTracker) {
deps.log('Runtime ready: immersion tracker startup deferred until first media activity.'); deps.createImmersionTracker();
deps.log('Runtime ready: immersion tracker startup requested.');
} else { } else {
deps.log('Runtime ready: immersion tracker dependency is missing.'); deps.log('Runtime ready: immersion tracker dependency is missing.');
} }

View File

@@ -454,6 +454,7 @@ import { createOverlayModalInputState } from './main/runtime/overlay-modal-input
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact'; import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
import { openOverlayHostedModal } from './main/runtime/overlay-hosted-modal-open';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import { import {
createFrequencyDictionaryRuntimeService, createFrequencyDictionaryRuntimeService,
@@ -1484,9 +1485,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
openRuntimeOptionsPalette(); openRuntimeOptionsPalette();
}, },
openJimaku: () => { openJimaku: () => {
sendToActiveOverlayWindow('jimaku:open', undefined, { openJimakuOverlay();
restoreOnModalClose: 'jimaku',
});
}, },
markAudioCard: () => markLastCardAsAudioCard(), markAudioCard: () => markLastCardAsAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => { copySubtitleMultiple: (timeoutMs: number) => {
@@ -2206,7 +2205,42 @@ function setOverlayDebugVisualizationEnabled(enabled: boolean): void {
} }
function openRuntimeOptionsPalette(): void { function openRuntimeOptionsPalette(): void {
overlayVisibilityComposer.openRuntimeOptionsPalette(); const opened = openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
ensureOverlayWindowsReadyForVisibilityActions: () =>
ensureOverlayWindowsReadyForVisibilityActions(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
sendToActiveOverlayWindow(channel, payload, runtimeOptions),
},
{
channel: IPC_CHANNELS.event.runtimeOptionsOpen,
modal: 'runtime-options',
preferModalWindow: true,
},
);
if (!opened) {
showMpvOsd('Runtime options overlay unavailable.');
}
}
function openJimakuOverlay(): void {
const opened = openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
ensureOverlayWindowsReadyForVisibilityActions: () =>
ensureOverlayWindowsReadyForVisibilityActions(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
sendToActiveOverlayWindow(channel, payload, runtimeOptions),
},
{
channel: IPC_CHANNELS.event.jimakuOpen,
modal: 'jimaku',
},
);
if (!opened) {
showMpvOsd('Jimaku overlay unavailable.');
}
} }
function openPlaylistBrowser(): void { function openPlaylistBrowser(): void {
@@ -4522,7 +4556,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
toggleSecondarySub: () => handleCycleSecondarySubMode(), toggleSecondarySub: () => handleCycleSecondarySubMode(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(), markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => overlayModalRuntime.openJimaku(), openJimaku: () => openJimakuOverlay(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(), openPlaylistBrowser: () => openPlaylistBrowser(),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
@@ -4551,7 +4585,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mpvCommandMainDeps: { mpvCommandMainDeps: {
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => overlayModalRuntime.openJimaku(), openJimaku: () => openJimakuOverlay(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(), openPlaylistBrowser: () => openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => { cycleRuntimeOption: (id, direction) => {
@@ -4591,7 +4625,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mainWindow.focus(); mainWindow.focus();
} }
}, },
onOverlayModalClosed: (modal) => { onOverlayModalClosed: (modal, senderWindow) => {
const modalWindow = overlayManager.getModalWindow();
if (
senderWindow &&
modalWindow &&
senderWindow === modalWindow &&
!senderWindow.isDestroyed()
) {
senderWindow.setIgnoreMouseEvents(true, { forward: true });
senderWindow.hide();
}
handleOverlayModalClosed(modal); handleOverlayModalClosed(modal);
}, },
onOverlayModalOpened: (modal) => { onOverlayModalOpened: (modal) => {

View File

@@ -7,13 +7,16 @@ type MockWindow = {
visible: boolean; visible: boolean;
focused: boolean; focused: boolean;
ignoreMouseEvents: boolean; ignoreMouseEvents: boolean;
forwardedIgnoreMouseEvents: boolean;
webContentsFocused: boolean; webContentsFocused: boolean;
showCount: number; showCount: number;
hideCount: number; hideCount: number;
sent: unknown[][]; sent: unknown[][];
loading: boolean; loading: boolean;
url: string; url: string;
contentReady: boolean;
loadCallbacks: Array<() => void>; loadCallbacks: Array<() => void>;
readyToShowCallbacks: Array<() => void>;
}; };
function createMockWindow(): MockWindow & { function createMockWindow(): MockWindow & {
@@ -29,6 +32,7 @@ function createMockWindow(): MockWindow & {
show: () => void; show: () => void;
hide: () => void; hide: () => void;
focus: () => void; focus: () => void;
once: (event: 'ready-to-show', cb: () => void) => void;
webContents: { webContents: {
focused: boolean; focused: boolean;
isLoading: () => boolean; isLoading: () => boolean;
@@ -44,13 +48,16 @@ function createMockWindow(): MockWindow & {
visible: false, visible: false,
focused: false, focused: false,
ignoreMouseEvents: false, ignoreMouseEvents: false,
forwardedIgnoreMouseEvents: false,
webContentsFocused: false, webContentsFocused: false,
showCount: 0, showCount: 0,
hideCount: 0, hideCount: 0,
sent: [], sent: [],
loading: false, loading: false,
url: 'file:///overlay/index.html?layer=modal', url: 'file:///overlay/index.html?layer=modal',
contentReady: true,
loadCallbacks: [], loadCallbacks: [],
readyToShowCallbacks: [],
}; };
const window = { const window = {
...state, ...state,
@@ -58,8 +65,9 @@ function createMockWindow(): MockWindow & {
isVisible: () => state.visible, isVisible: () => state.visible,
isFocused: () => state.focused, isFocused: () => state.focused,
getURL: () => state.url, getURL: () => state.url,
setIgnoreMouseEvents: (ignore: boolean, _options?: { forward?: boolean }) => { setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
state.ignoreMouseEvents = ignore; state.ignoreMouseEvents = ignore;
state.forwardedIgnoreMouseEvents = options?.forward === true;
}, },
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {}, setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {},
moveTop: () => {}, moveTop: () => {},
@@ -76,6 +84,9 @@ function createMockWindow(): MockWindow & {
focus: () => { focus: () => {
state.focused = true; state.focused = true;
}, },
once: (_event: 'ready-to-show', cb: () => void) => {
state.readyToShowCallbacks.push(cb);
},
webContents: { webContents: {
isLoading: () => state.loading, isLoading: () => state.loading,
getURL: () => state.url, getURL: () => state.url,
@@ -139,6 +150,25 @@ function createMockWindow(): MockWindow & {
}, },
}); });
Object.defineProperty(window, 'forwardedIgnoreMouseEvents', {
get: () => state.forwardedIgnoreMouseEvents,
set: (value: boolean) => {
state.forwardedIgnoreMouseEvents = value;
},
});
Object.defineProperty(window, 'contentReady', {
get: () => state.contentReady,
set: (value: boolean) => {
state.contentReady = value;
(window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady =
value;
},
});
(window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady =
state.contentReady;
return window; return window;
} }
@@ -199,6 +229,7 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
const window = createMockWindow(); const window = createMockWindow();
window.url = ''; window.url = '';
window.loading = true; window.loading = true;
window.contentReady = false;
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
getModalWindow: () => window as never, getModalWindow: () => window as never,
@@ -217,9 +248,14 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
assert.deepEqual(window.sent, []); assert.deepEqual(window.sent, []);
assert.equal(window.loadCallbacks.length, 1); assert.equal(window.loadCallbacks.length, 1);
assert.equal(window.readyToShowCallbacks.length, 1);
window.loading = false; window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal'; window.url = 'file:///overlay/index.html?layer=modal';
window.loadCallbacks[0]!(); window.loadCallbacks[0]!();
assert.deepEqual(window.sent, []);
window.contentReady = true;
window.readyToShowCallbacks[0]!();
runtime.notifyOverlayModalOpened('runtime-options'); runtime.notifyOverlayModalOpened('runtime-options');
assert.deepEqual(window.sent, [['runtime-options:open']]); assert.deepEqual(window.sent, [['runtime-options:open']]);
@@ -325,11 +361,12 @@ test('modal window path makes visible main overlay click-through until modal clo
assert.equal(sent, true); assert.equal(sent, true);
assert.equal(mainWindow.ignoreMouseEvents, true); assert.equal(mainWindow.ignoreMouseEvents, true);
assert.equal(mainWindow.forwardedIgnoreMouseEvents, true);
assert.equal(modalWindow.ignoreMouseEvents, false); assert.equal(modalWindow.ignoreMouseEvents, false);
runtime.handleOverlayModalClosed('youtube-track-picker'); runtime.handleOverlayModalClosed('youtube-track-picker');
assert.equal(mainWindow.ignoreMouseEvents, false); assert.equal(mainWindow.ignoreMouseEvents, true);
}); });
test('modal window path hides visible main overlay until modal closes', () => { test('modal window path hides visible main overlay until modal closes', () => {
@@ -359,8 +396,8 @@ test('modal window path hides visible main overlay until modal closes', () => {
runtime.handleOverlayModalClosed('youtube-track-picker'); runtime.handleOverlayModalClosed('youtube-track-picker');
assert.equal(mainWindow.getShowCount(), 1); assert.equal(mainWindow.getShowCount(), 0);
assert.equal(mainWindow.isVisible(), true); assert.equal(mainWindow.isVisible(), false);
}); });
test('modal runtime notifies callers when modal input state becomes active/inactive', () => { test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
@@ -500,6 +537,7 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open
window.loading = true; window.loading = true;
window.url = ''; window.url = '';
window.contentReady = false;
const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, {
restoreOnModalClose: 'jimaku', restoreOnModalClose: 'jimaku',
@@ -519,6 +557,36 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open
assert.equal(window.ignoreMouseEvents, false); assert.equal(window.ignoreMouseEvents, false);
}); });
test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering open event', () => {
const window = createMockWindow();
window.contentReady = false;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => {
throw new Error('modal window should not be created when already present');
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
assert.equal(sent, true);
assert.deepEqual(window.sent, []);
assert.equal(window.loadCallbacks.length, 1);
assert.equal(window.readyToShowCallbacks.length, 1);
window.loadCallbacks[0]!();
assert.deepEqual(window.sent, []);
window.contentReady = true;
window.readyToShowCallbacks[0]!();
assert.deepEqual(window.sent, [['runtime-options:open']]);
});
test('waitForModalOpen resolves true after modal acknowledgement', async () => { test('waitForModalOpen resolves true after modal acknowledgement', async () => {
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,

View File

@@ -3,6 +3,7 @@ import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { WindowGeometry } from '../types'; import type { WindowGeometry } from '../types';
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
export interface OverlayWindowResolver { export interface OverlayWindowResolver {
getMainWindow: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null;
@@ -90,6 +91,15 @@ export function createOverlayModalRuntimeService(
if (window.webContents.isLoading()) { if (window.webContents.isLoading()) {
return false; return false;
} }
const overlayWindow = window as BrowserWindow & {
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
};
if (
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
) {
return false;
}
const currentURL = window.webContents.getURL(); const currentURL = window.webContents.getURL();
return currentURL !== '' && currentURL !== 'about:blank'; return currentURL !== '' && currentURL !== 'about:blank';
}; };
@@ -109,11 +119,17 @@ export function createOverlayModalRuntimeService(
return; return;
} }
window.webContents.once('did-finish-load', () => { let delivered = false;
if (!window.isDestroyed() && !window.webContents.isLoading()) { const deliverWhenReady = (): void => {
sendNow(window); if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) {
return;
} }
}); delivered = true;
sendNow(window);
};
window.webContents.once('did-finish-load', deliverWhenReady);
window.once('ready-to-show', deliverWhenReady);
}; };
const showModalWindow = ( const showModalWindow = (
@@ -320,12 +336,12 @@ export function createOverlayModalRuntimeService(
const modalWindow = deps.getModalWindow(); const modalWindow = deps.getModalWindow();
if (restoreVisibleOverlayOnModalClose.size === 0) { if (restoreVisibleOverlayOnModalClose.size === 0) {
clearPendingModalWindowReveal(); clearPendingModalWindowReveal();
notifyModalStateChange(false);
setMainWindowMousePassthroughForModal(false);
setMainWindowVisibilityForModal(false);
if (modalWindow && !modalWindow.isDestroyed()) { if (modalWindow && !modalWindow.isDestroyed()) {
modalWindow.hide(); modalWindow.hide();
} }
mainWindowMousePassthroughForcedByModal = false;
mainWindowHiddenByModal = false;
notifyModalStateChange(false);
} }
}; };

View File

@@ -42,6 +42,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerFieldGrouping: false, triggerFieldGrouping: false,
triggerSubsync: false, triggerSubsync: false,
markAudioCard: false, markAudioCard: false,
toggleStatsOverlay: false,
openRuntimeOptions: false, openRuntimeOptions: false,
openJimaku: false, openJimaku: false,
openYoutubePicker: false, openYoutubePicker: false,
@@ -50,6 +51,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
playNextSubtitle: false, playNextSubtitle: false,
shiftSubDelayPrevLine: false, shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false, shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false, anilistStatus: false,
anilistLogout: false, anilistLogout: false,
anilistSetup: false, anilistSetup: false,

View File

@@ -76,7 +76,16 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.triggerFieldGrouping || args.triggerFieldGrouping ||
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.toggleStatsOverlay ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.openJimaku ||
args.openYoutubePicker ||
args.openPlaylistBrowser ||
args.replayCurrentSubtitle ||
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined ||
args.anilistStatus || args.anilistStatus ||
args.anilistLogout || args.anilistLogout ||
args.anilistSetup || args.anilistSetup ||

View File

@@ -161,6 +161,41 @@ test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv
assert.ok(calls.includes('info:Auto-connecting MPV client for immersion tracking')); assert.ok(calls.includes('info:Auto-connecting MPV client for immersion tracking'));
}); });
test('createImmersionTrackerStartupHandler keeps tracker startup alive when mpv auto-connect throws', () => {
const calls: string[] = [];
const trackerInstance = { kind: 'tracker' };
let assignedTracker: unknown = null;
const handler = createImmersionTrackerStartupHandler({
getResolvedConfig: () => makeConfig(),
getConfiguredDbPath: () => '/tmp/subminer.db',
createTrackerService: () => trackerInstance,
setTracker: (nextTracker) => {
assignedTracker = nextTracker;
},
getMpvClient: () => ({
connected: false,
connect: () => {
throw new Error('socket not ready');
},
}),
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message, details) => calls.push(`warn:${message}:${(details as Error).message}`),
});
handler();
assert.equal(assignedTracker, trackerInstance);
assert.ok(calls.includes('seedTracker'));
assert.ok(
calls.includes(
'warn:MPV auto-connect failed during immersion tracker startup; continuing.:socket not ready',
),
);
assert.equal(calls.includes('warn:Immersion tracker startup failed; disabling tracking.'), false);
});
test('createImmersionTrackerStartupHandler disables tracker on failure', () => { test('createImmersionTrackerStartupHandler disables tracker on failure', () => {
const calls: string[] = []; const calls: string[] = [];
let assignedTracker: unknown = 'initial'; let assignedTracker: unknown = 'initial';

View File

@@ -102,7 +102,11 @@ export function createImmersionTrackerStartupHandler(
const mpvClient = deps.getMpvClient(); const mpvClient = deps.getMpvClient();
if ((deps.shouldAutoConnectMpv?.() ?? true) && mpvClient && !mpvClient.connected) { if ((deps.shouldAutoConnectMpv?.() ?? true) && mpvClient && !mpvClient.connected) {
deps.logInfo('Auto-connecting MPV client for immersion tracking'); deps.logInfo('Auto-connecting MPV client for immersion tracking');
mpvClient.connect(); try {
mpvClient.connect();
} catch (error) {
deps.logWarn('MPV auto-connect failed during immersion tracker startup; continuing.', error);
}
} }
deps.seedTrackerFromCurrentMedia(); deps.seedTrackerFromCurrentMedia();
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openOverlayHostedModal } from './overlay-hosted-modal-open';
test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => {
const calls: string[] = [];
const opened = openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: () => {
calls.push('ensureOverlayStartupPrereqs');
},
ensureOverlayWindowsReadyForVisibilityActions: () => {
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
},
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
calls.push(`send:${channel}`);
assert.equal(payload, undefined);
assert.deepEqual(runtimeOptions, {
restoreOnModalClose: 'runtime-options',
preferModalWindow: undefined,
});
return true;
},
},
{
channel: 'runtime-options:open',
modal: 'runtime-options',
},
);
assert.equal(opened, true);
assert.deepEqual(calls, [
'ensureOverlayStartupPrereqs',
'ensureOverlayWindowsReadyForVisibilityActions',
'send:runtime-options:open',
]);
});
test('openOverlayHostedModal forwards payload and modal-window preference', () => {
const payload = { sessionId: 'yt-1' };
const opened = openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: () => {},
ensureOverlayWindowsReadyForVisibilityActions: () => {},
sendToActiveOverlayWindow: (channel, forwardedPayload, runtimeOptions) => {
assert.equal(channel, 'youtube:picker-open');
assert.deepEqual(forwardedPayload, payload);
assert.deepEqual(runtimeOptions, {
restoreOnModalClose: 'youtube-track-picker',
preferModalWindow: true,
});
return false;
},
},
{
channel: 'youtube:picker-open',
modal: 'youtube-track-picker',
payload,
preferModalWindow: true,
},
);
assert.equal(opened, false);
});

View File

@@ -0,0 +1,29 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
export function openOverlayHostedModal(
deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
},
input: {
channel: string;
modal: OverlayHostedModal;
payload?: unknown;
preferModalWindow?: boolean;
},
): boolean {
deps.ensureOverlayStartupPrereqs();
deps.ensureOverlayWindowsReadyForVisibilityActions();
return deps.sendToActiveOverlayWindow(input.channel, input.payload, {
restoreOnModalClose: input.modal,
preferModalWindow: input.preferModalWindow,
});
}

View File

@@ -0,0 +1,211 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ElectronAPI, RuntimeOptionState } from '../../types';
import { createRendererState } from '../state.js';
import { createRuntimeOptionsModal } from './runtime-options.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
toggle: (entry: string, force?: boolean) => {
if (force === undefined) {
if (tokens.has(entry)) {
tokens.delete(entry);
return false;
}
tokens.add(entry);
return true;
}
if (force) tokens.add(entry);
else tokens.delete(entry);
return force;
},
contains: (entry: string) => tokens.has(entry),
};
}
function createElementStub() {
return {
className: '',
textContent: '',
title: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
};
}
function createRuntimeOptionsListStub() {
return {
innerHTML: '',
appendChild: () => {},
querySelector: () => null,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function withRuntimeOptionsModal(
getRuntimeOptions: () => Promise<RuntimeOptionState[]>,
run: (input: {
modal: ReturnType<typeof createRuntimeOptionsModal>;
state: ReturnType<typeof createRendererState>;
overlayClassList: ReturnType<typeof createClassList>;
modalClassList: ReturnType<typeof createClassList>;
statusNode: {
textContent: string;
classList: ReturnType<typeof createClassList>;
};
syncCalls: string[];
}) => Promise<void> | void,
): Promise<void> {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const statusNode = {
textContent: '',
classList: createClassList(),
};
const overlayClassList = createClassList();
const modalClassList = createClassList(['hidden']);
const syncCalls: string[] = [];
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getRuntimeOptions,
setRuntimeOptionValue: async () => ({ ok: true }),
notifyOverlayModalClosed: () => {},
} satisfies Pick<
ElectronAPI,
'getRuntimeOptions' | 'setRuntimeOptionValue' | 'notifyOverlayModalClosed'
>,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
const modal = createRuntimeOptionsModal(
{
dom: {
overlay: { classList: overlayClassList },
runtimeOptionsModal: {
classList: modalClassList,
setAttribute: () => {},
},
runtimeOptionsClose: {
addEventListener: () => {},
},
runtimeOptionsList: createRuntimeOptionsListStub(),
runtimeOptionsStatus: statusNode,
},
state,
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {
syncCalls.push('sync');
},
},
);
return Promise.resolve()
.then(() =>
run({
modal,
state,
overlayClassList,
modalClassList,
statusNode,
syncCalls,
}),
)
.finally(() => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: previousDocument,
});
});
}
test('openRuntimeOptionsModal shows loading shell before runtime options resolve', async () => {
const deferred = createDeferred<RuntimeOptionState[]>();
await withRuntimeOptionsModal(() => deferred.promise, async (input) => {
input.modal.openRuntimeOptionsModal();
assert.equal(input.state.runtimeOptionsModalOpen, true);
assert.equal(input.overlayClassList.contains('interactive'), true);
assert.equal(input.modalClassList.contains('hidden'), false);
assert.equal(input.statusNode.textContent, 'Loading runtime options...');
assert.deepEqual(input.syncCalls, ['sync']);
deferred.resolve([
{
id: 'anki.autoUpdateNewCards',
label: 'Auto-update new cards',
scope: 'ankiConnect',
valueType: 'boolean',
value: true,
allowedValues: [true, false],
requiresRestart: false,
},
]);
await flushAsyncWork();
assert.equal(
input.statusNode.textContent,
'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
);
assert.equal(input.statusNode.classList.contains('error'), false);
});
});
test('openRuntimeOptionsModal keeps modal visible when loading fails', async () => {
const deferred = createDeferred<RuntimeOptionState[]>();
await withRuntimeOptionsModal(() => deferred.promise, async (input) => {
input.modal.openRuntimeOptionsModal();
deferred.reject(new Error('boom'));
await flushAsyncWork();
assert.equal(input.state.runtimeOptionsModalOpen, true);
assert.equal(input.overlayClassList.contains('interactive'), true);
assert.equal(input.modalClassList.contains('hidden'), false);
assert.equal(input.statusNode.textContent, 'Failed to load runtime options');
assert.equal(input.statusNode.classList.contains('error'), true);
});
});

View File

@@ -22,6 +22,9 @@ export function createRuntimeOptionsModal(
syncSettingsModalSubtitleSuppression: () => void; syncSettingsModalSubtitleSuppression: () => void;
}, },
) { ) {
const DEFAULT_STATUS_MESSAGE =
'Use arrow keys. Click value to cycle. Enter or double-click to apply.';
function formatRuntimeOptionValue(value: RuntimeOptionValue): string { function formatRuntimeOptionValue(value: RuntimeOptionValue): string {
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
return value ? 'On' : 'Off'; return value ? 'On' : 'Off';
@@ -177,10 +180,13 @@ export function createRuntimeOptionsModal(
} }
} }
async function openRuntimeOptionsModal(): Promise<void> { async function refreshRuntimeOptions(): Promise<void> {
const optionsList = await window.electronAPI.getRuntimeOptions(); const optionsList = await window.electronAPI.getRuntimeOptions();
updateRuntimeOptions(optionsList); updateRuntimeOptions(optionsList);
setRuntimeOptionsStatus(DEFAULT_STATUS_MESSAGE);
}
function showRuntimeOptionsModalShell(): void {
ctx.state.runtimeOptionsModalOpen = true; ctx.state.runtimeOptionsModalOpen = true;
options.syncSettingsModalSubtitleSuppression(); options.syncSettingsModalSubtitleSuppression();
@@ -188,9 +194,19 @@ export function createRuntimeOptionsModal(
ctx.dom.runtimeOptionsModal.classList.remove('hidden'); ctx.dom.runtimeOptionsModal.classList.remove('hidden');
ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'false'); ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'false');
setRuntimeOptionsStatus( setRuntimeOptionsStatus('Loading runtime options...');
'Use arrow keys. Click value to cycle. Enter or double-click to apply.', }
);
function openRuntimeOptionsModal(): void {
if (!ctx.state.runtimeOptionsModalOpen) {
showRuntimeOptionsModalShell();
} else {
setRuntimeOptionsStatus('Refreshing runtime options...');
}
void refreshRuntimeOptions().catch(() => {
setRuntimeOptionsStatus('Failed to load runtime options', true);
});
} }
function handleRuntimeOptionsKeydown(e: KeyboardEvent): boolean { function handleRuntimeOptionsKeydown(e: KeyboardEvent): boolean {

View File

@@ -432,15 +432,9 @@ registerRendererGlobalErrorHandlers(window, recovery);
function registerModalOpenHandlers(): void { function registerModalOpenHandlers(): void {
window.electronAPI.onOpenRuntimeOptions(() => { window.electronAPI.onOpenRuntimeOptions(() => {
runGuardedAsync('runtime-options:open', async () => { runGuarded('runtime-options:open', () => {
try { runtimeOptionsModal.openRuntimeOptionsModal();
await runtimeOptionsModal.openRuntimeOptionsModal(); window.electronAPI.notifyOverlayModalOpened('runtime-options');
window.electronAPI.notifyOverlayModalOpened('runtime-options');
} catch {
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);
window.electronAPI.notifyOverlayModalClosed('runtime-options');
syncSettingsModalSubtitleSuppression();
}
}); });
}); });
window.electronAPI.onOpenJimaku(() => { window.electronAPI.onOpenJimaku(() => {

View File

@@ -147,7 +147,7 @@ export class WindowsWindowTracker extends BaseWindowTracker {
const focusedMatch = result.matches.find((m) => m.isForeground); const focusedMatch = result.matches.find((m) => m.isForeground);
const best = const best =
focusedMatch ?? focusedMatch ??
result.matches.sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0]!; [...result.matches].sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0]!;
return { return {
geometry: best.bounds, geometry: best.bounds,