mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-11 16:19:27 -07:00
fix: address CodeRabbit follow-ups
This commit is contained in:
@@ -75,6 +75,7 @@ test('parseArgs captures youtube startup forwarding flags', () => {
|
||||
|
||||
test('parseArgs captures session action forwarding flags', () => {
|
||||
const args = parseArgs([
|
||||
'--toggle-stats-overlay',
|
||||
'--open-jimaku',
|
||||
'--open-youtube-picker',
|
||||
'--open-playlist-browser',
|
||||
@@ -82,11 +83,14 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
'--play-next-subtitle',
|
||||
'--shift-sub-delay-prev-line',
|
||||
'--shift-sub-delay-next-line',
|
||||
'--cycle-runtime-option',
|
||||
'anki.autoUpdateNewCards:prev',
|
||||
'--copy-subtitle-count',
|
||||
'3',
|
||||
'--mine-sentence-count=2',
|
||||
]);
|
||||
|
||||
assert.equal(args.toggleStatsOverlay, true);
|
||||
assert.equal(args.openJimaku, true);
|
||||
assert.equal(args.openYoutubePicker, 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.shiftSubDelayPrevLine, 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.mineSentenceCount, 2);
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
@@ -199,6 +205,21 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
|
||||
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']);
|
||||
assert.equal(dictionary.dictionary, true);
|
||||
assert.equal(hasExplicitCommand(dictionary), true);
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface CliArgs {
|
||||
triggerFieldGrouping: boolean;
|
||||
triggerSubsync: boolean;
|
||||
markAudioCard: boolean;
|
||||
toggleStatsOverlay: boolean;
|
||||
openRuntimeOptions: boolean;
|
||||
openJimaku: boolean;
|
||||
openYoutubePicker: boolean;
|
||||
@@ -32,6 +33,8 @@ export interface CliArgs {
|
||||
playNextSubtitle: boolean;
|
||||
shiftSubDelayPrevLine: boolean;
|
||||
shiftSubDelayNextLine: boolean;
|
||||
cycleRuntimeOptionId?: string;
|
||||
cycleRuntimeOptionDirection?: 1 | -1;
|
||||
copySubtitleCount?: number;
|
||||
mineSentenceCount?: number;
|
||||
anilistStatus: boolean;
|
||||
@@ -111,6 +114,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
triggerFieldGrouping: false,
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
openRuntimeOptions: false,
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
@@ -154,6 +158,24 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
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) {
|
||||
const arg = argv[i];
|
||||
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-subsync') args.triggerSubsync = 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-jimaku') args.openJimaku = 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 === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = 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=')) {
|
||||
const value = Number(arg.split('=', 2)[1]);
|
||||
if (Number.isInteger(value)) args.copySubtitleCount = value;
|
||||
@@ -407,6 +443,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openJimaku ||
|
||||
args.openYoutubePicker ||
|
||||
@@ -415,6 +452,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentenceCount !== undefined ||
|
||||
args.anilistStatus ||
|
||||
@@ -468,6 +506,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.triggerFieldGrouping &&
|
||||
!args.triggerSubsync &&
|
||||
!args.markAudioCard &&
|
||||
!args.toggleStatsOverlay &&
|
||||
!args.openRuntimeOptions &&
|
||||
!args.openJimaku &&
|
||||
!args.openYoutubePicker &&
|
||||
@@ -476,6 +515,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
args.mineSentenceCount === undefined &&
|
||||
!args.anilistStatus &&
|
||||
@@ -520,6 +560,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openJimaku ||
|
||||
args.openYoutubePicker ||
|
||||
@@ -528,6 +569,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentenceCount !== undefined ||
|
||||
args.dictionary ||
|
||||
@@ -567,6 +609,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.triggerFieldGrouping &&
|
||||
!args.triggerSubsync &&
|
||||
!args.markAudioCard &&
|
||||
!args.toggleStatsOverlay &&
|
||||
!args.openRuntimeOptions &&
|
||||
!args.openJimaku &&
|
||||
!args.openYoutubePicker &&
|
||||
@@ -575,6 +618,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
args.mineSentenceCount === undefined &&
|
||||
!args.anilistStatus &&
|
||||
@@ -627,6 +671,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentenceCount !== undefined
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
triggerFieldGrouping: false,
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
openRuntimeOptions: false,
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
@@ -36,6 +37,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
|
||||
@@ -76,9 +76,7 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
|
||||
);
|
||||
assert.ok(calls.includes('startBackgroundWarmups'));
|
||||
assert.ok(
|
||||
calls.includes(
|
||||
'log:Runtime ready: immersion tracker startup deferred until first media activity.',
|
||||
),
|
||||
calls.includes('log:Runtime ready: immersion tracker startup requested.'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
getResolvedConfig: () => ({
|
||||
|
||||
@@ -29,6 +29,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
triggerFieldGrouping: false,
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
refreshKnownWords: false,
|
||||
openRuntimeOptions: false,
|
||||
openJimaku: false,
|
||||
@@ -38,6 +39,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
@@ -509,6 +512,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
||||
expected: 'startPendingMineSentenceMultiple:2500',
|
||||
},
|
||||
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
|
||||
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
|
||||
{
|
||||
args: { openRuntimeOptions: true },
|
||||
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', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
|
||||
|
||||
@@ -396,6 +396,12 @@ export function handleCliCommand(
|
||||
'markLastCardAsAudioCard',
|
||||
'Audio card failed',
|
||||
);
|
||||
} else if (args.toggleStatsOverlay) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'toggleStatsOverlay' },
|
||||
'toggleStatsOverlay',
|
||||
'Stats toggle failed',
|
||||
);
|
||||
} else if (args.openRuntimeOptions) {
|
||||
deps.openRuntimeOptionsPalette();
|
||||
} else if (args.openJimaku) {
|
||||
@@ -436,6 +442,18 @@ export function handleCliCommand(
|
||||
'shiftSubDelayNextLine',
|
||||
'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) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'copySubtitleMultiple', payload: { count: args.copySubtitleCount } },
|
||||
|
||||
@@ -35,7 +35,10 @@ import {
|
||||
const { ipcMain } = electron;
|
||||
|
||||
export interface IpcServiceDeps {
|
||||
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
onOverlayModalClosed: (
|
||||
modal: OverlayHostedModal,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
onOverlayModalOpened?: (
|
||||
modal: OverlayHostedModal,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
@@ -160,7 +163,10 @@ interface IpcMainRegistrar {
|
||||
export interface IpcDepsRuntimeOptions {
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisibility: () => boolean;
|
||||
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
onOverlayModalClosed: (
|
||||
modal: OverlayHostedModal,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
onOverlayModalOpened?: (
|
||||
modal: OverlayHostedModal,
|
||||
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);
|
||||
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) => {
|
||||
const parsedModal = parseOverlayHostedModal(modal);
|
||||
|
||||
@@ -320,22 +320,7 @@ test('shouldActivateOverlayShortcuts preserves non-macOS behavior', () => {
|
||||
|
||||
test('registerOverlayShortcutsRuntime reports active shortcuts when configured', () => {
|
||||
const result = registerOverlayShortcutsRuntime({
|
||||
getConfiguredShortcuts: () =>
|
||||
({
|
||||
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,
|
||||
getConfiguredShortcuts: () => makeShortcuts({ openJimaku: 'Ctrl+J' }),
|
||||
getOverlayHandlers: () => ({
|
||||
copySubtitle: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
@@ -359,22 +344,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
|
||||
test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active', () => {
|
||||
const calls: string[] = [];
|
||||
const result = unregisterOverlayShortcutsRuntime(true, {
|
||||
getConfiguredShortcuts: () =>
|
||||
({
|
||||
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,
|
||||
getConfiguredShortcuts: () => makeShortcuts(),
|
||||
getOverlayHandlers: () => ({
|
||||
copySubtitle: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
|
||||
@@ -664,6 +664,80 @@ test('tracked Windows overlay stays interactive while the overlay window itself
|
||||
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', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
|
||||
@@ -92,6 +92,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
|
||||
const showPassiveVisibleOverlay = (): void => {
|
||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||
const wasVisible = mainWindow.isVisible();
|
||||
const shouldDefaultToPassthrough =
|
||||
args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough;
|
||||
const isVisibleOverlayFocused =
|
||||
@@ -116,8 +117,10 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
windowsForegroundProcessName === windowsOverlayProcessName)) &&
|
||||
!isTrackedWindowsTargetMinimized &&
|
||||
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
|
||||
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
|
||||
const shouldIgnoreMouseEvents =
|
||||
forceMousePassthrough || (shouldDefaultToPassthrough && !isVisibleOverlayFocused);
|
||||
forceMousePassthrough ||
|
||||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
||||
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
||||
const shouldKeepTrackedWindowsOverlayTopmost =
|
||||
!args.isWindowsPlatform ||
|
||||
@@ -126,8 +129,6 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
isTrackedWindowsTargetFocused ||
|
||||
shouldPreserveWindowsOverlayDuringFocusHandoff ||
|
||||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
|
||||
const wasVisible = mainWindow.isVisible();
|
||||
|
||||
if (shouldIgnoreMouseEvents) {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
|
||||
@@ -97,6 +97,9 @@ export function createOverlayWindow(
|
||||
},
|
||||
): BrowserWindow {
|
||||
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')) {
|
||||
options.ensureOverlayWindowLevel(window);
|
||||
|
||||
@@ -97,7 +97,26 @@ function normalizeCodeToken(token: string): string | null {
|
||||
/^arrow(?:up|down|left|right)$/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;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
triggerFieldGrouping: false,
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
openRuntimeOptions: false,
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
@@ -36,6 +37,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
|
||||
@@ -311,7 +311,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
|
||||
deps.createSubtitleTimingTracker();
|
||||
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 {
|
||||
deps.log('Runtime ready: immersion tracker dependency is missing.');
|
||||
}
|
||||
|
||||
58
src/main.ts
58
src/main.ts
@@ -454,6 +454,7 @@ import { createOverlayModalInputState } from './main/runtime/overlay-modal-input
|
||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
|
||||
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 {
|
||||
createFrequencyDictionaryRuntimeService,
|
||||
@@ -1484,9 +1485,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
||||
openRuntimeOptionsPalette();
|
||||
},
|
||||
openJimaku: () => {
|
||||
sendToActiveOverlayWindow('jimaku:open', undefined, {
|
||||
restoreOnModalClose: 'jimaku',
|
||||
});
|
||||
openJimakuOverlay();
|
||||
},
|
||||
markAudioCard: () => markLastCardAsAudioCard(),
|
||||
copySubtitleMultiple: (timeoutMs: number) => {
|
||||
@@ -2206,7 +2205,42 @@ function setOverlayDebugVisualizationEnabled(enabled: boolean): 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 {
|
||||
@@ -4522,7 +4556,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
toggleSecondarySub: () => handleCycleSecondarySubMode(),
|
||||
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openJimaku: () => overlayModalRuntime.openJimaku(),
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||
@@ -4551,7 +4585,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
mpvCommandMainDeps: {
|
||||
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openJimaku: () => overlayModalRuntime.openJimaku(),
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => {
|
||||
@@ -4591,7 +4625,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
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);
|
||||
},
|
||||
onOverlayModalOpened: (modal) => {
|
||||
|
||||
@@ -7,13 +7,16 @@ type MockWindow = {
|
||||
visible: boolean;
|
||||
focused: boolean;
|
||||
ignoreMouseEvents: boolean;
|
||||
forwardedIgnoreMouseEvents: boolean;
|
||||
webContentsFocused: boolean;
|
||||
showCount: number;
|
||||
hideCount: number;
|
||||
sent: unknown[][];
|
||||
loading: boolean;
|
||||
url: string;
|
||||
contentReady: boolean;
|
||||
loadCallbacks: Array<() => void>;
|
||||
readyToShowCallbacks: Array<() => void>;
|
||||
};
|
||||
|
||||
function createMockWindow(): MockWindow & {
|
||||
@@ -29,6 +32,7 @@ function createMockWindow(): MockWindow & {
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
focus: () => void;
|
||||
once: (event: 'ready-to-show', cb: () => void) => void;
|
||||
webContents: {
|
||||
focused: boolean;
|
||||
isLoading: () => boolean;
|
||||
@@ -44,13 +48,16 @@ function createMockWindow(): MockWindow & {
|
||||
visible: false,
|
||||
focused: false,
|
||||
ignoreMouseEvents: false,
|
||||
forwardedIgnoreMouseEvents: false,
|
||||
webContentsFocused: false,
|
||||
showCount: 0,
|
||||
hideCount: 0,
|
||||
sent: [],
|
||||
loading: false,
|
||||
url: 'file:///overlay/index.html?layer=modal',
|
||||
contentReady: true,
|
||||
loadCallbacks: [],
|
||||
readyToShowCallbacks: [],
|
||||
};
|
||||
const window = {
|
||||
...state,
|
||||
@@ -58,8 +65,9 @@ function createMockWindow(): MockWindow & {
|
||||
isVisible: () => state.visible,
|
||||
isFocused: () => state.focused,
|
||||
getURL: () => state.url,
|
||||
setIgnoreMouseEvents: (ignore: boolean, _options?: { forward?: boolean }) => {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
state.ignoreMouseEvents = ignore;
|
||||
state.forwardedIgnoreMouseEvents = options?.forward === true;
|
||||
},
|
||||
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {},
|
||||
moveTop: () => {},
|
||||
@@ -76,6 +84,9 @@ function createMockWindow(): MockWindow & {
|
||||
focus: () => {
|
||||
state.focused = true;
|
||||
},
|
||||
once: (_event: 'ready-to-show', cb: () => void) => {
|
||||
state.readyToShowCallbacks.push(cb);
|
||||
},
|
||||
webContents: {
|
||||
isLoading: () => state.loading,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -199,6 +229,7 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
|
||||
const window = createMockWindow();
|
||||
window.url = '';
|
||||
window.loading = true;
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
@@ -217,9 +248,14 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
assert.equal(window.loadCallbacks.length, 1);
|
||||
assert.equal(window.readyToShowCallbacks.length, 1);
|
||||
window.loading = false;
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.loadCallbacks[0]!();
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.contentReady = true;
|
||||
window.readyToShowCallbacks[0]!();
|
||||
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
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(mainWindow.ignoreMouseEvents, true);
|
||||
assert.equal(mainWindow.forwardedIgnoreMouseEvents, true);
|
||||
assert.equal(modalWindow.ignoreMouseEvents, false);
|
||||
|
||||
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', () => {
|
||||
@@ -359,8 +396,8 @@ test('modal window path hides visible main overlay until modal closes', () => {
|
||||
|
||||
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||
|
||||
assert.equal(mainWindow.getShowCount(), 1);
|
||||
assert.equal(mainWindow.isVisible(), true);
|
||||
assert.equal(mainWindow.getShowCount(), 0);
|
||||
assert.equal(mainWindow.isVisible(), false);
|
||||
});
|
||||
|
||||
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.url = '';
|
||||
window.contentReady = false;
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, {
|
||||
restoreOnModalClose: 'jimaku',
|
||||
@@ -519,6 +557,36 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open
|
||||
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 () => {
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
|
||||
|
||||
export interface OverlayWindowResolver {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
@@ -90,6 +91,15 @@ export function createOverlayModalRuntimeService(
|
||||
if (window.webContents.isLoading()) {
|
||||
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();
|
||||
return currentURL !== '' && currentURL !== 'about:blank';
|
||||
};
|
||||
@@ -109,11 +119,17 @@ export function createOverlayModalRuntimeService(
|
||||
return;
|
||||
}
|
||||
|
||||
window.webContents.once('did-finish-load', () => {
|
||||
if (!window.isDestroyed() && !window.webContents.isLoading()) {
|
||||
sendNow(window);
|
||||
let delivered = false;
|
||||
const deliverWhenReady = (): void => {
|
||||
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 = (
|
||||
@@ -320,12 +336,12 @@ export function createOverlayModalRuntimeService(
|
||||
const modalWindow = deps.getModalWindow();
|
||||
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||
clearPendingModalWindowReveal();
|
||||
notifyModalStateChange(false);
|
||||
setMainWindowMousePassthroughForModal(false);
|
||||
setMainWindowVisibilityForModal(false);
|
||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||
modalWindow.hide();
|
||||
}
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
mainWindowHiddenByModal = false;
|
||||
notifyModalStateChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
triggerFieldGrouping: false,
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
openRuntimeOptions: false,
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
@@ -50,6 +51,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
|
||||
@@ -76,7 +76,16 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openJimaku ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.anilistStatus ||
|
||||
args.anilistLogout ||
|
||||
args.anilistSetup ||
|
||||
|
||||
@@ -161,6 +161,41 @@ test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv
|
||||
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', () => {
|
||||
const calls: string[] = [];
|
||||
let assignedTracker: unknown = 'initial';
|
||||
|
||||
@@ -102,7 +102,11 @@ export function createImmersionTrackerStartupHandler(
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if ((deps.shouldAutoConnectMpv?.() ?? true) && mpvClient && !mpvClient.connected) {
|
||||
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();
|
||||
} catch (error) {
|
||||
|
||||
66
src/main/runtime/overlay-hosted-modal-open.test.ts
Normal file
66
src/main/runtime/overlay-hosted-modal-open.test.ts
Normal 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);
|
||||
});
|
||||
29
src/main/runtime/overlay-hosted-modal-open.ts
Normal file
29
src/main/runtime/overlay-hosted-modal-open.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
211
src/renderer/modals/runtime-options.test.ts
Normal file
211
src/renderer/modals/runtime-options.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,9 @@ export function createRuntimeOptionsModal(
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
const DEFAULT_STATUS_MESSAGE =
|
||||
'Use arrow keys. Click value to cycle. Enter or double-click to apply.';
|
||||
|
||||
function formatRuntimeOptionValue(value: RuntimeOptionValue): string {
|
||||
if (typeof value === 'boolean') {
|
||||
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();
|
||||
updateRuntimeOptions(optionsList);
|
||||
setRuntimeOptionsStatus(DEFAULT_STATUS_MESSAGE);
|
||||
}
|
||||
|
||||
function showRuntimeOptionsModalShell(): void {
|
||||
ctx.state.runtimeOptionsModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
|
||||
@@ -188,9 +194,19 @@ export function createRuntimeOptionsModal(
|
||||
ctx.dom.runtimeOptionsModal.classList.remove('hidden');
|
||||
ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
setRuntimeOptionsStatus(
|
||||
'Use arrow keys. Click value to cycle. Enter or double-click to apply.',
|
||||
);
|
||||
setRuntimeOptionsStatus('Loading runtime options...');
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -432,15 +432,9 @@ registerRendererGlobalErrorHandlers(window, recovery);
|
||||
|
||||
function registerModalOpenHandlers(): void {
|
||||
window.electronAPI.onOpenRuntimeOptions(() => {
|
||||
runGuardedAsync('runtime-options:open', async () => {
|
||||
try {
|
||||
await runtimeOptionsModal.openRuntimeOptionsModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('runtime-options');
|
||||
} catch {
|
||||
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);
|
||||
window.electronAPI.notifyOverlayModalClosed('runtime-options');
|
||||
syncSettingsModalSubtitleSuppression();
|
||||
}
|
||||
runGuarded('runtime-options:open', () => {
|
||||
runtimeOptionsModal.openRuntimeOptionsModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('runtime-options');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenJimaku(() => {
|
||||
|
||||
@@ -147,7 +147,7 @@ export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
const focusedMatch = result.matches.find((m) => m.isForeground);
|
||||
const best =
|
||||
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 {
|
||||
geometry: best.bounds,
|
||||
|
||||
Reference in New Issue
Block a user