From 1fc83a842df2ee01e388905cff3238f6a518388b Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 11 Jun 2026 23:11:56 -0700 Subject: [PATCH] refactor(main): extract overlay notifications runtime from main.ts --- src/main.ts | 186 ++----------- src/main/main-wiring.test.ts | 26 +- .../runtime/overlay-notifications-runtime.ts | 254 ++++++++++++++++++ 3 files changed, 298 insertions(+), 168 deletions(-) create mode 100644 src/main/runtime/overlay-notifications-runtime.ts diff --git a/src/main.ts b/src/main.ts index b1d191fa..aca2de83 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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), +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 | 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 { - 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 { - 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); - }, - }); - } - 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, diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 647c1cee..5585e126 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -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 \{(?[\s\S]*?)\n\}/, + /function dismissOverlayLoadingStatusNotification\(\): void \{(?[\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 \{(?[\s\S]*?)\n\}/, + /function isVisibleOverlayContentReady\(\): boolean \{(?[\s\S]*?)\n \}/, )?.groups?.body; const statusBlock = source.match( - /function showConfiguredStatusNotification\([\s\S]*?\): void \{(?[\s\S]*?)\n\}/, + /function showConfiguredStatusNotification\([\s\S]*?\): void \{(?[\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\(\)/); diff --git a/src/main/runtime/overlay-notifications-runtime.ts b/src/main/runtime/overlay-notifications-runtime.ts new file mode 100644 index 00000000..95ff51a7 --- /dev/null +++ b/src/main/runtime/overlay-notifications-runtime.ts @@ -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; + 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), + }); + let overlayLoadingOsdController: ReturnType | 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 { + 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 { + 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); + }, + }); + } + 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, + }; +}