refactor(main): extract overlay notifications runtime from main.ts

This commit is contained in:
2026-06-11 23:11:56 -07:00
parent a4edf53d21
commit 1fc83a842d
3 changed files with 298 additions and 168 deletions
+29 -157
View File
@@ -109,7 +109,6 @@ import type {
SubtitleMiningContext,
SubtitlePosition,
OverlayNotificationPayload,
OverlayNotificationEventPayload,
NotificationType,
WindowGeometry,
} from './types';
@@ -545,16 +544,8 @@ import {
INSTALL_UPDATE_ACTION_ID,
UPDATE_AVAILABLE_NOTIFICATION_ID,
} from './main/runtime/update/update-notifications';
import { createOverlayLoadingOsdController } from './main/runtime/overlay-loading-osd';
import { createMaybeStartOverlayLoadingOsdHandler } from './main/runtime/overlay-loading-osd-start';
import { withConfiguredOverlayNotificationPosition } from './main/runtime/overlay-notification-position';
import { createOverlayNotificationDelivery } from './main/runtime/overlay-notification-delivery';
import {
getPlaybackFeedbackNotificationOptions,
notifyConfiguredStatus,
type ConfiguredStatusNotificationOptions,
} from './main/runtime/configured-status-notification';
import { resolveOverlayReadinessNotificationType } from './main/runtime/notification-routing';
import { createOverlayNotificationsRuntime } from './main/runtime/overlay-notifications-runtime';
import { type ConfiguredStatusNotificationOptions } from './main/runtime/configured-status-notification';
import {
runUpdateCliCommand,
writeUpdateCliCommandResponse,
@@ -3291,177 +3282,58 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void {
overlayManager.broadcastToOverlayWindows(channel, ...args);
}
function isVisibleOverlayContentReady(): boolean {
const overlayWindow = overlayManager.getMainWindow();
return Boolean(
overlayManager.getVisibleOverlayVisible() &&
overlayWindow &&
isOverlayWindowReadyForNotification(overlayWindow),
);
}
function getConfiguredStatusNotificationType(): NotificationType {
const configuredType = getResolvedConfig().ankiConnect.behavior.notificationType;
return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady());
}
function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean {
if (window.isDestroyed() || !isOverlayWindowContentReady(window)) {
return false;
}
if (window.webContents.isLoading()) {
return false;
}
const currentURL = window.webContents.getURL();
return currentURL !== '' && currentURL !== 'about:blank';
}
const overlayNotificationDelivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => isVisibleOverlayContentReady(),
send: (payload) => {
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
},
scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs),
clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
const overlayNotificationsRuntime = createOverlayNotificationsRuntime({
getResolvedConfig: () => getResolvedConfig(),
getMainOverlayWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args),
showMpvOsd: (message) => showMpvOsd(message),
getMpvClient: () => appState.mpvClient,
getAnkiIntegration: () => appState.ankiIntegration,
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
});
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null = null;
const {
flushQueuedOverlayNotifications,
openAnkiCardFromNotification,
toggleNotificationHistoryPanel,
showConfiguredPlaybackFeedback,
maybeStartOverlayLoadingOsd,
} = overlayNotificationsRuntime;
function flushQueuedOverlayNotifications(): void {
overlayNotificationDelivery.flush();
}
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
overlayNotificationDelivery.send(payload);
// Hoisted wrappers: these names are referenced (directly or via deps object
// literals) during module initialization before this point, so they must stay
// hoisted function declarations that delegate to the runtime lazily.
function getConfiguredStatusNotificationType(): NotificationType {
return overlayNotificationsRuntime.getConfiguredStatusNotificationType();
}
function showOverlayNotification(payload: OverlayNotificationPayload): void {
sendOverlayNotificationEvent(
withConfiguredOverlayNotificationPosition(payload, getResolvedConfig()),
);
}
function dismissOverlayNotification(id: string): void {
sendOverlayNotificationEvent({ id, dismiss: true });
}
async function openAnkiCardFromNotification(noteId: number): Promise<void> {
const activeIntegrationOpen = appState.ankiIntegration?.openNoteInAnki(noteId);
if (activeIntegrationOpen) {
await activeIntegrationOpen;
return;
}
const resolvedConfig = getResolvedConfig();
const effectiveAnkiConfig =
appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
resolvedConfig.ankiConnect;
const fallbackClient = new AnkiConnectClient(
effectiveAnkiConfig.url || DEFAULT_CONFIG.ankiConnect.url,
);
await fallbackClient.openNoteInBrowser(noteId);
}
function toggleNotificationHistoryPanel(): void {
broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
overlayNotificationsRuntime.showOverlayNotification(payload);
}
function showConfiguredStatusNotification(
message: string,
options: ConfiguredStatusNotificationOptions = {},
): void {
notifyConfiguredStatus(
message,
{
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
isOverlayReady: () => isVisibleOverlayContentReady(),
showOsd: (text) => showMpvOsd(text),
showOverlayNotification,
showDesktopNotification: (title, notificationOptions) =>
showDesktopNotification(title, notificationOptions),
},
options,
);
}
function showConfiguredPlaybackFeedback(
message: string,
options: ConfiguredStatusNotificationOptions = {},
): void {
showConfiguredStatusNotification(message, {
...getPlaybackFeedbackNotificationOptions(message),
...options,
delivery: 'feedback',
});
overlayNotificationsRuntime.showConfiguredStatusNotification(message, options);
}
function showSubsyncStatusNotification(message: string): void {
const syncing = message.startsWith('Subsync: syncing');
const failed = message.toLowerCase().includes('failed');
showConfiguredStatusNotification(message, {
id: 'subsync-status',
title: 'Subsync',
variant: failed ? 'error' : syncing ? 'progress' : 'info',
persistent: syncing,
desktop: !syncing,
});
overlayNotificationsRuntime.showSubsyncStatusNotification(message);
}
function showYoutubeFlowStatusNotification(message: string): void {
const progress =
message.startsWith('Downloading subtitles') ||
message.startsWith('Loading subtitles') ||
message.startsWith('Getting subtitles') ||
message === 'Opening YouTube video';
showConfiguredStatusNotification(message, {
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: progress ? 'progress' : 'info',
persistent: progress,
desktop: !progress,
});
}
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
if (!overlayLoadingOsdController) {
overlayLoadingOsdController = createOverlayLoadingOsdController({
showOsd: (message) => {
showMpvOsd(message);
},
clearOsd: () => {
sendMpvCommandRuntime(appState.mpvClient, ['show-text', '', '1']);
},
setInterval: (callback, delayMs) => {
const timer = setInterval(callback, delayMs);
timer.unref?.();
return timer;
},
clearInterval: (timer) => {
clearInterval(timer as ReturnType<typeof setInterval>);
},
});
}
return overlayLoadingOsdController;
overlayNotificationsRuntime.showYoutubeFlowStatusNotification(message);
}
function showOverlayLoadingStatusNotification(message: string): void {
void message;
getOverlayLoadingOsdController().start();
overlayNotificationsRuntime.showOverlayLoadingStatusNotification(message);
}
function dismissOverlayLoadingStatusNotification(): void {
getOverlayLoadingOsdController().stop();
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-overlay-loading-ready']);
dismissOverlayNotification('overlay-loading-status');
overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification();
}
const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({
getVisibleOverlayRequested: () => overlayManager.getVisibleOverlayVisible(),
isOverlayContentReady: () => isVisibleOverlayContentReady(),
startOverlayLoadingOsd: () => {
showOverlayLoadingStatusNotification('Overlay loading...');
},
});
const buildBroadcastRuntimeOptionsChangedMainDepsHandler =
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
broadcastRuntimeOptionsChangedRuntime,
+15 -11
View File
@@ -95,15 +95,15 @@ test('mpv startup signals start overlay loading OSD before readiness work', () =
});
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
const source = readMainSource();
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
const dismissBlock = source.match(
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n\}/,
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
assert.ok(dismissBlock);
assert.match(
dismissBlock,
/sendMpvCommandRuntime\(appState\.mpvClient, \['script-message', 'subminer-overlay-loading-ready'\]\);/,
/sendMpvCommandRuntime\(deps\.getMpvClient\(\), \[\s*'script-message',\s*'subminer-overlay-loading-ready',\s*\]\);/,
);
});
@@ -168,6 +168,7 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
test('update overlay notification action triggers install flow', () => {
const source = readMainSource();
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
assert.match(
source,
@@ -177,13 +178,16 @@ test('update overlay notification action triggers install flow', () => {
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
assert.match(source, /installWhenAvailable:\s*true/);
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/);
assert.match(source, /appState\.runtimeOptionsManager\?\.getEffectiveAnkiConnectConfig/);
assert.match(runtimeSource, /deps\.getAnkiIntegration\(\)\?\.openNoteInAnki\(noteId\)/);
assert.match(
source,
runtimeSource,
/deps\.getRuntimeOptionsManager\(\)\?\.getEffectiveAnkiConnectConfig/,
);
assert.match(
runtimeSource,
/new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/,
);
assert.match(source, /fallbackClient\.openNoteInBrowser\(noteId\)/);
assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/);
});
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
@@ -463,17 +467,17 @@ test('manual visible overlay hide dismisses loading OSD', () => {
});
test('configured overlay notifications require visible ready overlay window', () => {
const source = readMainSource();
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
const readinessBlock = source.match(
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n\}/,
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
const statusBlock = source.match(
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
assert.ok(readinessBlock);
assert.ok(statusBlock);
assert.match(readinessBlock, /overlayManager\.getVisibleOverlayVisible\(\)/);
assert.match(readinessBlock, /deps\.getVisibleOverlayVisible\(\)/);
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
@@ -0,0 +1,254 @@
import type { BrowserWindow } from 'electron';
import type {
NotificationType,
OverlayNotificationEventPayload,
OverlayNotificationPayload,
ResolvedConfig,
} from '../../types';
import type { AnkiIntegration } from '../../anki-integration';
import type { RuntimeOptionsManager } from '../../runtime-options';
import { AnkiConnectClient } from '../../anki-connect';
import { DEFAULT_CONFIG } from '../../config';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { showDesktopNotification } from '../../core/utils';
import {
isOverlayWindowContentReady,
sendMpvCommandRuntime,
type MpvIpcClient,
} from '../../core/services';
import { createOverlayLoadingOsdController } from './overlay-loading-osd';
import { createMaybeStartOverlayLoadingOsdHandler } from './overlay-loading-osd-start';
import { withConfiguredOverlayNotificationPosition } from './overlay-notification-position';
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
import {
getPlaybackFeedbackNotificationOptions,
notifyConfiguredStatus,
type ConfiguredStatusNotificationOptions,
} from './configured-status-notification';
import { resolveOverlayReadinessNotificationType } from './notification-routing';
export interface OverlayNotificationsRuntimeDeps {
getResolvedConfig: () => ResolvedConfig;
getMainOverlayWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
showMpvOsd: (message: string) => void;
getMpvClient: () => MpvIpcClient | null;
getAnkiIntegration: () => AnkiIntegration | null;
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
}
export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRuntimeDeps): {
isVisibleOverlayContentReady: () => boolean;
getConfiguredStatusNotificationType: () => NotificationType;
flushQueuedOverlayNotifications: () => void;
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification: (id: string) => void;
openAnkiCardFromNotification: (noteId: number) => Promise<void>;
toggleNotificationHistoryPanel: () => void;
showConfiguredStatusNotification: (
message: string,
options?: ConfiguredStatusNotificationOptions,
) => void;
showConfiguredPlaybackFeedback: (
message: string,
options?: ConfiguredStatusNotificationOptions,
) => void;
showSubsyncStatusNotification: (message: string) => void;
showYoutubeFlowStatusNotification: (message: string) => void;
showOverlayLoadingStatusNotification: (message: string) => void;
dismissOverlayLoadingStatusNotification: () => void;
maybeStartOverlayLoadingOsd: (mediaPath?: string | null) => void;
} {
function isVisibleOverlayContentReady(): boolean {
const overlayWindow = deps.getMainOverlayWindow();
return Boolean(
deps.getVisibleOverlayVisible() &&
overlayWindow &&
isOverlayWindowReadyForNotification(overlayWindow),
);
}
function getConfiguredStatusNotificationType(): NotificationType {
const configuredType = deps.getResolvedConfig().ankiConnect.behavior.notificationType;
return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady());
}
function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean {
if (window.isDestroyed() || !isOverlayWindowContentReady(window)) {
return false;
}
if (window.webContents.isLoading()) {
return false;
}
const currentURL = window.webContents.getURL();
return currentURL !== '' && currentURL !== 'about:blank';
}
const overlayNotificationDelivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => isVisibleOverlayContentReady(),
send: (payload) => {
deps.broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
},
scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs),
clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
});
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null =
null;
function flushQueuedOverlayNotifications(): void {
overlayNotificationDelivery.flush();
}
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
overlayNotificationDelivery.send(payload);
}
function showOverlayNotification(payload: OverlayNotificationPayload): void {
sendOverlayNotificationEvent(
withConfiguredOverlayNotificationPosition(payload, deps.getResolvedConfig()),
);
}
function dismissOverlayNotification(id: string): void {
sendOverlayNotificationEvent({ id, dismiss: true });
}
async function openAnkiCardFromNotification(noteId: number): Promise<void> {
const activeIntegrationOpen = deps.getAnkiIntegration()?.openNoteInAnki(noteId);
if (activeIntegrationOpen) {
await activeIntegrationOpen;
return;
}
const resolvedConfig = deps.getResolvedConfig();
const effectiveAnkiConfig =
deps.getRuntimeOptionsManager()?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
resolvedConfig.ankiConnect;
const fallbackClient = new AnkiConnectClient(
effectiveAnkiConfig.url || DEFAULT_CONFIG.ankiConnect.url,
);
await fallbackClient.openNoteInBrowser(noteId);
}
function toggleNotificationHistoryPanel(): void {
deps.broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
}
function showConfiguredStatusNotification(
message: string,
options: ConfiguredStatusNotificationOptions = {},
): void {
notifyConfiguredStatus(
message,
{
getNotificationType: () => deps.getResolvedConfig().ankiConnect.behavior.notificationType,
isOverlayReady: () => isVisibleOverlayContentReady(),
showOsd: (text) => deps.showMpvOsd(text),
showOverlayNotification,
showDesktopNotification: (title, notificationOptions) =>
showDesktopNotification(title, notificationOptions),
},
options,
);
}
function showConfiguredPlaybackFeedback(
message: string,
options: ConfiguredStatusNotificationOptions = {},
): void {
showConfiguredStatusNotification(message, {
...getPlaybackFeedbackNotificationOptions(message),
...options,
delivery: 'feedback',
});
}
function showSubsyncStatusNotification(message: string): void {
const syncing = message.startsWith('Subsync: syncing');
const failed = message.toLowerCase().includes('failed');
showConfiguredStatusNotification(message, {
id: 'subsync-status',
title: 'Subsync',
variant: failed ? 'error' : syncing ? 'progress' : 'info',
persistent: syncing,
desktop: !syncing,
});
}
function showYoutubeFlowStatusNotification(message: string): void {
const progress =
message.startsWith('Downloading subtitles') ||
message.startsWith('Loading subtitles') ||
message.startsWith('Getting subtitles') ||
message === 'Opening YouTube video';
showConfiguredStatusNotification(message, {
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: progress ? 'progress' : 'info',
persistent: progress,
desktop: !progress,
});
}
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
if (!overlayLoadingOsdController) {
overlayLoadingOsdController = createOverlayLoadingOsdController({
showOsd: (message) => {
deps.showMpvOsd(message);
},
clearOsd: () => {
sendMpvCommandRuntime(deps.getMpvClient(), ['show-text', '', '1']);
},
setInterval: (callback, delayMs) => {
const timer = setInterval(callback, delayMs);
timer.unref?.();
return timer;
},
clearInterval: (timer) => {
clearInterval(timer as ReturnType<typeof setInterval>);
},
});
}
return overlayLoadingOsdController;
}
function showOverlayLoadingStatusNotification(message: string): void {
void message;
getOverlayLoadingOsdController().start();
}
function dismissOverlayLoadingStatusNotification(): void {
getOverlayLoadingOsdController().stop();
sendMpvCommandRuntime(deps.getMpvClient(), [
'script-message',
'subminer-overlay-loading-ready',
]);
dismissOverlayNotification('overlay-loading-status');
}
const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({
getVisibleOverlayRequested: () => deps.getVisibleOverlayVisible(),
isOverlayContentReady: () => isVisibleOverlayContentReady(),
startOverlayLoadingOsd: () => {
showOverlayLoadingStatusNotification('Overlay loading...');
},
});
return {
isVisibleOverlayContentReady,
getConfiguredStatusNotificationType,
flushQueuedOverlayNotifications,
showOverlayNotification,
dismissOverlayNotification,
openAnkiCardFromNotification,
toggleNotificationHistoryPanel,
showConfiguredStatusNotification,
showConfiguredPlaybackFeedback,
showSubsyncStatusNotification,
showYoutubeFlowStatusNotification,
showOverlayLoadingStatusNotification,
dismissOverlayLoadingStatusNotification,
maybeStartOverlayLoadingOsd,
};
}