From 2398f5c030ad3f637ae80d2499c3fc8e7c9ecc8e Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 15 Jul 2026 03:22:03 -0700 Subject: [PATCH] refactor(main): eliminate local pass-through wrappers in main.ts (#168) --- docs/architecture/README.md | 2 + launcher/sync/engine-merge.test.ts | 12 +- .../definitions/options-integrations.ts | 3 +- src/core/services/anki-jimaku-ipc.ts | 4 +- src/core/services/stats-sync/cli-args.test.ts | 5 +- src/core/services/stats-sync/cli-args.ts | 9 +- src/core/services/stats-sync/db-path.ts | 4 +- src/core/services/stats-sync/merge-catalog.ts | 6 +- src/core/services/stats-sync/merge-rollups.ts | 4 +- .../services/stats-sync/merge-sessions.ts | 3 +- src/core/services/stats-sync/merge.ts | 6 +- src/main.ts | 1056 +++++++---------- src/main/appimage-mount-keepalive.test.ts | 208 ++-- src/main/main-runtime-direct-wiring.test.ts | 99 ++ src/main/main-wiring.test.ts | 82 +- src/main/runtime/sync-launcher-client.test.ts | 5 +- src/shared/sync/sync-events.ts | 3 +- src/tsukihime/lang.ts | 4 +- src/tsukihime/utils.ts | 5 +- 19 files changed, 706 insertions(+), 814 deletions(-) create mode 100644 src/main/main-runtime-direct-wiring.test.ts diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 5b4d6f5e..a0c4858a 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -31,6 +31,8 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into - `src/config/` owns config definitions, defaults, loading, and resolution. - `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel. - `src/main/runtime/composers/` owns larger domain compositions. +- `src/main.ts` call sites invoke configured runtime handlers and runtime-object methods directly; + do not add local pass-through wrappers around them. ## Architecture Intent diff --git a/launcher/sync/engine-merge.test.ts b/launcher/sync/engine-merge.test.ts index 906f96a6..63010380 100644 --- a/launcher/sync/engine-merge.test.ts +++ b/launcher/sync/engine-merge.test.ts @@ -632,8 +632,10 @@ test('adopted word frequency excludes active-session counts that merge later', ( // Only the ended session's count is adopted; the active session's slice // is re-added when that session finalizes and syncs. assert.equal( - queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`) - ?.frequency, + queryOne<{ frequency: number }>( + localPath, + `SELECT frequency FROM imm_words WHERE word = '食べた'`, + )?.frequency, 1, ); @@ -650,8 +652,10 @@ test('adopted word frequency excludes active-session counts that merge later', ( assert.equal(second.sessionsAlreadyPresent, 1); // 1 (ended session) + 4 (finalized session), not 5 + 4 = 9. assert.equal( - queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`) - ?.frequency, + queryOne<{ frequency: number }>( + localPath, + `SELECT frequency FROM imm_words WHERE word = '食べた'`, + )?.frequency, 5, ); } finally { diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index fbcbb91a..6bd493b8 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -404,8 +404,7 @@ export function buildIntegrationConfigOptionRegistry( path: 'tsukihime.apiBaseUrl', kind: 'string', defaultValue: defaultConfig.tsukihime.apiBaseUrl, - description: - 'Base URL of the TsukiHime API (Animetosho successor). No API key required.', + description: 'Base URL of the TsukiHime API (Animetosho successor). No API key required.', }, { path: 'tsukihime.maxSearchResults', diff --git a/src/core/services/anki-jimaku-ipc.ts b/src/core/services/anki-jimaku-ipc.ts index 22fd9b58..d4270234 100644 --- a/src/core/services/anki-jimaku-ipc.ts +++ b/src/core/services/anki-jimaku-ipc.ts @@ -302,9 +302,7 @@ export function registerAnkiJimakuIpcHandlers( await deps.onDownloadedSecondarySubtitle(result.path); } } else { - logger.error( - `[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`, - ); + logger.error(`[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`); } return result; diff --git a/src/core/services/stats-sync/cli-args.test.ts b/src/core/services/stats-sync/cli-args.test.ts index 0ca729de..aa5c8cc1 100644 --- a/src/core/services/stats-sync/cli-args.test.ts +++ b/src/core/services/stats-sync/cli-args.test.ts @@ -48,7 +48,10 @@ test('parseSyncCliTokens handles the temp-dir protocol modes', () => { } assert.equal(parseSyncCliTokens(['sync', '--make-temp', 'host']).kind, 'error'); - assert.equal(parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, 'error'); + assert.equal( + parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, + 'error', + ); }); test('parseSyncCliTokens owns the sync CLI validation rules', () => { diff --git a/src/core/services/stats-sync/cli-args.ts b/src/core/services/stats-sync/cli-args.ts index f4cdecb4..2f865025 100644 --- a/src/core/services/stats-sync/cli-args.ts +++ b/src/core/services/stats-sync/cli-args.ts @@ -58,7 +58,9 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli { for (let i = 0; i < rest.length; i += 1) { const token = rest[i]!; - const assignValue = valueFlags.get(token.includes('=') ? token.slice(0, token.indexOf('=')) : token); + const assignValue = valueFlags.get( + token.includes('=') ? token.slice(0, token.indexOf('=')) : token, + ); if (assignValue) { if (token.includes('=')) { assignValue(token.slice(token.indexOf('=') + 1)); @@ -109,7 +111,10 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli { Boolean(removeTemp), ].filter(Boolean).length; if (modes === 0) { - return { kind: 'error', message: 'Sync requires a host, --snapshot , or --merge .' }; + return { + kind: 'error', + message: 'Sync requires a host, --snapshot , or --merge .', + }; } if (modes > 1) { return { diff --git a/src/core/services/stats-sync/db-path.ts b/src/core/services/stats-sync/db-path.ts index bd194ec1..eaac9030 100644 --- a/src/core/services/stats-sync/db-path.ts +++ b/src/core/services/stats-sync/db-path.ts @@ -34,9 +34,7 @@ export function resolveImmersionDbPath(): string { // no config or unreadable config → default location } if (configured) { - return configured.startsWith('~') - ? path.join(os.homedir(), configured.slice(1)) - : configured; + return configured.startsWith('~') ? path.join(os.homedir(), configured.slice(1)) : configured; } return path.join(getDefaultConfigDir(), 'immersion.sqlite'); } diff --git a/src/core/services/stats-sync/merge-catalog.ts b/src/core/services/stats-sync/merge-catalog.ts index 2c3d08cf..4ee58b82 100644 --- a/src/core/services/stats-sync/merge-catalog.ts +++ b/src/core/services/stats-sync/merge-catalog.ts @@ -251,11 +251,7 @@ export function mergeMediaMetadata( } } -export function mergeExcludedWords( - local: SyncDb, - remote: SyncDb, - summary: SyncMergeSummary, -): void { +export function mergeExcludedWords(local: SyncDb, remote: SyncDb, summary: SyncMergeSummary): void { if ( !tableExists(remote, 'imm_stats_excluded_words') || !tableExists(local, 'imm_stats_excluded_words') diff --git a/src/core/services/stats-sync/merge-rollups.ts b/src/core/services/stats-sync/merge-rollups.ts index 9204660a..ec36906f 100644 --- a/src/core/services/stats-sync/merge-rollups.ts +++ b/src/core/services/stats-sync/merge-rollups.ts @@ -147,7 +147,9 @@ export function refreshRollupsForNewSessions( } const stampMs = nowDbTimestamp(); - const deleteDaily = local.query('DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?'); + const deleteDaily = local.query( + 'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?', + ); const deleteMonthly = local.query( 'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?', ); diff --git a/src/core/services/stats-sync/merge-sessions.ts b/src/core/services/stats-sync/merge-sessions.ts index ea862b07..45ff7acc 100644 --- a/src/core/services/stats-sync/merge-sessions.ts +++ b/src/core/services/stats-sync/merge-sessions.ts @@ -319,7 +319,8 @@ function applyMergedSessionLifetime( .get(videoId, sessionId), ); const isFirstSessionForVideoRun = !mediaLifetime && !hasOtherSessionForVideo; - const isFirstCompletedSessionForVideoRun = watched > 0 && Number(mediaLifetime?.completed ?? 0) <= 0; + const isFirstCompletedSessionForVideoRun = + watched > 0 && Number(mediaLifetime?.completed ?? 0) <= 0; const hasOtherSessionOnDay = Boolean( local diff --git a/src/core/services/stats-sync/merge.ts b/src/core/services/stats-sync/merge.ts index 0876625f..1f4d2bfb 100644 --- a/src/core/services/stats-sync/merge.ts +++ b/src/core/services/stats-sync/merge.ts @@ -8,11 +8,7 @@ import { } from './merge-catalog'; import { mergeSessions } from './merge-sessions'; import { copyRemoteOnlyRollups, refreshRollupsForNewSessions } from './merge-rollups'; -import { - assertMergeableSchema, - createEmptyMergeSummary, - type SyncMergeSummary, -} from './shared'; +import { assertMergeableSchema, createEmptyMergeSummary, type SyncMergeSummary } from './shared'; import { openLibsqlSyncDb, type SyncDb } from './libsql-driver'; export type { SyncMergeSummary } from './shared'; diff --git a/src/main.ts b/src/main.ts index fa05f1f1..c75fef15 100644 --- a/src/main.ts +++ b/src/main.ts @@ -86,14 +86,11 @@ import type { KikuFieldGroupingChoice, MpvSubtitleRenderMetrics, ResolvedConfig, - RuntimeOptionState, SessionActionDispatchRequest, SecondarySubMode, SubtitleData, SubtitleMiningContext, SubtitlePosition, - OverlayNotificationPayload, - NotificationType, WindowGeometry, } from './types'; import { OPEN_ANKI_CARD_ACTION_ID } from './types'; @@ -510,7 +507,6 @@ import { UPDATE_AVAILABLE_NOTIFICATION_ID, } from './main/runtime/update/update-notifications'; import { createOverlayNotificationsRuntime } from './main/runtime/overlay-notifications-runtime'; -import { type ConfiguredStatusNotificationOptions } from './main/runtime/configured-status-notification'; import { runUpdateCliCommand, writeUpdateCliCommandResponse, @@ -697,15 +693,9 @@ const applyJellyfinMpvDefaultsHandler = createApplyJellyfinMpvDefaultsHandler( applyJellyfinMpvDefaultsMainDeps, ); -function applyJellyfinMpvDefaults( - client: Parameters[0], -): void { - applyJellyfinMpvDefaultsHandler(client); -} - const isDev = process.argv.includes('--dev') || process.argv.includes('--debug'); const texthookerService = new Texthooker(() => { - const config = getResolvedConfig(); + const config = configService.getConfig(); const characterDictionaryEnabled = config.subtitleStyle.nameMatchEnabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(); const knownWordColoringEnabled = getRuntimeBooleanOption( @@ -750,10 +740,6 @@ const buildGetDefaultSocketPathMainDepsHandler = createBuildGetDefaultSocketPath const getDefaultSocketPathMainDeps = buildGetDefaultSocketPathMainDepsHandler(); const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler(getDefaultSocketPathMainDeps); -function getDefaultSocketPath(): string { - return getDefaultSocketPathHandler(); -} - type BootServices = MainBootServicesResult< ConfigService, ReturnType, @@ -785,7 +771,7 @@ const bootServices = createMainBootServices({ defaultMpvLogFile: DEFAULT_MPV_LOG_FILE, envMpvLog: process.env.SUBMINER_MPV_LOG, defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, - getDefaultSocketPath: () => getDefaultSocketPath(), + getDefaultSocketPath: () => getDefaultSocketPathHandler(), resolveConfigDir, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, @@ -854,7 +840,7 @@ const bootServices = createMainBootServices({ getMainWindow: () => overlayManager.getMainWindow(), getModalWindow: () => overlayManager.getModalWindow(), createModalWindow: () => createModalWindow(), - getModalGeometry: () => getCurrentOverlayGeometry(), + getModalGeometry: () => overlayGeometryRuntime.getCurrentOverlayGeometry(), setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry), }); return createOverlayModalRuntimeService(buildHandler(), { @@ -965,7 +951,7 @@ const statsPreloadPath = path.join(__dirname, 'preload-stats.js'); const statsServerRuntime = createStatsServerRuntime({ userDataPath: USER_DATA_PATH, statsDistPath, - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), getImmersionTracker: () => appState.immersionTracker, setAppStateStatsServer: (server) => { appState.statsServer = server; @@ -1025,9 +1011,6 @@ process.on('SIGTERM', () => { requestAppQuit(); }); -const startBackgroundWarmupsIfAllowed = (): void => { - startBackgroundWarmups(); -}; const youtubeFlowRuntime = createYoutubeFlowRuntime({ probeYoutubeTracks: (url: string) => probeYoutubeTracks(url), acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input), @@ -1106,7 +1089,7 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({ appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || ''; const trackerFocused = tracker?.isTargetWindowFocused() ?? false; if (tracker && tracker.isTracking() && trackerGeometry && trackerFocused && mediaPath) { - if (!geometryMatches(stableGeometry, trackerGeometry)) { + if (!overlayGeometryRuntime.geometryMatches(stableGeometry, trackerGeometry)) { stableGeometry = trackerGeometry; stableSinceMs = Date.now(); } else if (Date.now() - stableSinceMs >= 200) { @@ -1129,7 +1112,10 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({ const trackerGeometry = tracker?.getGeometry() ?? null; if ( trackerGeometry && - geometryMatches(overlayGeometryRuntime.getLastOverlayWindowGeometry(), trackerGeometry) + overlayGeometryRuntime.geometryMatches( + overlayGeometryRuntime.getLastOverlayWindowGeometry(), + trackerGeometry, + ) ) { return; } @@ -1150,7 +1136,7 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({ mainWindow.webContents.focus(); } }, - showMpvOsd: (text: string) => showYoutubeFlowStatusNotification(text), + showMpvOsd: (text: string) => overlayNotificationsRuntime.showYoutubeFlowStatusNotification(text), reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message), notifyPrimarySubtitleLoaded: () => youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(), @@ -1184,16 +1170,19 @@ const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({ }); const youtubeMediaCache = createYoutubeMediaCacheService({ onDownloadStarted: (event) => { - showConfiguredStatusNotification('YouTube media cache is downloading.', { - id: 'youtube-media-cache-status', - title: 'YouTube media cache', - variant: 'progress', - persistent: true, - }); + overlayNotificationsRuntime.showConfiguredStatusNotification( + 'YouTube media cache is downloading.', + { + id: 'youtube-media-cache-status', + title: 'YouTube media cache', + variant: 'progress', + persistent: true, + }, + ); logger.info(`YouTube media cache download notification shown for ${event.url}`); }, onReady: (event) => { - showConfiguredStatusNotification('YouTube media cache ready.', { + overlayNotificationsRuntime.showConfiguredStatusNotification('YouTube media cache ready.', { id: 'youtube-media-cache-status', title: 'YouTube media cache', variant: 'success', @@ -1210,7 +1199,7 @@ const youtubeMediaCache = createYoutubeMediaCacheService({ }); }, onFailed: (event) => { - showConfiguredStatusNotification('YouTube media cache failed.', { + overlayNotificationsRuntime.showConfiguredStatusNotification('YouTube media cache failed.', { id: 'youtube-media-cache-status', title: 'YouTube media cache', variant: 'error', @@ -1230,7 +1219,7 @@ const youtubeMediaCache = createYoutubeMediaCacheService({ logWarn: (message) => logger.warn(message), }); const youtubeMediaCachePlaybackRuntime = createYoutubeMediaCachePlaybackRuntime({ - getMediaCacheConfig: () => getResolvedConfig().youtube.mediaCache, + getMediaCacheConfig: () => configService.getConfig().youtube.mediaCache, requestMpvProperty: async (name) => { const client = appState.mpvClient; if (!client) return null; @@ -1261,13 +1250,15 @@ function schedulePostWarmAutoplaySubtitlePrime(signal: AutoplayReadySignal): voi if (currentMediaPath !== mediaPath || !overlayManager.getVisibleOverlayVisible()) { return; } - void primeCurrentSubtitleForAutoplay(mediaPath).catch((error) => { - logger.debug( - `[autoplay-subtitle-prime] failed to prime current subtitle after warm readiness: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - }); + void autoplaySubtitlePrimingRuntime + .primeCurrentSubtitleForAutoplay(mediaPath) + .catch((error) => { + logger.debug( + `[autoplay-subtitle-prime] failed to prime current subtitle after warm readiness: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); }, delayMs); timer.unref?.(); } @@ -1298,7 +1289,7 @@ const autoplayReadyGate = createAutoplayReadyGate({ stealAppFocus: () => app.focus({ steal: true }), warn: (message, details) => logger.warn(message, details), }); - broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest); + overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest); }, onAutoplayReadyReleased: (signal) => { schedulePostWarmAutoplaySubtitlePrime(signal); @@ -1322,8 +1313,8 @@ const autoplayReadyGate = createAutoplayReadyGate({ const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelectionRuntime({ getCurrentMediaPath: () => appState.currentMediaPath, getMpvClient: () => appState.mpvClient, - getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, - getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, + getPrimarySubtitleLanguages: () => configService.getConfig().youtube.primarySubLanguages, + getSecondarySubtitleLanguages: () => configService.getConfig().secondarySub.secondarySubLanguages, sendMpvCommand: (command) => { sendMpvCommandRuntime(appState.mpvClient, command); }, @@ -1359,7 +1350,7 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ }, resolveYoutubePlaybackUrl: (url, format) => resolveYoutubePlaybackUrl(url, format), launchWindowsMpv: (playbackUrl, args) => { - const config = getResolvedConfig(); + const config = configService.getConfig(); setLogFileToggles(config.logging.files); const mpvLogPath = isLogFileEnabled('mpv') ? resolveDefaultLogFilePath('mpv') : ''; if (mpvLogPath) { @@ -1388,9 +1379,10 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs), prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), startYoutubeMediaCache: (url) => { + const mediaCacheConfig = configService.getConfig().youtube.mediaCache; youtubeMediaCache.start(url, { - mode: getResolvedConfig().youtube.mediaCache.mode, - maxHeight: getResolvedConfig().youtube.mediaCache.maxHeight, + mode: mediaCacheConfig.mode, + maxHeight: mediaCacheConfig.maxHeight, }); }, runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request), @@ -1401,7 +1393,7 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ }); function getMpvPluginRuntimeConfig() { - const config = getResolvedConfig(); + const config = configService.getConfig(); return { socketPath: appState.mpvSocketPath, binaryPath: config.mpv.subminerBinaryPath, @@ -1444,14 +1436,14 @@ const firstRunSetupService = createFirstRunSetupService({ return dictionaries.length; }, isExternalYomitanConfigured: () => - getResolvedConfig().yomitan.externalProfilePath.trim().length > 0, + configService.getConfig().yomitan.externalProfilePath.trim().length > 0, detectPluginInstalled: () => { const candidates = detectInstalledFirstRunPluginCandidates({ platform: process.platform, homeDir: os.homedir(), xdgConfigHome: process.env.XDG_CONFIG_HOME, appDataDir: app.getPath('appData'), - mpvExecutablePath: getResolvedConfig().mpv.executablePath, + mpvExecutablePath: configService.getConfig().mpv.executablePath, }); if (candidates.length > 0) { return true; @@ -1469,7 +1461,7 @@ const firstRunSetupService = createFirstRunSetupService({ homeDir: os.homedir(), xdgConfigHome: process.env.XDG_CONFIG_HOME, appDataDir: app.getPath('appData'), - mpvExecutablePath: getResolvedConfig().mpv.executablePath, + mpvExecutablePath: configService.getConfig().mpv.executablePath, }), removeLegacyMpvPlugins: (candidates) => removeLegacyMpvPluginCandidates({ @@ -1530,7 +1522,7 @@ const firstRunSetupService = createFirstRunSetupService({ onStateChanged: (state) => { appState.firstRunSetupCompleted = state.status === 'completed'; if (appTray) { - ensureTray(); + ensureTrayHandler(); } }, }); @@ -1538,7 +1530,7 @@ const discordPresenceSessionStartedAtMs = Date.now(); let discordPresenceMediaDurationSec: number | null = null; const discordPresenceRuntime = createDiscordPresenceRuntime({ getDiscordPresenceService: () => appState.discordPresenceService, - isDiscordPresenceEnabled: () => getResolvedConfig().discordPresence.enabled === true, + isDiscordPresenceEnabled: () => configService.getConfig().discordPresence.enabled === true, getMpvClient: () => appState.mpvClient, getCurrentMediaTitle: () => appState.currentMediaTitle, getCurrentMediaPath: () => appState.currentMediaPath, @@ -1553,13 +1545,13 @@ const discordPresenceRuntime = createDiscordPresenceRuntime({ }); async function initializeDiscordPresenceService(): Promise { - if (getResolvedConfig().discordPresence.enabled !== true) { + if (configService.getConfig().discordPresence.enabled !== true) { appState.discordPresenceService = null; return; } appState.discordPresenceService = createDiscordPresenceService({ - config: getResolvedConfig().discordPresence, + config: configService.getConfig().discordPresence, createClient: () => createDiscordRpcClient(DISCORD_PRESENCE_APP_ID), logDebug: (message, meta) => logger.debug(message, meta), }); @@ -1593,18 +1585,14 @@ const restoreOverlayMpvSubtitles = createRestoreOverlayMpvSubtitlesHandler({ appState.overlayMpvSubVisibilityRevision = revision; }, isMpvConnected: () => Boolean(appState.mpvClient?.connected), - shouldKeepSuppressedFromVisibleOverlayBinding: () => shouldSuppressMpvSubtitlesForOverlay(), + shouldKeepSuppressedFromVisibleOverlayBinding: () => overlayManager.getVisibleOverlayVisible(), setMpvSubVisibility: (visible) => { setMpvSubVisibilityRuntime(appState.mpvClient, visible); }, }); -function shouldSuppressMpvSubtitlesForOverlay(): boolean { - return overlayManager.getVisibleOverlayVisible(); -} - function syncOverlayMpvSubtitleSuppression(): void { - if (shouldSuppressMpvSubtitlesForOverlay()) { + if (overlayManager.getVisibleOverlayVisible()) { void ensureOverlayMpvSubtitlesHidden(); return; } @@ -1613,7 +1601,7 @@ function syncOverlayMpvSubtitleSuppression(): void { } const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({ - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH, getTracker: () => appState.immersionTracker, getMpvClient: () => appState.mpvClient, @@ -1644,7 +1632,7 @@ const buildAnilistStateRuntimeMainDepsHandler = createBuildAnilistStateRuntimeMa }, }); const buildConfigDerivedRuntimeMainDepsHandler = createBuildConfigDerivedRuntimeMainDepsHandler({ - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), getRuntimeOptionsManager: () => appState.runtimeOptionsManager, defaultJimakuLanguagePreference: DEFAULT_CONFIG.jimaku.languagePreference, defaultJimakuMaxEntryResults: DEFAULT_CONFIG.jimaku.maxEntryResults, @@ -1652,12 +1640,12 @@ const buildConfigDerivedRuntimeMainDepsHandler = createBuildConfigDerivedRuntime }); const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMainDepsHandler({ getMpvClient: () => appState.mpvClient, - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), getSubsyncInProgress: () => appState.subsyncInProgress, setSubsyncInProgress: (inProgress) => { appState.subsyncInProgress = inProgress; }, - showMpvOsd: (text) => showSubsyncStatusNotification(text), + showMpvOsd: (text) => overlayNotificationsRuntime.showSubsyncStatusNotification(text), openManualPicker: (payload) => { openOverlayHostedModalWithOsd( (deps) => openSubsyncManualModalRuntime(deps, payload), @@ -1679,13 +1667,14 @@ const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntim const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler()); const currentMediaTokenizationGate = createCurrentMediaTokenizationGate(); const startupOsdSequencer = createStartupOsdSequencer({ - getNotificationType: () => getConfiguredStatusNotificationType(), + getNotificationType: () => overlayNotificationsRuntime.getConfiguredStatusNotificationType(), showOsd: (message) => showMpvOsd(message), - showOverlayNotification, + showOverlayNotification: (payload) => + overlayNotificationsRuntime.showOverlayNotification(payload), showDesktopNotification: (title, options) => showDesktopNotification(title, options), }); const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({ - getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, + getPrimarySubtitleLanguages: () => configService.getConfig().youtube.primarySubLanguages, notifyFailure: (message) => reportYoutubeSubtitleFailure(message), schedule: (fn, delayMs) => setTimeout(fn, delayMs), clearSchedule: clearYoutubePrimarySubtitleNotificationTimer, @@ -1739,7 +1728,8 @@ async function getCurrentYoutubeMediaCacheSourceUrl(): Promise { function shouldRequireYoutubeMediaCacheForCurrentPlayback(): boolean { return ( - getResolvedConfig().youtube.mediaCache.mode === 'background' && isYoutubePlaybackActiveNow() + configService.getConfig().youtube.mediaCache.mode === 'background' && + isYoutubePlaybackActiveNow() ); } @@ -1747,7 +1737,7 @@ async function getCachedYoutubeMediaPathForCurrentPlayback( currentVideoPath: string, _kind: 'audio' | 'video', ): Promise { - if (getResolvedConfig().youtube.mediaCache.mode !== 'background') { + if (configService.getConfig().youtube.mediaCache.mode !== 'background') { return null; } const cacheSourceUrl = isYoutubeMediaPath(currentVideoPath) @@ -1765,12 +1755,12 @@ async function getCachedYoutubeMediaPathForCurrentPlayback( } function reportYoutubeSubtitleFailure(message: string): void { - const type = getConfiguredStatusNotificationType(); + const type = overlayNotificationsRuntime.getConfiguredStatusNotificationType(); if (type === 'none') { return; } if (type === 'overlay' || type === 'both') { - showOverlayNotification({ + overlayNotificationsRuntime.showOverlayNotification({ title: 'SubMiner', body: message, variant: 'warning', @@ -1790,16 +1780,19 @@ function reportYoutubeSubtitleFailure(message: string): void { async function openYoutubeTrackPickerFromPlayback(): Promise { if (youtubeFlowRuntime.hasActiveSession()) { - showConfiguredStatusNotification('YouTube subtitle flow already in progress.', { - title: 'YouTube subtitles', - variant: 'warning', - }); + overlayNotificationsRuntime.showConfiguredStatusNotification( + 'YouTube subtitle flow already in progress.', + { + title: 'YouTube subtitles', + variant: 'warning', + }, + ); return; } const currentMediaPath = appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || ''; if (!isYoutubePlaybackActiveNow() || !currentMediaPath) { - showConfiguredStatusNotification( + overlayNotificationsRuntime.showConfiguredStatusNotification( 'YouTube subtitle picker is only available during YouTube playback.', { title: 'YouTube subtitles', @@ -1824,18 +1817,16 @@ function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData { } function emitSubtitlePayload(payload: SubtitleData): void { const timedPayload = withCurrentSubtitleTiming(payload); + const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; + const frequencyOptions = { + enabled: frequencyDictionary.enabled, + topX: frequencyDictionary.topX, + mode: frequencyDictionary.mode, + }; appState.currentSubtitleData = timedPayload; - broadcastToOverlayWindows('subtitle:set', timedPayload); - subtitleWsService.broadcast(timedPayload, { - enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, - topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, - mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, - }); - annotationSubtitleWsService.broadcast(timedPayload, { - enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, - topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, - mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, - }); + overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload); + subtitleWsService.broadcast(timedPayload, frequencyOptions); + annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions); autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true }); subtitlePrefetchService?.resume(); } @@ -1935,7 +1926,7 @@ const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({ getLastObservedTimePos: () => lastObservedTimePos, getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), emitSecondarySubtitle: (text) => { - broadcastToOverlayWindows('secondary-subtitle:set', text); + overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text); }, initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) => subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey), @@ -1945,26 +1936,6 @@ const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({ }, }); -function primeCurrentSubtitleForVisibleOverlay(): Promise { - return autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay(); -} - -function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise { - return autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForAutoplay(mediaPath); -} - -function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { - autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); -} - -function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { - autoplaySubtitlePrimingRuntime.scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(); -} - -function scheduleSubtitlePrefetchRefresh(delayMs = 0): void { - autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(delayMs); -} - function cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(): void { cancelLinuxMpvFullscreenOverlayRefreshBurst?.(); cancelLinuxMpvFullscreenOverlayRefreshBurst = null; @@ -2008,7 +1979,7 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ }, }); const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({ - getFfmpegPath: () => getResolvedConfig().subsync.ffmpeg_path.trim() || 'ffmpeg', + getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg', extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) => extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track), }); @@ -2058,7 +2029,8 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( return windowTracker.isTargetWindowFocused(); }, - showMpvOsd: (text: string) => showConfiguredStatusNotification(text), + showMpvOsd: (text: string) => + overlayNotificationsRuntime.showConfiguredStatusNotification(text), openRuntimeOptionsPalette: () => { openRuntimeOptionsPalette(); }, @@ -2071,18 +2043,18 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( openTsukihime: () => { openTsukihimeOverlay(); }, - markAudioCard: () => markLastCardAsAudioCard(), + markAudioCard: () => markLastCardAsAudioCardHandler(), copySubtitleMultiple: (timeoutMs: number) => { startPendingMultiCopy(timeoutMs); }, copySubtitle: () => { - copyCurrentSubtitle(); + copyCurrentSubtitleHandler(); }, - toggleSecondarySubMode: () => handleCycleSecondarySubMode(), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - mineSentenceCard: () => mineSentenceCard(), + toggleSecondarySubMode: () => cycleSecondarySubMode(), + updateLastCardFromClipboard: () => updateLastCardFromClipboardHandler(), + triggerFieldGrouping: () => triggerFieldGroupingHandler(), + triggerSubsyncFromConfig: () => subsyncRuntime.triggerFromConfig(), + mineSentenceCard: () => mineSentenceCardHandler(), mineSentenceMultiple: (timeoutMs: number) => { startPendingMineSentenceMultiple(timeoutMs); }, @@ -2104,9 +2076,10 @@ syncOverlayShortcutsForModal = (isActive: boolean): void => { const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler( { - getNotificationType: () => getConfiguredStatusNotificationType(), + getNotificationType: () => overlayNotificationsRuntime.getConfiguredStatusNotificationType(), showMpvOsd: (message) => showMpvOsd(message), - showOverlayNotification, + showOverlayNotification: (payload) => + overlayNotificationsRuntime.showOverlayNotification(payload), showDesktopNotification: (title, options) => showDesktopNotification(title, options), }, ); @@ -2135,7 +2108,7 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp setSecondarySubMode(mode); }, broadcastToOverlayWindows: (channel, payload) => { - broadcastToOverlayWindows(channel, payload); + overlayManager.broadcastToOverlayWindows(channel, payload); }, applyAnkiRuntimeConfigPatch: (patch) => { if (appState.ankiIntegration) { @@ -2170,7 +2143,7 @@ const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler( ); const buildConfigHotReloadRuntimeMainDepsHandler = createBuildConfigHotReloadRuntimeMainDepsHandler( { - getCurrentConfig: () => getResolvedConfig(), + getCurrentConfig: () => configService.getConfig(), reloadConfigStrict: () => configService.reloadConfigStrict(), watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange), setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), @@ -2238,7 +2211,6 @@ const configSettingsRuntime = createConfigSettingsRuntime({ }); configSettingsRuntime.registerHandlers(); -const openConfigSettingsWindow = () => configSettingsRuntime.openWindow(); const openSyncUiWindowHandler = createOpenConfigSettingsWindowHandler({ getSettingsWindow: () => appState.syncUiWindow, @@ -2259,14 +2231,13 @@ const openSyncUiWindowHandler = createOpenConfigSettingsWindowHandler({ }, log: (message) => logger.error(message), }); -const openSyncUiWindow = () => openSyncUiWindowHandler(); const syncUiRuntime = createSyncUiRuntime({ ipcMain, hostsFilePath: getSyncHostsPath(USER_DATA_PATH), snapshotsDir: path.join(os.tmpdir(), 'subminer-db-snapshots'), getDbPath: () => { - const configured = getResolvedConfig().immersionTracking?.dbPath?.trim(); + const configured = configService.getConfig().immersionTracking?.dbPath?.trim(); return configured || DEFAULT_IMMERSION_DB_PATH; }, resolveLauncherCommand: () => @@ -2288,7 +2259,7 @@ const syncUiRuntime = createSyncUiRuntime({ nowMs: () => Date.now(), log: (message) => logger.info(message), notify: (payload) => - showOverlayNotification({ + overlayNotificationsRuntime.showOverlayNotification({ title: payload.title, body: payload.body, variant: payload.variant, @@ -2336,7 +2307,7 @@ const buildFrequencyDictionaryRootsHandler = createBuildFrequencyDictionaryRoots const jlptDictionaryRuntime = createJlptDictionaryRuntimeService( createBuildJlptDictionaryRuntimeMainDepsHandler({ - isJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, + isJlptEnabled: () => configService.getConfig().subtitleStyle.enableJlpt, getDictionaryRoots: () => buildDictionaryRootsHandler(), getJlptDictionarySearchPaths, setJlptLevelLookup: (lookup) => { @@ -2349,10 +2320,10 @@ const jlptDictionaryRuntime = createJlptDictionaryRuntimeService( const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService( createBuildFrequencyDictionaryRuntimeMainDepsHandler({ isFrequencyDictionaryEnabled: () => - getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + configService.getConfig().subtitleStyle.frequencyDictionary.enabled, getDictionaryRoots: () => buildFrequencyDictionaryRootsHandler(), getFrequencyDictionarySearchPaths, - getSourcePath: () => getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath, + getSourcePath: () => configService.getConfig().subtitleStyle.frequencyDictionary.sourcePath, setFrequencyRankLookup: (lookup) => { appState.frequencyRankLookup = lookup; }, @@ -2369,10 +2340,6 @@ const getFieldGroupingResolverHandler = createGetFieldGroupingResolverHandler( getFieldGroupingResolverMainDeps, ); -function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null { - return getFieldGroupingResolverHandler(); -} - const buildSetFieldGroupingResolverMainDepsHandler = createBuildSetFieldGroupingResolverMainDepsHandler({ setResolver: (resolver) => { @@ -2389,19 +2356,13 @@ const setFieldGroupingResolverHandler = createSetFieldGroupingResolverHandler( setFieldGroupingResolverMainDeps, ); -function setFieldGroupingResolver( - resolver: ((choice: KikuFieldGroupingChoice) => void) | null, -): void { - setFieldGroupingResolverHandler(resolver); -} - const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime( createBuildFieldGroupingOverlayMainDepsHandler({ getMainWindow: () => overlayManager.getMainWindow(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), - getResolver: () => getFieldGroupingResolver(), - setResolver: (resolver) => setFieldGroupingResolver(resolver), + getResolver: () => getFieldGroupingResolverHandler(), + setResolver: (resolver) => setFieldGroupingResolverHandler(resolver), getRestoreVisibleOverlayOnModalClose: () => overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), @@ -2436,7 +2397,7 @@ const mediaRuntime = createMediaRuntimeService( appState.subtitlePosition = position; }, broadcastToOverlayWindows: (channel, payload) => { - broadcastToOverlayWindows(channel, payload); + overlayManager.broadcastToOverlayWindows(channel, payload); }, getCurrentMediaTitle: () => appState.currentMediaTitle, setCurrentMediaTitle: (title) => { @@ -2452,9 +2413,9 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({ getCurrentMediaTitle: () => appState.currentMediaTitle, resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath), guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), - getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled, + getNameMatchImagesEnabled: () => configService.getConfig().subtitleStyle.nameMatchImagesEnabled, getCollapsibleSectionOpenState: (section) => - getResolvedConfig().anilist.characterDictionary.collapsibleSections[section], + configService.getConfig().anilist.characterDictionary.collapsibleSections[section], tokenizeJapaneseName: async (text) => (await appState.mecabTokenizer?.tokenize(text)) ?? null, getJapaneseNameTokenizerAvailable: () => { const status = appState.mecabTokenizer?.getStatus(); @@ -2468,10 +2429,10 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({ userDataPath: USER_DATA_PATH, getConfig: () => { - const config = getResolvedConfig().anilist.characterDictionary; + const config = configService.getConfig().anilist.characterDictionary; return { enabled: - getResolvedConfig().subtitleStyle.nameMatchEnabled && + configService.getConfig().subtitleStyle.nameMatchEnabled && yomitanProfilePolicy.isCharacterDictionaryEnabled() && !isYoutubePlaybackActiveNow(), maxLoaded: config.maxLoaded, @@ -2543,9 +2504,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt logWarn: (message) => logger.warn(message), onSyncStatus: (event) => { notifyCharacterDictionaryAutoSyncStatus(event, { - getNotificationType: () => getConfiguredStatusNotificationType(), + getNotificationType: () => overlayNotificationsRuntime.getConfiguredStatusNotificationType(), showOsd: (message) => showMpvOsd(message), - showOverlayNotification, + showOverlayNotification: (payload) => + overlayNotificationsRuntime.showOverlayNotification(payload), showDesktopNotification: (title, options) => showDesktopNotification(title, options), startupOsdSequencer, }); @@ -2600,36 +2562,38 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( getLastKnownWindowsForegroundProcessName: () => visibleOverlayInteractionRuntime.getLastWindowsVisibleOverlayForegroundProcessName(), getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), - getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(), + getWindowsFocusHandoffGraceActive: () => + visibleOverlayInteractionRuntime.hasWindowsVisibleOverlayFocusHandoffGrace(), getMacOSForegroundProbeActive: () => visibleOverlayInteractionRuntime.getMacOSVisibleOverlayForegroundProbeActive(), getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown: boolean) => { appState.trackerNotReadyWarningShown = shown; }, - updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), + updateVisibleOverlayBounds: (geometry: WindowGeometry) => + overlayGeometryRuntime.updateVisibleOverlayBounds(geometry), ensureOverlayWindowLevel: (window) => { - ensureOverlayWindowLevel(window); + overlayGeometryRuntime.ensureOverlayWindowLevel(window); }, syncWindowsOverlayToMpvZOrder: (_window) => { - requestWindowsVisibleOverlayZOrderSync(); + visibleOverlayInteractionRuntime.requestWindowsVisibleOverlayZOrderSync(); }, syncPrimaryOverlayWindowLayer: (layer) => { - syncPrimaryOverlayWindowLayer(layer); + overlayGeometryRuntime.syncPrimaryOverlayWindowLayer(layer); }, enforceOverlayLayerOrder: () => { - enforceOverlayLayerOrder(); + overlayGeometryRuntime.enforceOverlayLayerOrder(); }, syncOverlayShortcuts: () => { overlayShortcutsRuntime.syncOverlayShortcuts(); }, isMacOSPlatform: () => process.platform === 'darwin', isWindowsPlatform: () => process.platform === 'win32', - showOverlayLoadingOsd: (message: string) => { - showOverlayLoadingStatusNotification(message); + showOverlayLoadingOsd: (_message: string) => { + overlayNotificationsRuntime.showOverlayLoadingStatusNotification(); }, dismissOverlayLoadingOsd: () => { - dismissOverlayLoadingStatusNotification(); + overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); }, hideNonNativeOverlayWhenTargetUnfocused: () => shouldRunLinuxOverlayZOrderKeepAlive() && @@ -2683,126 +2647,22 @@ const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime( setLinuxVisibleOverlayOwnerBindingKey: (key) => { linuxVisibleOverlayOwnerBindingKey = key; }, - bindVisibleOverlayToTrackedX11Window: (window) => bindVisibleOverlayToTrackedX11Window(window), - updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry), + bindVisibleOverlayToTrackedX11Window: (window) => + overlayGeometryRuntime.bindVisibleOverlayToTrackedX11Window(window), + updateVisibleOverlayBounds: (geometry) => + overlayGeometryRuntime.updateVisibleOverlayBounds(geometry), refreshCurrentSubtitle: () => { subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); }, - getOverlayWindows: () => getOverlayWindows(), + getOverlayWindows: () => overlayManager.getOverlayWindows(), syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), resetLastOverlayWindowGeometry: () => overlayGeometryRuntime.resetLastOverlayWindowGeometry(), enforceOverlayLayerOrder: () => { - enforceOverlayLayerOrder(); + overlayGeometryRuntime.enforceOverlayLayerOrder(); }, getOverlayForegroundSeparateWindows: () => getOverlayForegroundSeparateWindows(), }); -function handleStatsOverlayVisibilityChanged(visible: boolean): void { - visibleOverlayInteractionRuntime.handleStatsOverlayVisibilityChanged(visible); -} - -function resetVisibleOverlayInputState(): void { - visibleOverlayInteractionRuntime.resetVisibleOverlayInputState(); -} - -function restoreVisibleOverlayWindowShapeForShow(): void { - visibleOverlayInteractionRuntime.restoreVisibleOverlayWindowShapeForShow(); -} - -function getNativeWindowHandleDecimal(window: BrowserWindow): string { - return visibleOverlayInteractionRuntime.getNativeWindowHandleDecimal(window); -} - -function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number { - return visibleOverlayInteractionRuntime.getWindowsNativeWindowHandleNumber(window); -} - -function enqueueVisibleOverlayX11OwnerBindingOperation( - window: BrowserWindow, - args: string[], - onError?: (error: Error) => void, -): void { - visibleOverlayInteractionRuntime.enqueueVisibleOverlayX11OwnerBindingOperation( - window, - args, - onError, - ); -} - -function clearVisibleOverlayX11OwnerBinding(window: BrowserWindow): void { - visibleOverlayInteractionRuntime.clearVisibleOverlayX11OwnerBinding(window); -} - -function createOverlayWindowTracker(override?: string | null, targetMpvSocketPath?: string | null) { - return visibleOverlayInteractionRuntime.createOverlayWindowTracker(override, targetMpvSocketPath); -} - -function bindVisibleOverlayOwner(): void { - visibleOverlayInteractionRuntime.bindVisibleOverlayOwner(); -} - -function releaseVisibleOverlayOwner(): void { - visibleOverlayInteractionRuntime.releaseVisibleOverlayOwner(); -} - -function retargetOverlayWindowTrackerForMpvSocket( - nextSocketPath: string, - previousSocketPath: string, -): void { - visibleOverlayInteractionRuntime.retargetOverlayWindowTrackerForMpvSocket( - nextSocketPath, - previousSocketPath, - ); -} - -function requestWindowsVisibleOverlayZOrderSync(): void { - visibleOverlayInteractionRuntime.requestWindowsVisibleOverlayZOrderSync(); -} - -function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void { - visibleOverlayInteractionRuntime.scheduleWindowsVisibleOverlayZOrderSyncBurst(); -} - -function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean { - return visibleOverlayInteractionRuntime.hasWindowsVisibleOverlayFocusHandoffGrace(); -} - -function clearWindowsVisibleOverlayForegroundPollLoop(): void { - visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(); -} - -function tickWindowsOverlayPointerInteractionNow(): void { - visibleOverlayInteractionRuntime.tickWindowsOverlayPointerInteractionNow(); -} - -function scheduleVisibleOverlayBlurRefresh(): void { - visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(); -} - -function resetLinuxVisibleOverlayStartupInputPrimer(): void { - visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer(); -} - -function startLinuxVisibleOverlayStartupInputGrace(): void { - visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace(); -} - -function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean { - return visibleOverlayInteractionRuntime.applyLinuxOverlayInputShapeFromLatestMeasurement(); -} - -function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void { - visibleOverlayInteractionRuntime.primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); -} - -function requestLinuxOverlayZOrderFollow(): void { - visibleOverlayInteractionRuntime.requestLinuxOverlayZOrderFollow(); -} - -function tickLinuxOverlayPointerInteractionNow(): void { - visibleOverlayInteractionRuntime.tickLinuxOverlayPointerInteractionNow(); -} - const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler( { getRuntimeOptionsManager: () => appState.runtimeOptionsManager, @@ -2813,14 +2673,6 @@ const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler( getRuntimeOptionsStateMainDeps, ); -function getRuntimeOptionsState(): RuntimeOptionState[] { - return getRuntimeOptionsStateHandler(); -} - -function getOverlayWindows(): BrowserWindow[] { - return overlayManager.getOverlayWindows(); -} - const buildRestorePreviousSecondarySubVisibilityMainDepsHandler = createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({ getMpvClient: () => appState.mpvClient, @@ -2829,15 +2681,12 @@ syncOverlayVisibilityForModal = () => { overlayVisibilityRuntime.updateVisibleOverlayVisibility(); }; -function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { - overlayManager.broadcastToOverlayWindows(channel, ...args); -} - const overlayNotificationsRuntime = createOverlayNotificationsRuntime({ - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), getMainOverlayWindow: () => overlayManager.getMainWindow(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args), + broadcastToOverlayWindows: (channel, ...args) => + overlayManager.broadcastToOverlayWindows(channel, ...args), showMpvOsd: (message) => showMpvOsd(message), getMpvClient: () => appState.mpvClient, getAnkiIntegration: () => appState.ankiIntegration, @@ -2852,45 +2701,12 @@ const { maybeStartOverlayLoadingOsd, } = overlayNotificationsRuntime; -// 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 { - overlayNotificationsRuntime.showOverlayNotification(payload); -} - -function showConfiguredStatusNotification( - message: string, - options: ConfiguredStatusNotificationOptions = {}, -): void { - overlayNotificationsRuntime.showConfiguredStatusNotification(message, options); -} - -function showSubsyncStatusNotification(message: string): void { - overlayNotificationsRuntime.showSubsyncStatusNotification(message); -} - -function showYoutubeFlowStatusNotification(message: string): void { - overlayNotificationsRuntime.showYoutubeFlowStatusNotification(message); -} - -function showOverlayLoadingStatusNotification(_message: string): void { - overlayNotificationsRuntime.showOverlayLoadingStatusNotification(); -} - -function dismissOverlayLoadingStatusNotification(): void { - overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); -} - const buildBroadcastRuntimeOptionsChangedMainDepsHandler = createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({ broadcastRuntimeOptionsChangedRuntime, - getRuntimeOptionsState: () => getRuntimeOptionsState(), - broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args), + getRuntimeOptionsState: () => getRuntimeOptionsStateHandler(), + broadcastToOverlayWindows: (channel, ...args) => + overlayManager.broadcastToOverlayWindows(channel, ...args), }); const buildSendToActiveOverlayWindowMainDepsHandler = @@ -2923,26 +2739,6 @@ const overlayVisibilityComposer = composeOverlayVisibilityRuntime({ openRuntimeOptionsPaletteMainDeps: buildOpenRuntimeOptionsPaletteMainDepsHandler(), }); -function restorePreviousSecondarySubVisibility(): void { - overlayVisibilityComposer.restorePreviousSecondarySubVisibility(); -} - -function broadcastRuntimeOptionsChanged(): void { - overlayVisibilityComposer.broadcastRuntimeOptionsChanged(); -} - -function sendToActiveOverlayWindow( - channel: string, - payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, -): boolean { - return overlayVisibilityComposer.sendToActiveOverlayWindow(channel, payload, runtimeOptions); -} - -function setOverlayDebugVisualizationEnabled(enabled: boolean): void { - overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled); -} - function createOverlayHostedModalOpenDeps(): { ensureOverlayStartupPrereqs: () => void; ensureOverlayWindowsReadyForVisibilityActions: () => void; @@ -2962,7 +2758,7 @@ function createOverlayHostedModalOpenDeps(): { ensureOverlayWindowsReadyForVisibilityActions: () => ensureOverlayWindowsReadyForVisibilityActions(), sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => - sendToActiveOverlayWindow(channel, payload, runtimeOptions), + overlayVisibilityComposer.sendToActiveOverlayWindow(channel, payload, runtimeOptions), waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), logWarn: (message) => logger.warn(message), }; @@ -2976,12 +2772,16 @@ function openOverlayHostedModalWithOsd( void openModal(createOverlayHostedModalOpenDeps()) .then((opened) => { if (!opened) { - showConfiguredStatusNotification(unavailableMessage, { variant: 'warning' }); + overlayNotificationsRuntime.showConfiguredStatusNotification(unavailableMessage, { + variant: 'warning', + }); } }) .catch((error) => { logger.error(failureLogMessage, error); - showConfiguredStatusNotification(unavailableMessage, { variant: 'error' }); + overlayNotificationsRuntime.showConfiguredStatusNotification(unavailableMessage, { + variant: 'error', + }); }); } @@ -3019,8 +2819,8 @@ function openSessionHelpOverlay(): void { function openCharacterDictionaryManagerOverlay(): void { openCharacterDictionaryManagerWithConfigGate({ - isCharacterDictionaryEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, - getNotificationType: () => getConfiguredStatusNotificationType(), + isCharacterDictionaryEnabled: () => configService.getConfig().subtitleStyle.nameMatchEnabled, + getNotificationType: () => overlayNotificationsRuntime.getConfiguredStatusNotificationType(), openManager: () => { openOverlayHostedModalWithOsd( openCharacterDictionaryManagerModalRuntime, @@ -3029,7 +2829,8 @@ function openCharacterDictionaryManagerOverlay(): void { ); }, showOsd: (message) => showMpvOsd(message), - showOverlayNotification, + showOverlayNotification: (payload) => + overlayNotificationsRuntime.showOverlayNotification(payload), showDesktopNotification: (title, options) => showDesktopNotification(title, options), logWarn: (message, error) => logger.warn(message, error), }); @@ -3053,10 +2854,13 @@ function openControllerDebugOverlay(): void { function openPlaylistBrowser(): void { if (!appState.mpvClient?.connected) { - showConfiguredStatusNotification('Playlist browser requires active playback.', { - title: 'Playlist browser', - variant: 'warning', - }); + overlayNotificationsRuntime.showConfiguredStatusNotification( + 'Playlist browser requires active playback.', + { + title: 'Playlist browser', + variant: 'warning', + }, + ); return; } openOverlayHostedModalWithOsd( @@ -3066,10 +2870,6 @@ function openPlaylistBrowser(): void { ); } -function getResolvedConfig() { - return configService.getConfig(); -} - function getRuntimeBooleanOption( id: | 'subtitle.annotation.knownWords.highlightEnabled' @@ -3083,7 +2883,7 @@ function getRuntimeBooleanOption( } function shouldInitializeMecabForAnnotations(): boolean { - const config = getResolvedConfig(); + const config = configService.getConfig(); const knownWordsEnabled = getRuntimeBooleanOption( 'subtitle.annotation.knownWords.highlightEnabled', config.ankiConnect.knownWords.highlightEnabled, @@ -3115,7 +2915,7 @@ const { getJellyfinClientInfo, } = composeJellyfinRuntimeHandlers({ getResolvedJellyfinConfigMainDeps: { - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), loadStoredSession: () => jellyfinTokenStore.loadSession(), getEnv: (name) => process.env[name], }, @@ -3132,7 +2932,7 @@ const { }, launchMpvIdleForJellyfinPlaybackMainDeps: { getSocketPath: () => appState.mpvSocketPath, - getLaunchMode: () => getResolvedConfig().mpv.launchMode, + getLaunchMode: () => configService.getConfig().mpv.launchMode, platform: process.platform, execPath: process.execPath, getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(), @@ -3142,7 +2942,7 @@ const { homeDir: os.homedir(), xdgConfigHome: process.env.XDG_CONFIG_HOME, appDataDir: app.getPath('appData'), - mpvExecutablePath: getResolvedConfig().mpv.executablePath, + mpvExecutablePath: configService.getConfig().mpv.executablePath, }), getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(), getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''), @@ -3211,7 +3011,7 @@ const { subtitleStreamIndex: params.subtitleStreamIndex ?? undefined, }, ), - applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient), + applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaultsHandler(mpvClient), showVisibleOverlay: () => setVisibleOverlayVisible(true), sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), armQuitOnDisconnect: () => { @@ -3238,7 +3038,7 @@ const { void appState.jellyfinRemoteSession?.reportPlaying(payload); }, showMpvOsd: (text) => { - showConfiguredStatusNotification(text, { title: 'Jellyfin' }); + overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Jellyfin' }); }, updateCurrentMediaTitle: (title) => { mediaRuntime.updateCurrentMediaTitle(title); @@ -3347,7 +3147,7 @@ const { patchJellyfinConfig: (session) => { const recentServers = mergeJellyfinRecentServers( session.serverUrl, - getResolvedConfig().jellyfin.recentServers || [], + configService.getConfig().jellyfin.recentServers || [], ); configService.patchRawConfig({ jellyfin: { @@ -3363,7 +3163,7 @@ const { persistJellyfinAuthSession({ session, clientInfo, - existingRecentServers: getResolvedConfig().jellyfin.recentServers || [], + existingRecentServers: configService.getConfig().jellyfin.recentServers || [], saveStoredSession: (storedSession) => jellyfinTokenStore.saveSession(storedSession), patchRawConfig: (patch) => { configService.patchRawConfig(patch); @@ -3372,7 +3172,8 @@ const { }), logInfo: (message) => logger.info(message), logError: (message, error) => logger.error(message, error), - showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'Jellyfin' }), + showMpvOsd: (message) => + overlayNotificationsRuntime.showConfiguredStatusNotification(message, { title: 'Jellyfin' }), clearSetupWindow: () => { appState.jellyfinSetupWindow = null; }, @@ -3413,7 +3214,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ }), getSetupSnapshot: async () => { const snapshot = await firstRunSetupService.getSetupStatus(); - const mpvExecutablePath = getResolvedConfig().mpv.executablePath; + const mpvExecutablePath = configService.getConfig().mpv.executablePath; return { configReady: snapshot.configReady, dictionaryCount: snapshot.dictionaryCount, @@ -3478,7 +3279,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ return; } if (submission.action === 'open-config-settings') { - const opened = openConfigSettingsWindow(); + const opened = configSettingsRuntime.openWindow(); firstRunSetupMessage = opened ? 'Opened SubMiner settings.' : 'SubMiner settings are unavailable.'; @@ -3540,10 +3341,12 @@ const { registerSubminerProtocolClient, } = composeAnilistSetupHandlers({ notifyDeps: { - getNotificationType: () => getConfiguredStatusNotificationType(), + getNotificationType: () => overlayNotificationsRuntime.getConfiguredStatusNotificationType(), hasMpvClient: () => Boolean(appState.mpvClient), - showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'AniList' }), - showOverlayNotification, + showMpvOsd: (message) => + overlayNotificationsRuntime.showConfiguredStatusNotification(message, { title: 'AniList' }), + showOverlayNotification: (payload) => + overlayNotificationsRuntime.showOverlayNotification(payload), showDesktopNotification: (title, options) => showDesktopNotification(title, options), logInfo: (message) => logger.info(message), }, @@ -3641,9 +3444,9 @@ const buildOpenAnilistSetupWindowMainDepsHandler = createBuildOpenAnilistSetupWi }, ); -function openAnilistSetupWindow(): void { - createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(); -} +const openAnilistSetupWindowHandler = createOpenAnilistSetupWindowHandler( + buildOpenAnilistSetupWindowMainDepsHandler(), +); const { refreshAnilistClientSecretState, @@ -3659,7 +3462,7 @@ const { maybeRunAnilistPostWatchUpdate, } = composeAnilistTrackingHandlers({ refreshClientSecretMainDeps: { - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), getCachedAccessToken: () => anilistCachedAccessToken, setCachedAccessToken: (token) => { @@ -3677,7 +3480,7 @@ const { appState.anilistSetupPageOpened = opened; }, openAnilistSetupWindow: () => { - openAnilistSetupWindow(); + openAnilistSetupWindowHandler(); }, now: () => Date.now(), }, @@ -3838,7 +3641,7 @@ const { value, ); }, - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), getCurrentMediaKey: () => getCurrentAnilistMediaKey(), hasMpvClient: () => Boolean(appState.mpvClient), @@ -3870,7 +3673,8 @@ const { rememberAttemptedUpdateKey: (key) => { rememberAnilistAttemptedUpdate(key); }, - showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'AniList' }), + showMpvOsd: (message) => + overlayNotificationsRuntime.showConfiguredStatusNotification(message, { title: 'AniList' }), logInfo: (message) => logger.info(message), logWarn: (message) => logger.warn(message), minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS, @@ -3882,7 +3686,7 @@ function refreshAnilistClientSecretStateIfEnabled(options?: { force?: boolean; allowSetupPrompt?: boolean; }): Promise { - if (!isAnilistTrackingEnabled(getResolvedConfig())) { + if (!isAnilistTrackingEnabled(configService.getConfig())) { return Promise.resolve(null); } return refreshAnilistClientSecretState(options); @@ -3900,7 +3704,7 @@ const buildLoadSubtitlePositionMainDepsHandler = createBuildLoadSubtitlePosition loadSubtitlePositionCore: () => loadSubtitlePositionCore({ currentMediaPath: appState.currentMediaPath, - fallbackPosition: getResolvedConfig().subtitlePosition, + fallbackPosition: configService.getConfig().subtitlePosition, subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, }), setSubtitlePosition: (position) => { @@ -3956,9 +3760,10 @@ const { }, }, onWillQuitCleanupMainDeps: { - destroyTray: () => destroyTray(), + destroyTray: () => destroyTrayHandler(), stopConfigHotReload: () => configHotReloadRuntime.stop(), - restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(), + restorePreviousSecondarySubVisibility: () => + overlayVisibilityComposer.restorePreviousSecondarySubVisibility(), restoreMpvSubVisibility: () => { restoreOverlayMpvSubtitles({ force: true }); }, @@ -3974,7 +3779,7 @@ const { await syncUiRuntime.shutdown(); }, clearWindowsVisibleOverlayForegroundPollLoop: () => - clearWindowsVisibleOverlayForegroundPollLoop(), + visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(), clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => { cancelLinuxMpvFullscreenOverlayRefreshBurst = null; clearLinuxMpvFullscreenOverlayRefreshTimeouts(); @@ -4076,7 +3881,7 @@ const resolveLegacyVocabularyPos = async (row: { const immersionTrackerStartupMainDeps: Parameters< typeof createBuildImmersionTrackerStartupMainDepsHandler >[0] = { - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), createTrackerService: (params) => new ImmersionTrackerService({ @@ -4096,7 +3901,7 @@ const immersionTrackerStartupMainDeps: Parameters< if (tracker) { // Start HTTP stats server if (!appState.statsServer) { - const config = getResolvedConfig(); + const config = configService.getConfig(); if (config.stats.autoStartServer) { ensureStatsServerStarted(); } @@ -4107,10 +3912,10 @@ const immersionTrackerStartupMainDeps: Parameters< staticDir: statsDistPath, preloadPath: statsPreloadPath, getApiBaseUrl: () => ensureStatsServerStarted().url, - getToggleKey: () => getResolvedConfig().stats.toggleKey, - resolveBounds: () => getCurrentOverlayGeometry(), + getToggleKey: () => configService.getConfig().stats.toggleKey, + resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(), onVisibilityChanged: (visible) => { - handleStatsOverlayVisibilityChanged(visible); + visibleOverlayInteractionRuntime.handleStatsOverlayVisibilityChanged(visible); }, }); } @@ -4170,8 +3975,8 @@ const statsStartupRuntime = { }, } as const; const ensureBackgroundStatsServer = createEnsureBackgroundStatsServerHandler({ - isStatsAutoStartEnabled: () => getResolvedConfig().stats.autoStartServer, - isImmersionTrackingEnabled: () => getResolvedConfig().immersionTracking?.enabled !== false, + isStatsAutoStartEnabled: () => configService.getConfig().stats.autoStartServer, + isImmersionTrackingEnabled: () => configService.getConfig().immersionTracking?.enabled !== false, ensureBackgroundStatsServerStarted: () => statsStartupRuntime.ensureBackgroundStatsServerStarted(), logInfo: (message) => logger.info(message), @@ -4179,7 +3984,7 @@ const ensureBackgroundStatsServer = createEnsureBackgroundStatsServerHandler({ }); const runStatsCliCommand = createRunStatsCliCommandHandler({ - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), ensureImmersionTrackerStarted: () => statsStartupRuntime.ensureImmersionTrackerStarted(), ensureVocabularyCleanupTokenizerReady: async () => { await createMecabTokenizerAndCheck(); @@ -4208,7 +4013,7 @@ async function runHeadlessInitialCommand(): Promise { return; } - const resolvedConfig = getResolvedConfig(); + const resolvedConfig = configService.getConfig(); if (resolvedConfig.ankiConnect.enabled !== true) { logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled'); process.exitCode = 1; @@ -4281,13 +4086,13 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ }, loadSubtitlePosition: () => loadSubtitlePosition(), resolveKeybindings: () => { - appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); + appState.keybindings = resolveKeybindings(configService.getConfig(), DEFAULT_KEYBINDINGS); refreshCurrentSessionBindings(); }, createMpvClient: () => { appState.mpvClient = createMpvClientRuntimeService(); }, - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), getConfigWarnings: () => configService.getWarnings(), logConfigWarning: (warning) => appLogger.logConfigWarning(warning), setLogLevel: (level: string, source: LogLevelSource) => setLogLevel(level, source), @@ -4306,7 +4111,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ onOptionsChanged: () => { subtitleProcessingController.invalidateTokenizationCache(); subtitlePrefetchService?.onSeek(lastObservedTimePos); - broadcastRuntimeOptionsChanged(); + overlayVisibilityComposer.broadcastRuntimeOptionsChanged(); refreshOverlayShortcuts(); }, }, @@ -4333,11 +4138,14 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ endTime: appState.mpvClient?.currentSubEnd ?? null, } : null), - () => ({ - enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, - topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, - mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, - }), + () => { + const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; + return { + enabled: frequencyDictionary.enabled, + topX: frequencyDictionary.topX, + mode: frequencyDictionary.mode, + }; + }, ); }, startAnnotationWebsocket: (port: number) => { @@ -4353,11 +4161,14 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ endTime: appState.mpvClient?.currentSubEnd ?? null, } : null), - () => ({ - enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, - topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, - mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, - }), + () => { + const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; + return { + enabled: frequencyDictionary.enabled, + topX: frequencyDictionary.topX, + mode: frequencyDictionary.mode, + }; + }, ); }, startTexthooker: (port: number, websocketUrl?: string) => { @@ -4397,7 +4208,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ await prewarmSubtitleDictionaries(); }, startBackgroundWarmups: () => { - startBackgroundWarmupsIfAllowed(); + startBackgroundWarmups(); }, texthookerOnlyMode: appState.texthookerOnlyMode, shouldAutoInitializeOverlayRuntimeFromConfig: () => @@ -4445,7 +4256,7 @@ function ensureOverlayStartupPrereqs(): void { loadSubtitlePosition(); } if (appState.keybindings.length === 0) { - appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); + appState.keybindings = resolveKeybindings(configService.getConfig(), DEFAULT_KEYBINDINGS); refreshCurrentSessionBindings(); } else if (!appState.sessionBindingsInitialized) { refreshCurrentSessionBindings(); @@ -4466,7 +4277,7 @@ function ensureOverlayStartupPrereqs(): void { onOptionsChanged: () => { subtitleProcessingController.invalidateTokenizationCache(); subtitlePrefetchService?.onSeek(lastObservedTimePos); - broadcastRuntimeOptionsChanged(); + overlayVisibilityComposer.broadcastRuntimeOptionsChanged(); refreshOverlayShortcuts(); }, }, @@ -4534,7 +4345,7 @@ const { }, logSubtitleTimingError: (message, error) => logger.error(message, error), broadcastToOverlayWindows: (channel, payload) => { - broadcastToOverlayWindows(channel, payload); + overlayManager.broadcastToOverlayWindows(channel, payload); }, getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text), emitImmediateSubtitle: (payload) => { @@ -4563,7 +4374,7 @@ const { ); if ((normalizedPath || null) !== previousPath) { const resetSubtitlePayload = { text: '', tokens: null }; - const frequencyDictionary = getResolvedConfig().subtitleStyle.frequencyDictionary; + const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; const frequencyOptions = { enabled: frequencyDictionary.enabled, topX: frequencyDictionary.topX, @@ -4580,7 +4391,7 @@ const { appState.activeParsedSubtitleMediaPath = null; } activeJellyfinSubtitleDelayKey = null; - broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); + overlayManager.broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(); @@ -4627,7 +4438,7 @@ const { }, scheduleCharacterDictionarySync: () => { if ( - !getResolvedConfig().subtitleStyle.nameMatchEnabled || + !configService.getConfig().subtitleStyle.nameMatchEnabled || !yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow() ) { @@ -4665,7 +4476,8 @@ const { getOverlayInteractionActive: () => visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() || visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(), - ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), + ensureOverlayWindowLevel: (window) => + overlayGeometryRuntime.ensureOverlayWindowLevel(window), }, cancelLinuxMpvFullscreenOverlayRefreshBurst, ); @@ -4673,7 +4485,7 @@ const { onSubtitleTrackChange: (sid) => { lastObservedPrimarySubtitleTrackId = sid; logger.info('[mpv-subtitles] primary subtitle track changed', { sid }); - scheduleSubtitlePrefetchRefresh(); + autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(); youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid); }, onSubtitleTrackListChange: (trackList) => { @@ -4689,11 +4501,11 @@ const { logger.info('[mpv-subtitles] subtitle track list updated', diagnostics); } managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList); - scheduleSubtitlePrefetchRefresh(); + autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(); youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList); }, updateSubtitleRenderMetrics: (patch) => { - updateMpvSubtitleRenderMetrics(patch as Partial); + updateMpvSubtitleRenderMetricsHandler(patch as Partial); }, syncOverlayMpvSubtitleSuppression: () => { syncOverlayMpvSubtitleSuppression(); @@ -4702,7 +4514,7 @@ const { mpvClientRuntimeServiceFactoryMainDeps: { createClient: MpvIpcClient, getSocketPath: () => appState.mpvSocketPath, - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), isAutoStartOverlayEnabled: () => appState.autoStartOverlay, setOverlayVisible: (visible: boolean) => setOverlayVisible(visible), isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), @@ -4754,41 +4566,42 @@ const { }, getKnownWordMatchMode: () => appState.ankiIntegration?.getKnownWordMatchMode() ?? - getResolvedConfig().ankiConnect.knownWords.matchMode, + configService.getConfig().ankiConnect.knownWords.matchMode, getKnownWordsEnabled: () => getRuntimeBooleanOption( 'subtitle.annotation.knownWords.highlightEnabled', - getResolvedConfig().ankiConnect.knownWords.highlightEnabled, + configService.getConfig().ankiConnect.knownWords.highlightEnabled, ), getNPlusOneEnabled: () => getRuntimeBooleanOption( 'subtitle.annotation.nPlusOne', - getResolvedConfig().ankiConnect.nPlusOne.enabled, + configService.getConfig().ankiConnect.nPlusOne.enabled, ), getMinSentenceWordsForNPlusOne: () => - getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords, + configService.getConfig().ankiConnect.nPlusOne.minSentenceWords, getJlptLevel: (text) => appState.jlptLevelLookup(text), getJlptEnabled: () => getRuntimeBooleanOption( 'subtitle.annotation.jlpt', - getResolvedConfig().subtitleStyle.enableJlpt, + configService.getConfig().subtitleStyle.enableJlpt, ), getCharacterDictionaryEnabled: () => - getResolvedConfig().subtitleStyle.nameMatchEnabled && + configService.getConfig().subtitleStyle.nameMatchEnabled && yomitanProfilePolicy.isCharacterDictionaryEnabled() && !isYoutubePlaybackActiveNow(), - getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, - getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled, + getNameMatchEnabled: () => configService.getConfig().subtitleStyle.nameMatchEnabled, + getNameMatchImagesEnabled: () => + configService.getConfig().subtitleStyle.nameMatchImagesEnabled, getCharacterNameImage: (term) => characterDictionaryImageLookup.get(term), getCurrentCharacterDictionaryMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(), getFrequencyDictionaryEnabled: () => getRuntimeBooleanOption( 'subtitle.annotation.frequency', - getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + configService.getConfig().subtitleStyle.frequencyDictionary.enabled, ), getFrequencyDictionaryMatchMode: () => - getResolvedConfig().subtitleStyle.frequencyDictionary.matchMode, + configService.getConfig().subtitleStyle.frequencyDictionary.matchMode, getFrequencyRank: (text) => appState.frequencyRankLookup(text), getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, getMecabTokenizer: () => appState.mecabTokenizer, @@ -4814,12 +4627,13 @@ const { ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(), ensureFrequencyDictionaryLookup: () => frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), - showMpvOsd: (message: string) => showConfiguredStatusNotification(message), + showMpvOsd: (message: string) => + overlayNotificationsRuntime.showConfiguredStatusNotification(message), showLoadingOsd: (message: string) => startupOsdSequencer.showAnnotationLoading(message), showLoadedOsd: (message: string) => startupOsdSequencer.markAnnotationLoadingComplete(message), shouldShowOsdNotification: () => { - const type = getConfiguredStatusNotificationType(); + const type = overlayNotificationsRuntime.getConfiguredStatusNotificationType(); return type === 'osd' || type === 'osd-system'; }, }, @@ -4838,7 +4652,7 @@ const { isTexthookerOnlyMode: () => appState.texthookerOnlyMode, ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}), shouldWarmupMecab: () => { - const startupWarmups = getResolvedConfig().startupWarmups; + const startupWarmups = configService.getConfig().startupWarmups; if (startupWarmups.lowPowerMode) { return false; } @@ -4847,23 +4661,23 @@ const { } return shouldInitializeMecabForAnnotations(); }, - shouldWarmupYomitanExtension: () => getResolvedConfig().startupWarmups.yomitanExtension, + shouldWarmupYomitanExtension: () => configService.getConfig().startupWarmups.yomitanExtension, shouldWarmupSubtitleDictionaries: () => { - const startupWarmups = getResolvedConfig().startupWarmups; + const startupWarmups = configService.getConfig().startupWarmups; if (startupWarmups.lowPowerMode) { return false; } return startupWarmups.subtitleDictionaries; }, shouldWarmupJellyfinRemoteSession: () => { - const startupWarmups = getResolvedConfig().startupWarmups; + const startupWarmups = configService.getConfig().startupWarmups; if (startupWarmups.lowPowerMode) { return false; } return startupWarmups.jellyfinRemoteSession; }, shouldAutoConnectJellyfinRemote: () => { - const jellyfin = getResolvedConfig().jellyfin; + const jellyfin = configService.getConfig().jellyfin; return ( jellyfin.enabled && jellyfin.remoteControlEnabled && jellyfin.remoteControlAutoConnect ); @@ -4897,8 +4711,8 @@ tokenizeSubtitleDeferred = tokenizeSubtitle; const aniSkipRuntime = createAniSkipRuntime({ getAniSkipConfig: () => ({ - aniskipEnabled: getResolvedConfig().mpv.aniskipEnabled, - aniskipButtonKey: getResolvedConfig().mpv.aniskipButtonKey, + aniskipEnabled: configService.getConfig().mpv.aniskipEnabled, + aniskipButtonKey: configService.getConfig().mpv.aniskipButtonKey, }), resolveMetadataForFile: (mediaPath) => resolveAniSkipMetadataForFile(mediaPath), sendMpvCommand: (command) => { @@ -4931,7 +4745,7 @@ function createMpvClientRuntimeService(): MpvIpcClient { return; } youtubeFlowRuntime.cancelActivePicker(); - broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null); + overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null); overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker'); }); client.on('connection-change', aniSkipRuntime.handleConnectionChange); @@ -4946,10 +4760,6 @@ function resetSubtitleSidebarEmbeddedLayoutRuntime(): void { sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-pan-x', 0]); } -function updateMpvSubtitleRenderMetrics(patch: Partial): void { - updateMpvSubtitleRenderMetricsHandler(patch); -} - const overlayGeometryRuntime = createOverlayGeometryRuntime({ overlayManager: { getMainWindow: () => overlayManager.getMainWindow(), @@ -4972,47 +4782,21 @@ const overlayGeometryRuntime = createOverlayGeometryRuntime({ setLinuxVisibleOverlayOwnerBindingKey: (key) => { linuxVisibleOverlayOwnerBindingKey = key; }, - clearVisibleOverlayX11OwnerBinding: (window) => clearVisibleOverlayX11OwnerBinding(window), - getNativeWindowHandleDecimal: (window) => getNativeWindowHandleDecimal(window), + clearVisibleOverlayX11OwnerBinding: (window) => + visibleOverlayInteractionRuntime.clearVisibleOverlayX11OwnerBinding(window), + getNativeWindowHandleDecimal: (window) => + visibleOverlayInteractionRuntime.getNativeWindowHandleDecimal(window), enqueueVisibleOverlayX11OwnerBindingOperation: (window, args, onError) => - enqueueVisibleOverlayX11OwnerBindingOperation(window, args, onError), + visibleOverlayInteractionRuntime.enqueueVisibleOverlayX11OwnerBindingOperation( + window, + args, + onError, + ), scheduleWindowsVisibleOverlayZOrderSyncBurst: () => - scheduleWindowsVisibleOverlayZOrderSyncBurst(), + visibleOverlayInteractionRuntime.scheduleWindowsVisibleOverlayZOrderSyncBurst(), logDebug: (message, ...args) => logger.debug(message, ...args), }); -function getCurrentOverlayGeometry(): WindowGeometry { - return overlayGeometryRuntime.getCurrentOverlayGeometry(); -} - -function getCurrentTrackedOverlayGeometry(): WindowGeometry | null { - return overlayGeometryRuntime.getCurrentTrackedOverlayGeometry(); -} - -function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean { - return overlayGeometryRuntime.geometryMatches(a, b); -} - -function syncPrimaryOverlayWindowLayer(layer: 'visible'): void { - overlayGeometryRuntime.syncPrimaryOverlayWindowLayer(layer); -} - -function bindVisibleOverlayToTrackedX11Window(window: BrowserWindow): void { - overlayGeometryRuntime.bindVisibleOverlayToTrackedX11Window(window); -} - -function updateVisibleOverlayBounds(geometry: WindowGeometry): void { - overlayGeometryRuntime.updateVisibleOverlayBounds(geometry); -} - -function ensureOverlayWindowLevel(window: unknown): void { - overlayGeometryRuntime.ensureOverlayWindowLevel(window); -} - -function enforceOverlayLayerOrder(): void { - overlayGeometryRuntime.enforceOverlayLayerOrder(); -} - async function loadYomitanExtension(): Promise { const extension = await yomitanExtensionRuntime.loadYomitanExtension(); if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) { @@ -5031,7 +4815,7 @@ async function ensureYomitanExtensionLoaded(): Promise { const { syncYomitanDefaultProfileAnkiServer } = createYomitanAnkiServerSyncRuntime({ isExternalReadOnlyMode: () => yomitanProfilePolicy.isExternalReadOnlyMode(), - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), getYomitanParserRuntimeDeps: () => getYomitanParserRuntimeDeps(), logError: (message, ...args) => { logger.error(message, ...args); @@ -5066,14 +4850,14 @@ function createModalWindow(): BrowserWindow { return existingWindow; } const window = createModalWindowHandler(); - overlayManager.setModalWindowBounds(getCurrentOverlayGeometry()); + overlayManager.setModalWindowBounds(overlayGeometryRuntime.getCurrentOverlayGeometry()); return window; } function createMainWindow(): BrowserWindow { const window = createMainWindowHandler(); if (process.platform === 'win32') { - const overlayHwnd = getWindowsNativeWindowHandleNumber(window); + const overlayHwnd = visibleOverlayInteractionRuntime.getWindowsNativeWindowHandleNumber(window); if (!ensureWindowsOverlayTransparency(overlayHwnd)) { logger.warn('Failed to eagerly extend Windows overlay transparency via koffi'); } @@ -5093,9 +4877,9 @@ function createLinuxVisibleOverlayWindowForCurrentMode(token: number, fullscreen return; } - resetVisibleOverlayInputState(); + visibleOverlayInteractionRuntime.resetVisibleOverlayInputState(); createMainWindow(); - const trackedGeometry = getCurrentTrackedOverlayGeometry(); + const trackedGeometry = overlayGeometryRuntime.getCurrentTrackedOverlayGeometry(); if (trackedGeometry) { overlayManager.setOverlayWindowBounds(trackedGeometry); } @@ -5160,14 +4944,6 @@ function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void { } } -function ensureTray(): void { - ensureTrayHandler(); -} - -function destroyTray(): void { - destroyTrayHandler(); -} - function initializeOverlayRuntime(): void { initializeOverlayRuntimeHandler(); appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); @@ -5184,7 +4960,7 @@ function openYomitanSettings(): boolean { logger.warn( 'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.', ); - showConfiguredStatusNotification(message, { variant: 'warning' }); + overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }); return false; } openYomitanSettingsHandler(); @@ -5210,7 +4986,7 @@ const { } = composeShortcutRuntimes({ globalShortcuts: { getConfiguredShortcutsMainDeps: { - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), defaultConfig: DEFAULT_CONFIG, resolveConfiguredShortcuts, }, @@ -5230,13 +5006,13 @@ const { }, numericShortcutRuntimeMainDeps: { globalShortcut, - showMpvOsd: (text) => showConfiguredStatusNotification(text), + showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), clearTimer: (timer) => clearTimeout(timer), }, numericSessions: { - onMultiCopyDigit: (count) => handleMultiCopyDigit(count), - onMineSentenceDigit: (count) => handleMineSentenceDigit(count), + onMultiCopyDigit: (count) => handleMultiCopyDigitHandler(count), + onMineSentenceDigit: (count) => handleMineSentenceDigitHandler(count), tryBeginMultiCopyOverlaySelection: (timeoutMs) => tryBeginVisibleOverlayNumericSelection({ actionId: 'copySubtitleMultiple', @@ -5261,7 +5037,7 @@ const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionB configDir: CONFIG_DIR, getKeybindings: () => appState.keybindings, getConfiguredShortcuts: () => getConfiguredShortcuts(), - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), getMpvClient: () => appState.mpvClient, setSessionBindings: (bindings) => { appState.sessionBindings = bindings; @@ -5298,10 +5074,11 @@ flushPendingMpvLogWrites = () => { const { getUpdateService } = createUpdateServiceRuntime({ userDataPath: USER_DATA_PATH, - getUpdatesConfig: () => getResolvedConfig().updates, + getUpdatesConfig: () => configService.getConfig().updates, logInfo: (message) => logger.info(message), logWarn: (message, details) => logger.warn(message, details), - showOverlayNotification, + showOverlayNotification: (payload) => + overlayNotificationsRuntime.showOverlayNotification(payload), showDesktopNotification: (title, options) => showDesktopNotification(title, options), showMpvOsd: (message) => { showMpvOsd(message); @@ -5321,7 +5098,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({ appState.lastSecondarySubToggleAtMs = timestampMs; }, broadcastToOverlayWindows: (channel, mode) => { - broadcastToOverlayWindows(channel, mode); + overlayManager.broadcastToOverlayWindows(channel, mode); }, showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text), }, @@ -5332,35 +5109,11 @@ function setSecondarySubMode(mode: SecondarySubMode): void { appState.secondarySubMode = mode; } -function handleCycleSecondarySubMode(): void { - cycleSecondarySubMode(); -} - -function toggleSubtitleSidebar(): void { - broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle); -} - -function togglePrimarySubtitleBar(): void { - broadcastToOverlayWindows(IPC_CHANNELS.event.primarySubtitleBarToggle); -} - -async function triggerSubsyncFromConfig(): Promise { - await subsyncRuntime.triggerFromConfig(); -} - -function handleMultiCopyDigit(count: number): void { - handleMultiCopyDigitHandler(count); -} - -function copyCurrentSubtitle(): void { - copyCurrentSubtitleHandler(); -} - const buildUpdateLastCardFromClipboardMainDepsHandler = createBuildUpdateLastCardFromClipboardMainDepsHandler({ getAnkiIntegration: () => appState.ankiIntegration, readClipboardText: () => clipboard.readText(), - showMpvOsd: (text) => showConfiguredStatusNotification(text), + showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), updateLastCardFromClipboardCore, }); const updateLastCardFromClipboardMainDeps = buildUpdateLastCardFromClipboardMainDepsHandler(); @@ -5379,7 +5132,7 @@ const refreshKnownWordCacheHandler = createRefreshKnownWordCacheHandler( const buildTriggerFieldGroupingMainDepsHandler = createBuildTriggerFieldGroupingMainDepsHandler({ getAnkiIntegration: () => appState.ankiIntegration, - showMpvOsd: (text) => showConfiguredStatusNotification(text), + showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), triggerFieldGroupingCore, }); const triggerFieldGroupingMainDeps = buildTriggerFieldGroupingMainDepsHandler(); @@ -5388,7 +5141,7 @@ const triggerFieldGroupingHandler = createTriggerFieldGroupingHandler(triggerFie const buildMarkLastCardAsAudioCardMainDepsHandler = createBuildMarkLastCardAsAudioCardMainDepsHandler({ getAnkiIntegration: () => appState.ankiIntegration, - showMpvOsd: (text) => showConfiguredStatusNotification(text), + showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), markLastCardAsAudioCardCore, }); const markLastCardAsAudioCardMainDeps = buildMarkLastCardAsAudioCardMainDepsHandler(); @@ -5399,7 +5152,7 @@ const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler( const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({ getAnkiIntegration: () => appState.ankiIntegration, getMpvClient: () => appState.mpvClient, - showMpvOsd: (text) => showConfiguredStatusNotification(text), + showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), mineSentenceCardCore, recordCardsMined: (count, noteIds) => { ensureImmersionTrackerStarted(); @@ -5422,7 +5175,7 @@ const handleMultiCopyDigitHandler = createHandleMultiCopyDigitHandler(handleMult const buildCopyCurrentSubtitleMainDepsHandler = createBuildCopyCurrentSubtitleMainDepsHandler({ getSubtitleTimingTracker: () => appState.subtitleTimingTracker, writeClipboardText: (text) => clipboard.writeText(text), - showMpvOsd: (text) => showConfiguredStatusNotification(text), + showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), copyCurrentSubtitleCore, }); const copyCurrentSubtitleMainDeps = buildCopyCurrentSubtitleMainDepsHandler(); @@ -5433,7 +5186,7 @@ const buildHandleMineSentenceDigitMainDepsHandler = getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getAnkiIntegration: () => appState.ankiIntegration, getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined, - showMpvOsd: (text) => showConfiguredStatusNotification(text), + showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), logError: (message, err) => { logger.error(message, err); }, @@ -5476,7 +5229,7 @@ const buildAppendClipboardVideoToQueueMainDepsHandler = appendClipboardVideoToQueueRuntime, getMpvClient: () => appState.mpvClient, readClipboardText: () => clipboard.readText(), - showMpvOsd: (text) => showConfiguredStatusNotification(text), + showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), sendMpvCommand: (command) => { sendMpvCommandRuntime(appState.mpvClient, command); }, @@ -5493,27 +5246,28 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro staticDir: statsDistPath, preloadPath: statsPreloadPath, getApiBaseUrl: () => ensureStatsServerStarted().url, - getToggleKey: () => getResolvedConfig().stats.toggleKey, - resolveBounds: () => getCurrentOverlayGeometry(), + getToggleKey: () => configService.getConfig().stats.toggleKey, + resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(), onVisibilityChanged: (visible) => { - handleStatsOverlayVisibilityChanged(visible); + visibleOverlayInteractionRuntime.handleStatsOverlayVisibilityChanged(visible); }, }), toggleVisibleOverlay: () => toggleVisibleOverlay(), - copyCurrentSubtitle: () => copyCurrentSubtitle(), - copySubtitleCount: (count) => handleMultiCopyDigit(count), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - mineSentenceCard: () => mineSentenceCard(), - mineSentenceCount: (count) => handleMineSentenceDigit(count), - toggleSecondarySub: () => handleCycleSecondarySubMode(), - toggleSubtitleSidebar: () => toggleSubtitleSidebar(), + copyCurrentSubtitle: () => copyCurrentSubtitleHandler(), + copySubtitleCount: (count) => handleMultiCopyDigitHandler(count), + updateLastCardFromClipboard: () => updateLastCardFromClipboardHandler(), + triggerFieldGrouping: () => triggerFieldGroupingHandler(), + triggerSubsyncFromConfig: () => subsyncRuntime.triggerFromConfig(), + mineSentenceCard: () => mineSentenceCardHandler(), + mineSentenceCount: (count) => handleMineSentenceDigitHandler(count), + toggleSecondarySub: () => cycleSecondarySubMode(), + toggleSubtitleSidebar: () => + overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle), toggleNotificationHistory: () => toggleNotificationHistoryPanel(), appendClipboardVideoToQueue: () => { - appendClipboardVideoToQueue(); + appendClipboardVideoToQueueHandler(); }, - markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + markLastCardAsAudioCard: () => markLastCardAsAudioCardHandler(), markActiveVideoWatched: async () => { ensureImmersionTrackerStarted(); const marked = (await appState.immersionTracker?.markActiveVideoWatched()) ?? false; @@ -5553,13 +5307,13 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro } const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, { - getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, - getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, + getPrimarySubtitleLanguages: () => configService.getConfig().youtube.primarySubLanguages, + getSecondarySubtitleLanguages: () => configService.getConfig().secondarySub.secondarySubLanguages, }); const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ mpvCommandMainDeps: { - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + triggerSubsyncFromConfig: () => subsyncRuntime.triggerFromConfig(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openJimaku: () => openJimakuOverlay(), openTsukihime: () => openTsukihimeOverlay(), @@ -5574,7 +5328,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ (text) => showConfiguredPlaybackFeedback(text), ); }, - showMpvOsd: (text: string) => showConfiguredStatusNotification(text), + showMpvOsd: (text: string) => + overlayNotificationsRuntime.showConfiguredStatusNotification(text), showRawMpvOsd: (text: string) => showMpvOsd(text), showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text), replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), @@ -5614,14 +5369,14 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ activatePlaybackWindowForOverlayInteraction: async () => { try { const raised = (await appState.windowTracker?.raiseTargetWindow?.()) ?? false; - enforceOverlayLayerOrder(); + overlayGeometryRuntime.enforceOverlayLayerOrder(); return raised; } catch (error) { logger.debug( 'Failed to raise tracked mpv window for overlay interaction:', error instanceof Error ? error.message : String(error), ); - enforceOverlayLayerOrder(); + overlayGeometryRuntime.enforceOverlayLayerOrder(); return false; } }, @@ -5639,7 +5394,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ senderWindow.setIgnoreMouseEvents(true, { forward: true }); senderWindow.hide(); } - handleOverlayModalClosed(modal); + handleOverlayModalClosedHandler(modal); }, onOverlayModalOpened: (modal, senderWindow) => { if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) { @@ -5669,7 +5424,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ return; } visibleOverlayInteractionRuntime.setLinuxOverlayInteractiveHint(interactive); - applyLinuxOverlayInputShapeFromLatestMeasurement(); + visibleOverlayInteractionRuntime.applyLinuxOverlayInputShapeFromLatestMeasurement(); }, handleOverlayNotificationAction: (notificationId, actionId, noteId) => { if ( @@ -5688,10 +5443,13 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ if (actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined) { void openAnkiCardFromNotification(noteId).catch((error) => { logger.warn('Failed to open Anki card from overlay notification action:', error); - showConfiguredStatusNotification('Failed to open Anki card in Anki.', { - id: 'open-anki-card-failed', - variant: 'error', - }); + overlayNotificationsRuntime.showConfiguredStatusNotification( + 'Failed to open Anki card in Anki.', + { + id: 'open-anki-card-failed', + variant: 'error', + }, + ); }); } }, @@ -5726,7 +5484,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ endTime: appState.mpvClient?.currentSubEnd ?? null, }; const currentTimeSec = appState.mpvClient?.currentTimePos ?? null; - const config = getResolvedConfig().subtitleSidebar; + const config = configService.getConfig().subtitleSidebar; const client = appState.mpvClient; if (!client?.connected) { return { @@ -5826,7 +5584,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ }), getSubtitlePosition: () => loadSubtitlePosition(), getSubtitleStyle: () => { - const resolvedConfig = getResolvedConfig(); + const resolvedConfig = configService.getConfig(); return resolveSubtitleStyleForRenderer(resolvedConfig); }, saveSubtitlePosition: (position) => saveSubtitlePosition(position), @@ -5835,10 +5593,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ getSessionBindings: () => appState.sessionBindings, getConfiguredShortcuts: () => getConfiguredShortcuts(), dispatchSessionAction: (request) => dispatchSessionAction(request), - getStatsToggleKey: () => getResolvedConfig().stats.toggleKey, - getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey, - getOverlayNotificationPosition: () => getResolvedConfig().notifications.overlayPosition, - getControllerConfig: () => getResolvedConfig().controller, + getStatsToggleKey: () => configService.getConfig().stats.toggleKey, + getMarkWatchedKey: () => configService.getConfig().stats.markWatchedKey, + getOverlayNotificationPosition: () => configService.getConfig().notifications.overlayPosition, + getControllerConfig: () => configService.getConfig().controller, saveControllerConfig: (update) => { const currentRawConfig = configService.getRawConfig(); configService.patchRawConfig({ @@ -5856,19 +5614,19 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ getSecondarySubMode: () => appState.secondarySubMode, getMpvClient: () => appState.mpvClient, getAnkiConnectStatus: () => appState.ankiIntegration !== null, - getRuntimeOptions: () => getRuntimeOptionsState(), + getRuntimeOptions: () => getRuntimeOptionsStateHandler(), reportOverlayContentBounds: (payload: unknown) => { if (overlayContentMeasurementStore.report(payload)) { - tickLinuxOverlayPointerInteractionNow(); - tickWindowsOverlayPointerInteractionNow(); - primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); + visibleOverlayInteractionRuntime.tickLinuxOverlayPointerInteractionNow(); + visibleOverlayInteractionRuntime.tickWindowsOverlayPointerInteractionNow(); + visibleOverlayInteractionRuntime.primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); autoplayReadyGate.flushPendingAutoplayReadySignal(); - scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(); + autoplaySubtitlePrimingRuntime.scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(); } }, getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), clearAnilistToken: () => anilistStateRuntime.clearTokenState(), - openAnilistSetup: () => openAnilistSetupWindow(), + openAnilistSetup: () => openAnilistSetupWindowHandler(), getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }), @@ -5946,7 +5704,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ } return result; }, - appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), + appendClipboardVideoToQueue: () => appendClipboardVideoToQueueHandler(), ...playlistBrowserMainDeps, getImmersionTracker: () => appState.immersionTracker, }, @@ -5954,7 +5712,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ patchAnkiConnectEnabled: (enabled: boolean) => { configService.patchRawConfig({ ankiConnect: { enabled } }); }, - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), getRuntimeOptionsManager: () => appState.runtimeOptionsManager, getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getMpvClient: () => appState.mpvClient, @@ -5975,12 +5733,14 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(), getYoutubeMediaSourceUrl: () => getCurrentYoutubeMediaCacheSourceUrl(), showDesktopNotification, - showOverlayNotification, + showOverlayNotification: (payload) => + overlayNotificationsRuntime.showOverlayNotification(payload), createFieldGroupingCallback: () => createFieldGroupingCallback(), - broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), - getFieldGroupingResolver: () => getFieldGroupingResolver(), + broadcastRuntimeOptionsChanged: () => + overlayVisibilityComposer.broadcastRuntimeOptionsChanged(), + getFieldGroupingResolver: () => getFieldGroupingResolverHandler(), setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => - setFieldGroupingResolver(resolver), + setFieldGroupingResolverHandler(resolver), parseMediaInfo: (mediaPath: string | null) => parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath)), getCurrentMediaPath: () => appState.currentMediaPath, @@ -6007,35 +5767,40 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ appState, setLogLevel: (level) => setLogLevel(level, 'cli'), onMpvSocketPathChanged: (nextSocketPath, previousSocketPath) => - retargetOverlayWindowTrackerForMpvSocket(nextSocketPath, previousSocketPath), + visibleOverlayInteractionRuntime.retargetOverlayWindowTrackerForMpvSocket( + nextSocketPath, + previousSocketPath, + ), texthookerService, - getResolvedConfig: () => getResolvedConfig(), + getResolvedConfig: () => configService.getConfig(), defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, defaultAnnotationWebsocketPort: DEFAULT_CONFIG.annotationWebsocket.port, hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), openExternal: (url: string) => shell.openExternal(url), logBrowserOpenError: (url: string, error: unknown) => logger.error(`Failed to open browser for texthooker URL: ${url}`, error), - showMpvOsd: (text: string) => showConfiguredStatusNotification(text), + showMpvOsd: (text: string) => + overlayNotificationsRuntime.showConfiguredStatusNotification(text), showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text), initializeOverlayRuntime: () => initializeOverlayRuntime(), toggleVisibleOverlay: () => toggleVisibleOverlay(), - togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(), + togglePrimarySubtitleBar: () => + overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.primarySubtitleBarToggle), openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force), setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), - copyCurrentSubtitle: () => copyCurrentSubtitle(), + copyCurrentSubtitle: () => copyCurrentSubtitleHandler(), startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), - mineSentenceCard: () => mineSentenceCard(), + mineSentenceCard: () => mineSentenceCardHandler(), startPendingMineSentenceMultiple: (timeoutMs: number) => startPendingMineSentenceMultiple(timeoutMs), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - refreshKnownWordCache: () => refreshKnownWordCache(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + updateLastCardFromClipboard: () => updateLastCardFromClipboardHandler(), + refreshKnownWordCache: () => refreshKnownWordCacheHandler(), + triggerFieldGrouping: () => triggerFieldGroupingHandler(), + triggerSubsyncFromConfig: () => subsyncRuntime.triggerFromConfig(), + markLastCardAsAudioCard: () => markLastCardAsAudioCardHandler(), getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), clearAnilistToken: () => anilistStateRuntime.clearTokenState(), - openAnilistSetupWindow: () => openAnilistSetupWindow(), + openAnilistSetupWindow: () => openAnilistSetupWindowHandler(), openJellyfinSetupWindow: () => openJellyfinSetupWindow(), getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), @@ -6085,9 +5850,9 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request), ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(), openYomitanSettings: () => openYomitanSettings(), - openConfigSettingsWindow: () => openConfigSettingsWindow(), - openSyncUiWindow: () => openSyncUiWindow(), - cycleSecondarySubMode: () => handleCycleSecondarySubMode(), + openConfigSettingsWindow: () => configSettingsRuntime.openWindow(), + openSyncUiWindow: () => openSyncUiWindowHandler(), + cycleSecondarySubMode: () => cycleSecondarySubMode(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), stopApp: () => requestAppQuit(), @@ -6115,7 +5880,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ }, ensureTrayForCommand: (args) => { if (args.background || args.managedPlayback) { - ensureTray(); + ensureTrayHandler(); } }, handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) => @@ -6127,7 +5892,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ shouldEnsureTrayOnStartup: () => shouldEnsureTrayOnStartupForInitialArgs(process.platform, appState.initialArgs), shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args), - ensureTray: () => ensureTray(), + ensureTray: () => ensureTrayHandler(), isTexthookerOnlyMode: () => appState.texthookerOnlyMode, hasImmersionTracker: () => Boolean(appState.immersionTracker), getMpvClient: () => appState.mpvClient, @@ -6188,7 +5953,8 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers< enforceUnsupportedWaylandMode(args); }, shouldStartApp: (args: CliArgs) => shouldStartApp(args), - getDefaultSocketPath: () => getResolvedConfig().mpv.socketPath || getDefaultSocketPath(), + getDefaultSocketPath: () => + configService.getConfig().mpv.socketPath || getDefaultSocketPathHandler(), defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, configDir: CONFIG_DIR, defaultConfig: DEFAULT_CONFIG, @@ -6225,7 +5991,7 @@ const startupModeFlags = getStartupModeFlags(appState.initialArgs); const shouldUseMinimalStartup = startupModeFlags.shouldUseMinimalStartup; const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup; if (!appState.initialArgs || (!shouldUseMinimalStartup && !shouldSkipHeavyStartup)) { - if (isAnilistTrackingEnabled(getResolvedConfig())) { + if (isAnilistTrackingEnabled(configService.getConfig())) { void refreshAnilistClientSecretStateIfEnabled({ force: true, allowSetupPrompt: false, @@ -6243,10 +6009,10 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa createOverlayWindowDeps: { createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), isDev, - ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), - onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), + ensureOverlayWindowLevel: (window) => overlayGeometryRuntime.ensureOverlayWindowLevel(window), + onRuntimeOptionsChanged: () => overlayVisibilityComposer.broadcastRuntimeOptionsChanged(), setOverlayDebugVisualizationEnabled: (enabled) => - setOverlayDebugVisualizationEnabled(enabled), + overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled), isOverlayVisible: (windowKind) => windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false, getYomitanSession: () => appState.yomitanSession, @@ -6257,16 +6023,18 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa shouldRunLinuxOverlayZOrderKeepAlive() && linuxTrackedMpvFullscreen && linuxVisibleOverlayWindowMode === 'fullscreen-override', - onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(), - onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(), + onVisibleWindowBlurred: () => + visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(), + onVisibleWindowFocused: () => + visibleOverlayInteractionRuntime.requestLinuxOverlayZOrderFollow(), onWindowDidFinishLoad: () => { flushQueuedOverlayNotifications(); }, onWindowContentReady: () => { - dismissOverlayLoadingStatusNotification(); + overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); flushQueuedOverlayNotifications(); overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); + visibleOverlayInteractionRuntime.primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); autoplayReadyGate.flushPendingAutoplayReadySignal(); }, onWindowClosed: (windowKind, window) => { @@ -6306,7 +6074,7 @@ function getJellyfinTrayDiscoveryDeps() { refreshTrayMenu: () => refreshTrayMenuIfPresent(), logger, showMpvOsd: (message: string) => - showConfiguredStatusNotification(message, { title: 'Jellyfin' }), + overlayNotificationsRuntime.showConfiguredStatusNotification(message, { title: 'Jellyfin' }), }; } @@ -6329,13 +6097,13 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = openSessionHelpModal: () => openSessionHelpOverlay(), openTexthookerInBrowser: () => handleCliCommand(parseArgs(['--texthooker', '--open-browser'])), - showTexthookerPage: () => shouldShowTexthookerTrayEntry(getResolvedConfig()), + showTexthookerPage: () => shouldShowTexthookerTrayEntry(configService.getConfig()), showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(), openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force), showWindowsMpvLauncherSetup: () => process.platform === 'win32', openYomitanSettings: () => openYomitanSettings(), - openConfigSettingsWindow: () => openConfigSettingsWindow(), - openSyncUiWindow: () => openSyncUiWindow(), + openConfigSettingsWindow: () => configSettingsRuntime.openWindow(), + openSyncUiWindow: () => openSyncUiWindowHandler(), exportLogs: () => { void exportLogsFromTray(); }, @@ -6347,7 +6115,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps(), { desiredActive: checked, }), - openAnilistSetupWindow: () => openAnilistSetupWindow(), + openAnilistSetupWindow: () => openAnilistSetupWindowHandler(), checkForUpdates: () => { void getUpdateService().checkForUpdates({ source: 'manual' }); }, @@ -6377,7 +6145,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), }); const yomitanProfilePolicy = createYomitanProfilePolicy({ - externalProfilePath: getResolvedConfig().yomitan.externalProfilePath, + externalProfilePath: configService.getConfig().yomitan.externalProfilePath, logInfo: (message) => logger.info(message), }); const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath; @@ -6408,7 +6176,7 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({ }, onYomitanExtensionLoaded: () => { const reloaded = reloadOverlayWindowsForYomitanContentScripts( - getOverlayWindows(), + overlayManager.getOverlayWindows(), (message, error) => logger.warn(message, error), ); if (reloaded > 0) { @@ -6446,15 +6214,16 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = registerGlobalShortcuts(); }, createWindowTracker: (override, targetMpvSocketPath) => - createOverlayWindowTracker(override, targetMpvSocketPath), + visibleOverlayInteractionRuntime.createOverlayWindowTracker(override, targetMpvSocketPath), updateVisibleOverlayBounds: (geometry: WindowGeometry) => - updateVisibleOverlayBounds(geometry), - bindOverlayOwner: () => bindVisibleOverlayOwner(), - releaseOverlayOwner: () => releaseVisibleOverlayOwner(), - getOverlayWindows: () => getOverlayWindows(), - getResolvedConfig: () => getResolvedConfig(), + overlayGeometryRuntime.updateVisibleOverlayBounds(geometry), + bindOverlayOwner: () => visibleOverlayInteractionRuntime.bindVisibleOverlayOwner(), + releaseOverlayOwner: () => visibleOverlayInteractionRuntime.releaseVisibleOverlayOwner(), + getOverlayWindows: () => overlayManager.getOverlayWindows(), + getResolvedConfig: () => configService.getConfig(), showDesktopNotification, - showOverlayNotification, + showOverlayNotification: (payload) => + overlayNotificationsRuntime.showOverlayNotification(payload), createFieldGroupingCallback: () => createFieldGroupingCallback(), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), getCachedMediaPath: (currentVideoPath, kind) => @@ -6504,30 +6273,6 @@ const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSetting logError: (message, error) => logger.error(message, error), }); -async function updateLastCardFromClipboard(): Promise { - await updateLastCardFromClipboardHandler(); -} - -async function refreshKnownWordCache(): Promise { - await refreshKnownWordCacheHandler(); -} - -async function triggerFieldGrouping(): Promise { - await triggerFieldGroupingHandler(); -} - -async function markLastCardAsAudioCard(): Promise { - await markLastCardAsAudioCardHandler(); -} - -async function mineSentenceCard(): Promise { - await mineSentenceCardHandler(); -} - -function handleMineSentenceDigit(count: number): void { - handleMineSentenceDigitHandler(count); -} - function ensureOverlayWindowsReadyForVisibilityActions(): void { if (!appState.overlayRuntimeInitialized) { initializeOverlayRuntime(); @@ -6550,19 +6295,19 @@ function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void { function setVisibleOverlayVisible(visible: boolean): void { ensureOverlayWindowsReadyForVisibilityActions(); if (!visible) { - dismissOverlayLoadingStatusNotification(); + overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); autoplayReadyGate.markCurrentMediaAutoplayReady(); - cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); + autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); - resetVisibleOverlayInputState(); + visibleOverlayInteractionRuntime.resetVisibleOverlayInputState(); } if (visible) { maybeStartOverlayLoadingOsd(); - resetLinuxVisibleOverlayStartupInputPrimer(); - startLinuxVisibleOverlayStartupInputGrace(); - restoreVisibleOverlayWindowShapeForShow(); + visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer(); + visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace(); + visibleOverlayInteractionRuntime.restoreVisibleOverlayWindowShapeForShow(); void ensureOverlayMpvSubtitlesHidden(); - void primeCurrentSubtitleForVisibleOverlay(); + void autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay(); } setVisibleOverlayVisibleHandler(visible); notifyMpvPluginVisibleOverlayVisibility(visible); @@ -6573,18 +6318,18 @@ function toggleVisibleOverlay(): void { ensureOverlayWindowsReadyForVisibilityActions(); const nextVisible = !overlayManager.getVisibleOverlayVisible(); if (!nextVisible) { - dismissOverlayLoadingStatusNotification(); + overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); autoplayReadyGate.markCurrentMediaAutoplayReady(); - cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); + autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); - resetVisibleOverlayInputState(); + visibleOverlayInteractionRuntime.resetVisibleOverlayInputState(); } else { maybeStartOverlayLoadingOsd(); - resetLinuxVisibleOverlayStartupInputPrimer(); - startLinuxVisibleOverlayStartupInputGrace(); - restoreVisibleOverlayWindowShapeForShow(); + visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer(); + visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace(); + visibleOverlayInteractionRuntime.restoreVisibleOverlayWindowShapeForShow(); void ensureOverlayMpvSubtitlesHidden(); - void primeCurrentSubtitleForVisibleOverlay(); + void autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay(); } toggleVisibleOverlayHandler(); notifyMpvPluginVisibleOverlayVisibility(nextVisible); @@ -6592,30 +6337,23 @@ function toggleVisibleOverlay(): void { } function setOverlayVisible(visible: boolean): void { if (!visible) { - dismissOverlayLoadingStatusNotification(); - cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); - resetVisibleOverlayInputState(); + overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); + autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); + visibleOverlayInteractionRuntime.resetVisibleOverlayInputState(); autoplayReadyGate.markCurrentMediaAutoplayReady(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); } if (visible) { maybeStartOverlayLoadingOsd(); - resetLinuxVisibleOverlayStartupInputPrimer(); - startLinuxVisibleOverlayStartupInputGrace(); - restoreVisibleOverlayWindowShapeForShow(); + visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer(); + visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace(); + visibleOverlayInteractionRuntime.restoreVisibleOverlayWindowShapeForShow(); void ensureOverlayMpvSubtitlesHidden(); - void primeCurrentSubtitleForVisibleOverlay(); + void autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay(); } setOverlayVisibleHandler(visible); notifyMpvPluginVisibleOverlayVisibility(visible); syncOverlayMpvSubtitleSuppression(); } -function handleOverlayModalClosed(modal: OverlayHostedModal): void { - handleOverlayModalClosedHandler(modal); -} - -function appendClipboardVideoToQueue(): { ok: boolean; message: string } { - return appendClipboardVideoToQueueHandler(); -} registerIpcRuntimeHandlers(); diff --git a/src/main/appimage-mount-keepalive.test.ts b/src/main/appimage-mount-keepalive.test.ts index ed350f59..8fcfbdc3 100644 --- a/src/main/appimage-mount-keepalive.test.ts +++ b/src/main/appimage-mount-keepalive.test.ts @@ -52,7 +52,13 @@ function runKeepaliveScript( return new Promise((resolve, reject) => { execFile( '/bin/sh', - ['-c', APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, APPIMAGE_MOUNT_KEEPALIVE_LABEL, appImagePath, ...extraArgs], + [ + '-c', + APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, + APPIMAGE_MOUNT_KEEPALIVE_LABEL, + appImagePath, + ...extraArgs, + ], { timeout: 30_000 }, (error) => { if (error && typeof error.code !== 'number') { @@ -69,113 +75,107 @@ function writeExecutable(filePath: string, content: string): void { fs.writeFileSync(filePath, content, { mode: 0o755 }); } -test( - 'keepalive script releases the mount only after straggler processes exit', - { skip: process.platform !== 'linux' }, - async () => { - const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-')); - const resultsDir = path.join(workDir, 'results'); - fs.mkdirSync(resultsDir); - const mountDir = path.join(workDir, 'fake-mount'); - fs.mkdirSync(mountDir); +const linuxTest = process.platform === 'linux' ? test : test.skip; - // AppRun leaves behind a straggler that keeps executing *from the mount* - // after AppRun itself exits — mimicking Chromium utility children. - fs.copyFileSync('/usr/bin/sleep', path.join(mountDir, 'straggler')); - fs.chmodSync(path.join(mountDir, 'straggler'), 0o755); - writeExecutable( - path.join(mountDir, 'AppRun'), - [ - '#!/bin/sh', - `"${mountDir}/straggler" 1 &`, - `date +%s%N > "${resultsDir}/apprun-exited"`, - 'exit 42', - ].join('\n'), +linuxTest('keepalive script releases the mount only after straggler processes exit', async () => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-')); + const resultsDir = path.join(workDir, 'results'); + fs.mkdirSync(resultsDir); + const mountDir = path.join(workDir, 'fake-mount'); + fs.mkdirSync(mountDir); + + // AppRun leaves behind a straggler that keeps executing *from the mount* + // after AppRun itself exits — mimicking Chromium utility children. + fs.copyFileSync('/usr/bin/sleep', path.join(mountDir, 'straggler')); + fs.chmodSync(path.join(mountDir, 'straggler'), 0o755); + writeExecutable( + path.join(mountDir, 'AppRun'), + [ + '#!/bin/sh', + `"${mountDir}/straggler" 1 &`, + `date +%s%N > "${resultsDir}/apprun-exited"`, + 'exit 42', + ].join('\n'), + ); + + const fakeAppImage = path.join(workDir, 'Fake.AppImage'); + writeExecutable( + fakeAppImage, + [ + '#!/bin/sh', + 'if [ "${1:-}" = "--appimage-mount" ]; then', + ` echo "${mountDir}"`, + ` trap ': > "${resultsDir}/holder-released"; sleep 0.1; date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`, + ' while :; do sleep 0.05; done', + 'fi', + `date +%s%N > "${resultsDir}/direct-run"`, + 'exit 0', + ].join('\n'), + ); + + try { + const { status } = await runKeepaliveScript(fakeAppImage); + + assert.equal(status, 42, 'exit code of AppRun must be propagated'); + assert.ok( + !fs.existsSync(path.join(resultsDir, 'direct-run')), + 'must not fall back to direct AppImage run when mount succeeds', ); - - const fakeAppImage = path.join(workDir, 'Fake.AppImage'); - writeExecutable( - fakeAppImage, - [ - '#!/bin/sh', - 'if [ "${1:-}" = "--appimage-mount" ]; then', - ` echo "${mountDir}"`, - ` trap ': > "${resultsDir}/holder-released"; sleep 0.1; date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`, - ' while :; do sleep 0.05; done', - 'fi', - `date +%s%N > "${resultsDir}/direct-run"`, - 'exit 0', - ].join('\n'), - ); - - try { - const { status } = await runKeepaliveScript(fakeAppImage); - - assert.equal(status, 42, 'exit code of AppRun must be propagated'); - assert.ok( - !fs.existsSync(path.join(resultsDir, 'direct-run')), - 'must not fall back to direct AppImage run when mount succeeds', - ); - // The script does not wait for the holder to finish handling SIGTERM - // (the real runtime unmounts on its own after the signal), so poll. - const releasedMarker = path.join(resultsDir, 'holder-released'); - const pollDeadline = Date.now() + 2000; - let holderReleased: number | null = null; - while (holderReleased === null && Date.now() < pollDeadline) { - if (fs.existsSync(releasedMarker)) { - const timestamp = fs.readFileSync(releasedMarker, 'utf8').trim(); - if (/^\d+$/.test(timestamp)) holderReleased = Number(timestamp); - } - if (holderReleased !== null) break; - await new Promise((r) => setTimeout(r, 25)); + // The script does not wait for the holder to finish handling SIGTERM + // (the real runtime unmounts on its own after the signal), so poll. + const releasedMarker = path.join(resultsDir, 'holder-released'); + const pollDeadline = Date.now() + 2000; + let holderReleased: number | null = null; + while (holderReleased === null && Date.now() < pollDeadline) { + if (fs.existsSync(releasedMarker)) { + const timestamp = fs.readFileSync(releasedMarker, 'utf8').trim(); + if (/^\d+$/.test(timestamp)) holderReleased = Number(timestamp); } - assert.ok(holderReleased !== null, 'holder release timestamp must be recorded'); - - const appRunExited = Number( - fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(), - ); - const drainNs = holderReleased - appRunExited; - assert.ok( - drainNs >= 0.8e9, - `holder must outlive the 1s straggler (drained after ${drainNs / 1e9}s)`, - ); - } finally { - fs.rmSync(workDir, { recursive: true, force: true }); + if (holderReleased !== null) break; + await new Promise((r) => setTimeout(r, 25)); } - }, -); + assert.ok(holderReleased !== null, 'holder release timestamp must be recorded'); -test( - 'keepalive script falls back to direct run when --appimage-mount fails', - { skip: process.platform !== 'linux' }, - async () => { - const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-')); - const resultsDir = path.join(workDir, 'results'); - fs.mkdirSync(resultsDir); - - const fakeAppImage = path.join(workDir, 'Fake.AppImage'); - writeExecutable( - fakeAppImage, - [ - '#!/bin/sh', - 'if [ "${1:-}" = "--appimage-mount" ]; then', - ' exit 1', - 'fi', - `printf '%s\\n' "$@" > "${resultsDir}/direct-run"`, - 'exit 7', - ].join('\n'), + const appRunExited = Number( + fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(), ); + const drainNs = holderReleased - appRunExited; + assert.ok( + drainNs >= 0.8e9, + `holder must outlive the 1s straggler (drained after ${drainNs / 1e9}s)`, + ); + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } +}); - try { - const { status } = await runKeepaliveScript(fakeAppImage, ['--start', '--background']); - assert.equal(status, 7, 'direct-run exit code must be propagated'); - assert.equal( - fs.readFileSync(path.join(resultsDir, 'direct-run'), 'utf8'), - '--start\n--background\n', - 'launch args must be forwarded to the direct run', - ); - } finally { - fs.rmSync(workDir, { recursive: true, force: true }); - } - }, -); +linuxTest('keepalive script falls back to direct run when --appimage-mount fails', async () => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-')); + const resultsDir = path.join(workDir, 'results'); + fs.mkdirSync(resultsDir); + + const fakeAppImage = path.join(workDir, 'Fake.AppImage'); + writeExecutable( + fakeAppImage, + [ + '#!/bin/sh', + 'if [ "${1:-}" = "--appimage-mount" ]; then', + ' exit 1', + 'fi', + `printf '%s\\n' "$@" > "${resultsDir}/direct-run"`, + 'exit 7', + ].join('\n'), + ); + + try { + const { status } = await runKeepaliveScript(fakeAppImage, ['--start', '--background']); + assert.equal(status, 7, 'direct-run exit code must be propagated'); + assert.equal( + fs.readFileSync(path.join(resultsDir, 'direct-run'), 'utf8'), + '--start\n--background\n', + 'launch args must be forwarded to the direct run', + ); + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } +}); diff --git a/src/main/main-runtime-direct-wiring.test.ts b/src/main/main-runtime-direct-wiring.test.ts new file mode 100644 index 00000000..d1cdd468 --- /dev/null +++ b/src/main/main-runtime-direct-wiring.test.ts @@ -0,0 +1,99 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +test('main process call sites bypass zero-logic pass-through wrappers', () => { + const sourcePath = path.join(process.cwd(), 'src/main.ts'); + const source = fs.readFileSync(sourcePath, 'utf8'); + const sourceFile = ts.createSourceFile( + sourcePath, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const offenders: string[] = []; + + for (const statement of sourceFile.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) { + if (isRuntimePassthrough(statement, statement.body)) { + offenders.push(statement.name.text); + } + continue; + } + + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.initializer && + (ts.isArrowFunction(declaration.initializer) || + ts.isFunctionExpression(declaration.initializer)) && + isRuntimePassthrough(declaration.initializer, declaration.initializer.body) + ) { + offenders.push(declaration.name.text); + } + } + } + + assert.deepEqual(offenders, []); +}); + +function isRuntimePassthrough( + callable: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression, + body: ts.ConciseBody, +): boolean { + let expression: ts.Expression | undefined; + if (!ts.isBlock(body)) { + expression = body; + } else { + if (body.statements.length !== 1) return false; + const statement = body.statements[0]!; + if (ts.isExpressionStatement(statement)) expression = statement.expression; + if (ts.isReturnStatement(statement)) expression = statement.expression; + } + if (!expression) return false; + if (ts.isAwaitExpression(expression) || ts.isVoidExpression(expression)) { + expression = expression.expression; + } + if (!ts.isCallExpression(expression)) return false; + + const root = getCalleeRoot(expression.expression); + if (!root) return false; + if (callable.parameters.length !== expression.arguments.length) return false; + + return callable.parameters.every((parameter, index) => { + if (!ts.isIdentifier(parameter.name)) return false; + const argument = expression.arguments[index]; + if (!argument) return false; + if (parameter.dotDotDotToken) { + if (!ts.isSpreadElement(argument)) return false; + const spreadExpression = unwrapTransparentExpression(argument.expression); + return ts.isIdentifier(spreadExpression) && spreadExpression.text === parameter.name.text; + } + const argumentExpression = unwrapTransparentExpression(argument); + return ts.isIdentifier(argumentExpression) && argumentExpression.text === parameter.name.text; + }); +} + +function unwrapTransparentExpression(expression: ts.Expression): ts.Expression { + let current = expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isNonNullExpression(current) + ) { + current = current.expression; + } + return current; +} + +function getCalleeRoot(expression: ts.LeftHandSideExpression): string { + let current: ts.Expression = expression; + while (ts.isPropertyAccessExpression(current) || ts.isCallExpression(current)) { + current = current.expression; + } + return ts.isIdentifier(current) ? current.text : ''; +} diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 3fff4289..728964bf 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -446,7 +446,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge )?.groups?.body; assert.ok(actionBlock); - assert.match(actionBlock, /const trackedGeometry = getCurrentTrackedOverlayGeometry\(\);/); + assert.match( + actionBlock, + /const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/, + ); assert.match(actionBlock, /if \(trackedGeometry\) \{/); assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/); assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/); @@ -556,11 +559,60 @@ test('YouTube media cache lifecycle routes through configured status notificatio ); assert.match(cacheBlock, /variant:\s*'success'/); assert.match(cacheBlock, /notifyNoQueued:\s*false/); - assert.match(startCacheBlock, /mode:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.mode/); assert.match( startCacheBlock, - /maxHeight:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.maxHeight/, + /const mediaCacheConfig = configService\.getConfig\(\)\.youtube\.mediaCache;/, ); + assert.match(startCacheBlock, /mode:\s*mediaCacheConfig\.mode/); + assert.match(startCacheBlock, /maxHeight:\s*mediaCacheConfig\.maxHeight/); +}); + +test('subtitle broadcasts share one frequency options snapshot per emitted payload', () => { + const source = readMainSource(); + const emitBlock = source.match( + /function emitSubtitlePayload\(payload: SubtitleData\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + const frequencyOptionsSnapshot = emitBlock?.match( + /const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;(?[\s\S]*?)\n \};/, + )?.[0]; + + assert.ok(emitBlock); + assert.ok(frequencyOptionsSnapshot); + assert.match(frequencyOptionsSnapshot, /const frequencyOptions = \{/); + assert.match(frequencyOptionsSnapshot, /enabled:\s*frequencyDictionary\.enabled/); + assert.match(frequencyOptionsSnapshot, /topX:\s*frequencyDictionary\.topX/); + assert.match(frequencyOptionsSnapshot, /mode:\s*frequencyDictionary\.mode/); + assert.equal((frequencyOptionsSnapshot.match(/configService\.getConfig\(\)/g) ?? []).length, 1); + assert.match(emitBlock, /subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/); + assert.match( + emitBlock, + /annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/, + ); +}); + +test('websocket frequency options callbacks each read one configuration snapshot', () => { + const source = readMainSource(); + const subtitleBlock = source.match( + /startSubtitleWebsocket:\s*\(port: number\)\s*=>\s*\{(?[\s\S]*?)\n \},\n startAnnotationWebsocket:/, + )?.groups?.body; + const annotationBlock = source.match( + /startAnnotationWebsocket:\s*\(port: number\)\s*=>\s*\{(?[\s\S]*?)\n \},\n startTexthooker:/, + )?.groups?.body; + const subtitleFrequencyOptionsCallback = subtitleBlock?.match( + /(?\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/, + )?.groups?.body; + const annotationFrequencyOptionsCallback = annotationBlock?.match( + /(?\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/, + )?.groups?.body; + + assert.ok(subtitleFrequencyOptionsCallback); + assert.ok(annotationFrequencyOptionsCallback); + for (const callback of [subtitleFrequencyOptionsCallback, annotationFrequencyOptionsCallback]) { + assert.match(callback, /return \{[\s\S]*enabled:\s*frequencyDictionary\.enabled/); + assert.match(callback, /topX:\s*frequencyDictionary\.topX/); + assert.match(callback, /mode:\s*frequencyDictionary\.mode/); + assert.equal((callback.match(/configService\.getConfig\(\)/g) ?? []).length, 1); + } }); test('mpv connection flushes queued configured OSD notifications', () => { @@ -587,11 +639,11 @@ test('manual visible overlay show primes current subtitle from mpv before relyin assert.ok(toggleBlock); assert.match( setBlock, - /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, + /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/, ); assert.match( toggleBlock, - /else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, + /else \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/, ); }); @@ -612,7 +664,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape' assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/); assert.match( setBlock, - /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, + /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, ); }); @@ -663,14 +715,20 @@ test('Linux visible overlay show starts input grace before first measurement', ( for (const block of [setVisibleBlock, toggleBlock, setOverlayBlock]) { assert.ok(block); - assert.ok( - block.indexOf('resetLinuxVisibleOverlayStartupInputPrimer();') < - block.indexOf('startLinuxVisibleOverlayStartupInputGrace();'), + const resetIndex = block.indexOf( + 'visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer();', ); - assert.ok( - block.indexOf('startLinuxVisibleOverlayStartupInputGrace();') < - block.indexOf('void primeCurrentSubtitleForVisibleOverlay();'), + const graceIndex = block.indexOf( + 'visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace();', ); + const primeIndex = block.indexOf( + 'void autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay();', + ); + assert.ok(resetIndex >= 0); + assert.ok(graceIndex >= 0); + assert.ok(primeIndex >= 0); + assert.ok(resetIndex < graceIndex); + assert.ok(graceIndex < primeIndex); } }); diff --git a/src/main/runtime/sync-launcher-client.test.ts b/src/main/runtime/sync-launcher-client.test.ts index b100026d..3c08640d 100644 --- a/src/main/runtime/sync-launcher-client.test.ts +++ b/src/main/runtime/sync-launcher-client.test.ts @@ -147,10 +147,7 @@ test('runSyncLauncher settles on exit when the terminal event is already parsed' onEvent: () => {}, spawn, }); - children[0]!.stdout.emit( - 'data', - Buffer.from('{"type":"result","ok":true,"error":null}\n'), - ); + children[0]!.stdout.emit('data', Buffer.from('{"type":"result","ok":true,"error":null}\n')); children[0]!.emit('exit', 0, null); const result = await Promise.race([ diff --git a/src/shared/sync/sync-events.ts b/src/shared/sync/sync-events.ts index e10031aa..122c0789 100644 --- a/src/shared/sync/sync-events.ts +++ b/src/shared/sync/sync-events.ts @@ -106,7 +106,8 @@ export function parseSyncProgressLine(line: string): SyncProgressEvent | null { ? (parsed as SyncProgressEvent) : null; case 'merge-summary': - return (event.target === 'local' || event.target === 'remote') && isMergeSummary(event.summary) + return (event.target === 'local' || event.target === 'remote') && + isMergeSummary(event.summary) ? (parsed as SyncProgressEvent) : null; case 'remote-output': diff --git a/src/tsukihime/lang.ts b/src/tsukihime/lang.ts index c13877b4..b8e4e7d0 100644 --- a/src/tsukihime/lang.ts +++ b/src/tsukihime/lang.ts @@ -60,9 +60,7 @@ export function tsukihimeTrackMatchesLanguages( export function describeTsukihimeTabLanguages(configuredLanguages: string[]): string { const normalized = [ ...new Set( - configuredLanguages - .map((candidate) => normalizeTsukihimeLangCode(candidate)) - .filter(Boolean), + configuredLanguages.map((candidate) => normalizeTsukihimeLangCode(candidate)).filter(Boolean), ), ]; if (normalized.length === 0) return 'English'; diff --git a/src/tsukihime/utils.ts b/src/tsukihime/utils.ts index d40c1137..da643d0c 100644 --- a/src/tsukihime/utils.ts +++ b/src/tsukihime/utils.ts @@ -76,10 +76,7 @@ function asFiniteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } -export function mapTsukihimeSearchResults( - payload: unknown, - maxResults: number, -): TsukihimeEntry[] { +export function mapTsukihimeSearchResults(payload: unknown, maxResults: number): TsukihimeEntry[] { if (!isObject(payload) || !Array.isArray(payload.results)) return []; const entries: TsukihimeEntry[] = []; for (const item of payload.results) {