feat(overlay): add loading OSD spinner and queue notifications until ren

- Show mpv OSD spinner from start-file until subminer-overlay-loading-ready; force-shown for visible-overlay startup regardless of osd_messages setting
- Gate non-macOS overlay visibility on content-ready so first subtitle line is immediately hoverable and clickable
- Queue startup notifications in main process until overlay window finishes loading; upsert progress cards by id to avoid cold-start floods
- Defer background warmups until after overlay runtime init so queued notifications can deliver promptly
- Preserve character dictionary checking/building/importing/ready phases as distinct history entries; route building and importing to system notifications when notificationType is both
This commit is contained in:
2026-06-07 23:13:51 -07:00
parent d033884b09
commit 9d77907877
49 changed files with 1613 additions and 132 deletions
+6 -11
View File
@@ -346,20 +346,15 @@ test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomit
assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs'));
});
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
const calls: string[] = [];
const { deps } = makeDeps({
startBackgroundWarmups: () => {
calls.push('startBackgroundWarmups');
},
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
createMpvClient: () => calls.push('createMpvClient'),
});
test('runAppReadyRuntime starts background warmups after overlay startup', async () => {
const { deps, calls } = makeDeps();
await runAppReadyRuntime(deps);
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition'));
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient'));
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
});
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
+65 -2
View File
@@ -211,7 +211,70 @@ test('macOS dismisses overlay loading OSD when tracker recovers', () => {
assert.ok(calls.includes('show-inactive'));
});
test('tracked non-macOS overlay stays hidden while tracker is not ready', () => {
test('tracked non-native overlay shows loading OSD until renderer content is visible', () => {
const { window, calls, setContentReady } = createMainWindowRecorder();
let loadingShown = false;
const osdMessages: string[] = [];
const dismissedOsds: string[] = [];
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: loadingShown,
setTrackerNotReadyWarningShown: (shown: boolean) => {
loadingShown = shown;
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
dismissOverlayLoadingOsd: () => {
dismissedOsds.push('dismiss');
},
} as never);
setContentReady(false);
run();
run();
assert.equal(loadingShown, true);
assert.deepEqual(osdMessages, ['Overlay loading...']);
assert.deepEqual(dismissedOsds, []);
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('show-inactive'));
setContentReady(true);
run();
assert.equal(loadingShown, false);
assert.deepEqual(dismissedOsds, ['dismiss']);
assert.ok(calls.includes('show-inactive'));
});
test('tracked non-macOS overlay stays hidden and emits loading OSD while tracker is not ready', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
const tracker: WindowTrackerStub = {
@@ -254,7 +317,7 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
assert.ok(!calls.includes('update-bounds'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('osd'));
assert.ok(calls.includes('osd'));
});
test('non-native passive overlay stays click-through after subsequent visibility updates', () => {
+20 -6
View File
@@ -311,8 +311,18 @@ export function updateVisibleOverlayVisibility(args: {
!args.isWindowsPlatform &&
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
const isWaitingForOverlayContentReady = (): boolean => {
const hasWebContents =
typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object';
return (
!mainWindow.isVisible() &&
hasWebContents &&
!isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow)
);
};
const maybeShowOverlayLoadingOsd = (): void => {
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
if (!args.showOverlayLoadingOsd) {
return;
}
if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) {
@@ -322,9 +332,6 @@ export function updateVisibleOverlayVisibility(args: {
args.markOverlayLoadingOsdShown?.();
};
const maybeDismissOverlayLoadingOsd = (): void => {
if (!args.isMacOSPlatform) {
return;
}
args.dismissOverlayLoadingOsd?.();
};
@@ -379,8 +386,15 @@ export function updateVisibleOverlayVisibility(args: {
args.syncOverlayShortcuts();
return;
}
args.setTrackerNotReadyWarningShown(false);
maybeDismissOverlayLoadingOsd();
if (isWaitingForOverlayContentReady()) {
if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true);
maybeShowOverlayLoadingOsd();
}
} else {
args.setTrackerNotReadyWarningShown(false);
maybeDismissOverlayLoadingOsd();
}
const geometry = args.windowTracker.getGeometry();
if (geometry) {
args.updateVisibleOverlayBounds(geometry);
+2
View File
@@ -116,6 +116,7 @@ export function createOverlayWindow(
linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void;
yomitanSession?: Session | null;
@@ -139,6 +140,7 @@ export function createOverlayWindow(
window.webContents.on('did-finish-load', () => {
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
options.onRuntimeOptionsChanged();
options.onWindowDidFinishLoad?.();
});
window.webContents.on('page-title-updated', (event) => {
+4 -3
View File
@@ -269,7 +269,7 @@ test('runAppReadyRuntime loads Yomitan before headless overlay fallback initiali
]);
});
test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => {
test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => {
const calls: string[] = [];
await runAppReadyRuntime({
@@ -354,9 +354,10 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
shouldSkipHeavyStartup: () => false,
});
assert.ok(calls.indexOf('load-yomitan') !== -1);
assert.ok(calls.indexOf('init-overlay') !== -1);
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
assert.ok(calls.indexOf('warmups') !== -1);
assert.ok(calls.indexOf('init-overlay') < calls.indexOf('warmups'));
assert.equal(calls.includes('load-yomitan'), false);
});
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
+12 -3
View File
@@ -232,6 +232,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
let firstRunSetupHandled = false;
let initialArgsHandled = false;
let backgroundWarmupsHandled = false;
const handleFirstRunSetupOnce = async (): Promise<void> => {
if (firstRunSetupHandled) {
return;
@@ -246,6 +247,13 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
initialArgsHandled = true;
deps.handleInitialArgs();
};
const startBackgroundWarmupsOnce = (): void => {
if (backgroundWarmupsHandled) {
return;
}
backgroundWarmupsHandled = true;
deps.startBackgroundWarmups();
};
deps.ensureDefaultConfigBootstrap();
if (deps.shouldRunHeadlessInitialCommand?.()) {
@@ -297,8 +305,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
for (const warning of deps.getConfigWarnings()) {
deps.logConfigWarning(warning);
}
deps.startBackgroundWarmups();
deps.loadSubtitlePosition();
deps.resolveKeybindings();
deps.createMpvClient();
@@ -344,16 +350,19 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.');
startBackgroundWarmupsOnce();
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
await ensureYomitanExtensionReady();
deps.setVisibleOverlayVisible(true);
deps.initializeOverlayRuntime();
startBackgroundWarmupsOnce();
} else {
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
if (deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.()) {
await handleFirstRunSetupOnce();
handleInitialArgsOnce();
startBackgroundWarmupsOnce();
} else {
startBackgroundWarmupsOnce();
await ensureYomitanExtensionReady();
}
}