diff --git a/backlog/tasks/task-238.8 - Refactor-src-main.ts-composition-root-into-domain-runtimes.md b/backlog/tasks/task-238.8 - Refactor-src-main.ts-composition-root-into-domain-runtimes.md new file mode 100644 index 00000000..12e5c6d6 --- /dev/null +++ b/backlog/tasks/task-238.8 - Refactor-src-main.ts-composition-root-into-domain-runtimes.md @@ -0,0 +1,36 @@ +--- +id: TASK-238.8 +title: Refactor src/main.ts composition root into domain runtimes +status: To Do +assignee: [] +created_date: '2026-03-31 06:28' +labels: + - tech-debt + - runtime + - maintainability + - composition-root +dependencies: [] +references: + - src/main.ts + - src/main/boot/services + - src/main/runtime/composers + - docs/architecture/README.md +parent_task_id: TASK-238 +priority: high +--- + +## Description + + +Refactor `src/main.ts` so it becomes a thin composition root and the domain-specific runtime wiring moves into short wrapper modules under `src/main/`. Preserve all current behavior, IPC contracts, and config/schema semantics while reducing the entrypoint to boot services, grouped runtime instantiation, startup execution, and process-level quit handling. + + +## Acceptance Criteria + +- [ ] #1 `src/main.ts` is bootstrap/composition only: platform preflight, boot services, runtime creation, startup execution, and top-level quit/signal handling. +- [ ] #2 `src/main.ts` no longer imports `src/main/runtime/*-main-deps.ts` directly. +- [ ] #3 `src/main.ts` has no local names like `build*MainDepsHandler`, `*MainDeps`, or trivial `*Handler` pass-through wrappers. +- [ ] #4 New wrapper files stay under ~500 LOC each; if one exceeds that, split before merge. +- [ ] #5 Cross-domain coordination stays in `main.ts`; wrapper modules stay acyclic and communicate via injected callbacks. +- [ ] #6 No user-facing behavior, config fields, or IPC channel names change. + diff --git a/backlog/tasks/task-262 - Create-overlay-UI-bootstrap-input-helper.md b/backlog/tasks/task-262 - Create-overlay-UI-bootstrap-input-helper.md new file mode 100644 index 00000000..45c2e412 --- /dev/null +++ b/backlog/tasks/task-262 - Create-overlay-UI-bootstrap-input-helper.md @@ -0,0 +1,27 @@ +--- +id: TASK-262 +title: Create overlay UI bootstrap input helper +status: To Do +assignee: [] +created_date: '2026-03-31 17:04' +labels: + - refactor + - main + - overlay-ui +dependencies: [] +--- + +## Description + + +Add a coarse input-builder/helper module to reduce the large createOverlayUiRuntime(...) callsite in src/main.ts without changing runtime behavior. Do not edit src/main.ts in this task. + + +## Acceptance Criteria + +- [ ] #1 New helper module(s) live under src/main/ +- [ ] #2 Helper accepts grouped overlay UI/domain inputs instead of giant inline literals +- [ ] #3 Helper keeps files under 500 LOC +- [ ] #4 Optional focused tests added if useful +- [ ] #5 No runtime behavior changes + diff --git a/backlog/tasks/task-263 - Create-coarse-startup-bootstrap-wrapper.md b/backlog/tasks/task-263 - Create-coarse-startup-bootstrap-wrapper.md new file mode 100644 index 00000000..f68a9336 --- /dev/null +++ b/backlog/tasks/task-263 - Create-coarse-startup-bootstrap-wrapper.md @@ -0,0 +1,28 @@ +--- +id: TASK-263 +title: Create coarse startup bootstrap wrapper +status: To Do +assignee: [] +created_date: '2026-03-31 17:21' +labels: + - refactor + - main + - startup +dependencies: [] +--- + +## Description + + +Move the large createMainStartupRuntime construction and its self-reference handling out of src/main.ts into a coarse startup bootstrap wrapper. Keep behavior identical and shrink the startup section materially. + + +## Acceptance Criteria + +- [ ] #1 New wrapper module(s) live under src/main/ +- [ ] #2 Wrapper owns createMainStartupRuntime construction and self-reference handling +- [ ] #3 src/main.ts startup section becomes materially smaller +- [ ] #4 Files stay under 500 LOC +- [ ] #5 Focused tests cover the wrapper if useful +- [ ] #6 No runtime behavior changes + diff --git a/changes/260-main-runtime-refactor.md b/changes/260-main-runtime-refactor.md new file mode 100644 index 00000000..3113f853 --- /dev/null +++ b/changes/260-main-runtime-refactor.md @@ -0,0 +1,6 @@ +type: internal +area: main + +- Split `src/main.ts` into domain runtime wrappers and startup sequencing helpers. +- Removed the last direct `src/main/runtime/*-main-deps.ts` imports from `src/main.ts`. +- Kept startup behavior and IPC contracts stable while reducing composition-root size. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c3841765..b8bc49df 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -3,7 +3,7 @@ # Architecture Map Status: active -Last verified: 2026-03-26 +Last verified: 2026-03-31 Owner: Kyle Yasuda Read when: runtime ownership, composition boundaries, or layering questions @@ -24,6 +24,27 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into ## Current Shape - `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters. +- `src/main/*.ts` wrapper runtimes sit between `src/main.ts` and `src/main/runtime/**` + so the composition root stays thin while exported `createBuild*MainDepsHandler` + APIs remain internal plumbing. Key domain runtimes: + - `anilist-runtime` – AniList token management, media tracking, retry queue + - `cli-startup-runtime` – CLI command dispatch and initial-args handling + - `discord-presence-lifecycle-runtime` – Discord Rich Presence lifecycle + - `first-run-runtime` – first-run setup wizard + - `ipc-runtime` – IPC handler registration and composition + - `jellyfin-runtime` – Jellyfin session, playback, mpv orchestration + - `main-startup-runtime` – top-level startup orchestration (app-ready → CLI → headless) + - `main-startup-bootstrap` – wiring helper that builds startup runtime inputs + - `mining-runtime` – Anki card mining actions + - `mpv-runtime` – mpv client lifecycle + - `overlay-ui-runtime` – overlay window management, visibility, tray + - `overlay-geometry-runtime` – overlay bounds resolution + - `shortcuts-runtime` – global shortcut registration + - `startup-sequence-runtime` – headless known-word refresh and deferred startup sequencing + - `stats-runtime` – immersion tracker, stats server, stats CLI + - `subtitle-runtime` – subtitle prefetch, tokenization, caching + - `youtube-runtime` – YouTube playback flow + - `yomitan-runtime` – Yomitan extension loading and settings - `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection. - `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic. - `src/renderer/` owns overlay rendering and input behavior. diff --git a/src/main.ts b/src/main.ts index a1645ded..8b15999a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,72 +23,11 @@ import { shell, protocol, Extension, - Session, - Menu, - nativeImage, Tray, dialog, screen, } from 'electron'; import { applyControllerConfigUpdate } from './main/controller-config-update.js'; -import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open'; -import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js'; -import { mergeAiConfig } from './ai/config'; - -function getPasswordStoreArg(argv: string[]): string | null { - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg?.startsWith('--password-store')) { - continue; - } - - if (arg === '--password-store') { - const value = argv[i + 1]; - if (value && !value.startsWith('--')) { - return value; - } - return null; - } - - const [prefix, value] = arg.split('=', 2); - if (prefix === '--password-store' && value && value.trim().length > 0) { - return value.trim(); - } - } - return null; -} - -function normalizePasswordStoreArg(value: string): string { - const normalized = value.trim(); - if (normalized.toLowerCase() === 'gnome') { - return 'gnome-libsecret'; - } - return normalized; -} - -function getDefaultPasswordStore(): string { - return 'gnome-libsecret'; -} - -function getStartupModeFlags(initialArgs: CliArgs | null | undefined): { - shouldUseMinimalStartup: boolean; - shouldSkipHeavyStartup: boolean; -} { - return { - shouldUseMinimalStartup: Boolean( - (initialArgs && isStandaloneTexthookerCommand(initialArgs)) || - (initialArgs?.stats && - (initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)), - ), - shouldSkipHeavyStartup: Boolean( - initialArgs && - (shouldRunSettingsOnlyStartup(initialArgs) || - initialArgs.stats || - initialArgs.dictionary || - initialArgs.setup), - ), - }; -} protocol.registerSchemesAsPrivileged([ { @@ -107,48 +46,37 @@ import * as fs from 'fs'; import { spawn } from 'node:child_process'; import * as os from 'os'; import * as path from 'path'; -import { MecabTokenizer } from './mecab-tokenizer'; import type { JimakuApiResponse, KikuFieldGroupingChoice, MpvSubtitleRenderMetrics, ResolvedConfig, - RuntimeOptionState, - SecondarySubMode, - SubtitleData, - SubtitlePosition, - WindowGeometry, } from './types'; import { AnkiIntegration } from './anki-integration'; import { SubtitleTimingTracker } from './subtitle-timing-tracker'; -import { RuntimeOptionsManager } from './runtime-options'; -import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils'; -import { createLogger, setLogLevel, resolveDefaultLogFilePath, type LogLevelSource } from './logger'; -import { createWindowTracker as createWindowTrackerCore } from './window-trackers'; +import { isRemoteMediaPath } from './jimaku/utils'; +import { + createLogger, + setLogLevel, + resolveDefaultLogFilePath, + type LogLevelSource, +} from './logger'; import { commandNeedsOverlayStartupPrereqs, commandNeedsOverlayRuntime, isHeadlessInitialCommand, - isStandaloneTexthookerCommand, parseArgs, - shouldRunSettingsOnlyStartup, shouldStartApp, type CliArgs, type CliCommandSource, } from './cli/args'; import { printHelp } from './cli/help'; import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts'; -import { - buildConfigParseErrorDetails, - buildConfigWarningDialogDetails, - buildConfigWarningNotificationBody, - failStartupFromConfig, -} from './main/config-validation'; +import { buildConfigParseErrorDetails, failStartupFromConfig } from './main/config-validation'; import { buildAnilistSetupUrl, consumeAnilistSetupCallbackUrl, createAnilistStateRuntime, - createBuildOpenAnilistSetupWindowMainDepsHandler, createMaybeFocusExistingAnilistSetupWindowHandler, createOpenAnilistSetupWindowHandler, findAnilistSetupDeepLinkArgvUrl, @@ -158,111 +86,16 @@ import { rememberAnilistAttemptedUpdateKey, } from './main/runtime/domains/anilist'; import { DEFAULT_MIN_WATCH_RATIO } from './shared/watch-threshold'; +import { buildJellyfinSetupFormHtml } from './main/runtime/domains/jellyfin'; import { - createApplyJellyfinMpvDefaultsHandler, - createBuildApplyJellyfinMpvDefaultsMainDepsHandler, - createBuildGetDefaultSocketPathMainDepsHandler, - createGetDefaultSocketPathHandler, - buildJellyfinSetupFormHtml, - parseJellyfinSetupSubmissionUrl, - getConfiguredJellyfinSession, - type ActiveJellyfinRemotePlaybackState, -} from './main/runtime/domains/jellyfin'; -import { - createBuildConfigHotReloadMessageMainDepsHandler, - createBuildConfigHotReloadAppliedMainDepsHandler, - createBuildConfigHotReloadRuntimeMainDepsHandler, - createBuildWatchConfigPathMainDepsHandler, - createWatchConfigPathHandler, - createBuildOverlayContentMeasurementStoreMainDepsHandler, - createBuildOverlayModalRuntimeMainDepsHandler, - createBuildAppendClipboardVideoToQueueMainDepsHandler, - createBuildHandleOverlayModalClosedMainDepsHandler, - createBuildLoadSubtitlePositionMainDepsHandler, - createBuildSaveSubtitlePositionMainDepsHandler, - createBuildFieldGroupingOverlayMainDepsHandler, - createBuildGetFieldGroupingResolverMainDepsHandler, - createBuildSetFieldGroupingResolverMainDepsHandler, - createBuildOverlayVisibilityRuntimeMainDepsHandler, - createBuildBroadcastRuntimeOptionsChangedMainDepsHandler, - createBuildGetRuntimeOptionsStateMainDepsHandler, - createBuildOpenRuntimeOptionsPaletteMainDepsHandler, - createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler, - createBuildSendToActiveOverlayWindowMainDepsHandler, - createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler, - createBuildEnforceOverlayLayerOrderMainDepsHandler, - createBuildEnsureOverlayWindowLevelMainDepsHandler, - createBuildUpdateVisibleOverlayBoundsMainDepsHandler, - createTrayRuntimeHandlers, - createOverlayVisibilityRuntime, createBroadcastRuntimeOptionsChangedHandler, - createGetRuntimeOptionsStateHandler, - createGetFieldGroupingResolverHandler, - createSetFieldGroupingResolverHandler, - createOpenRuntimeOptionsPaletteHandler, - createRestorePreviousSecondarySubVisibilityHandler, - createSendToActiveOverlayWindowHandler, - createSetOverlayDebugVisualizationEnabledHandler, - createEnforceOverlayLayerOrderHandler, - createEnsureOverlayWindowLevelHandler, - createUpdateVisibleOverlayBoundsHandler, - createLoadSubtitlePositionHandler, - createSaveSubtitlePositionHandler, - createAppendClipboardVideoToQueueHandler, - createHandleOverlayModalClosedHandler, - createConfigHotReloadMessageHandler, - createConfigHotReloadAppliedHandler, - buildTrayMenuTemplateRuntime, - resolveTrayIconPathRuntime, - createYomitanExtensionRuntime, - createYomitanSettingsRuntime, - buildRestartRequiredConfigMessage, resolveSubtitleStyleForRenderer, } from './main/runtime/domains/overlay'; -import { - createBuildAnilistStateRuntimeMainDepsHandler, - createBuildConfigDerivedRuntimeMainDepsHandler, - createBuildImmersionMediaRuntimeMainDepsHandler, - createBuildMainSubsyncRuntimeMainDepsHandler, - createBuildSubtitleProcessingControllerMainDepsHandler, - createBuildMediaRuntimeMainDepsHandler, - createBuildDictionaryRootsMainHandler, - createBuildFrequencyDictionaryRootsMainHandler, - createBuildFrequencyDictionaryRuntimeMainDepsHandler, - createBuildJlptDictionaryRuntimeMainDepsHandler, - createImmersionMediaRuntime, - createConfigDerivedRuntime, - appendClipboardVideoToQueueRuntime, - createMainSubsyncRuntime, -} from './main/runtime/domains/startup'; -import { - createMpvOsdRuntimeHandlers, - createCycleSecondarySubModeRuntimeHandler, -} from './main/runtime/domains/mpv'; -import { - createBuildCopyCurrentSubtitleMainDepsHandler, - createBuildHandleMineSentenceDigitMainDepsHandler, - createBuildHandleMultiCopyDigitMainDepsHandler, - createBuildMarkLastCardAsAudioCardMainDepsHandler, - createBuildMineSentenceCardMainDepsHandler, - createBuildRefreshKnownWordCacheMainDepsHandler, - createBuildTriggerFieldGroupingMainDepsHandler, - createBuildUpdateLastCardFromClipboardMainDepsHandler, - createMarkLastCardAsAudioCardHandler, - createMineSentenceCardHandler, - createRefreshKnownWordCacheHandler, - createTriggerFieldGroupingHandler, - createUpdateLastCardFromClipboardHandler, - createCopyCurrentSubtitleHandler, - createHandleMineSentenceDigitHandler, - createHandleMultiCopyDigitHandler, -} from './main/runtime/domains/mining'; import { enforceUnsupportedWaylandMode, forceX11Backend, generateDefaultConfigFile, resolveConfiguredShortcuts, - resolveKeybindings, showDesktopNotification, } from './core/utils'; import { @@ -271,40 +104,14 @@ import { resolveDefaultMpvInstallPaths, } from './shared/setup-state'; import { - ImmersionTrackerService, - JellyfinRemoteSessionService, MpvIpcClient, - SubtitleWebSocket, Texthooker, - applyMpvSubtitleRenderMetricsPatch, - authenticateWithPasswordRuntime, - broadcastRuntimeOptionsChangedRuntime, copyCurrentSubtitle as copyCurrentSubtitleCore, - createConfigHotReloadRuntime, - createDiscordPresenceService, - createShiftSubtitleDelayToAdjacentCueHandler, - createFieldGroupingOverlayRuntime, - createOverlayContentMeasurementStore, - createOverlayManager, - createOverlayWindow as createOverlayWindowCore, - createSubtitleProcessingController, - createTokenizerDepsRuntime, - cycleSecondarySubMode as cycleSecondarySubModeCore, deleteYomitanDictionaryByTitle, - enforceOverlayLayerOrder as enforceOverlayLayerOrderCore, - ensureOverlayWindowLevel as ensureOverlayWindowLevelCore, - getYomitanDictionaryInfo, handleMineSentenceDigit as handleMineSentenceDigitCore, handleMultiCopyDigit as handleMultiCopyDigitCore, hasMpvWebsocketPlugin, importYomitanDictionaryFromZip, - initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore, - initializeOverlayRuntime as initializeOverlayRuntimeCore, - jellyfinTicksToSecondsRuntime, - listJellyfinItemsRuntime, - listJellyfinLibrariesRuntime, - listJellyfinSubtitleTracksRuntime, - loadSubtitlePosition as loadSubtitlePositionCore, loadYomitanExtension as loadYomitanExtensionCore, markLastCardAsAudioCard as markLastCardAsAudioCardCore, mineSentenceCard as mineSentenceCardCore, @@ -312,65 +119,18 @@ import { playNextSubtitleRuntime, registerGlobalShortcuts as registerGlobalShortcutsCore, replayCurrentSubtitleRuntime, - resolveJellyfinPlaybackPlanRuntime, runStartupBootstrapRuntime, - saveSubtitlePosition as saveSubtitlePositionCore, - addYomitanNoteViaSearch, clearYomitanParserCachesForWindow, - syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore, sendMpvCommandRuntime, setMpvSubVisibilityRuntime, - setOverlayDebugVisualizationEnabledRuntime, - syncOverlayWindowLayer, - setVisibleOverlayVisible as setVisibleOverlayVisibleCore, - showMpvOsdRuntime, - tokenizeSubtitle as tokenizeSubtitleCore, triggerFieldGrouping as triggerFieldGroupingCore, upsertYomitanDictionarySettings, updateLastCardFromClipboard as updateLastCardFromClipboardCore, } from './core/services'; -import { - acquireYoutubeSubtitleTrack, - acquireYoutubeSubtitleTracks, -} from './core/services/youtube/generate'; -import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; -import { probeYoutubeTracks } from './core/services/youtube/track-probe'; -import { startStatsServer } from './core/services/stats-server'; -import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js'; -import { - createFirstRunSetupService, - shouldAutoOpenFirstRunSetup, -} from './main/runtime/first-run-setup-service'; -import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow'; -import { createYoutubePlaybackRuntime } from './main/runtime/youtube-playback-runtime'; -import { - clearYoutubePrimarySubtitleNotificationTimer, - createYoutubePrimarySubtitleNotificationRuntime, -} from './main/runtime/youtube-primary-subtitle-notification'; -import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate'; -import { - buildFirstRunSetupHtml, - createMaybeFocusExistingFirstRunSetupWindowHandler, - createOpenFirstRunSetupWindowHandler, - parseFirstRunSetupSubmissionUrl, - type FirstRunSetupSubmission, -} from './main/runtime/first-run-setup-window'; -import { - detectInstalledFirstRunPlugin, - installFirstRunPluginToDefaultLocation, - syncInstalledFirstRunPluginBinaryPath, -} from './main/runtime/first-run-setup-plugin'; -import { - applyWindowsMpvShortcuts, - detectWindowsMpvShortcuts, - resolveWindowsMpvShortcutPaths, -} from './main/runtime/windows-mpv-shortcuts'; +import { shouldAutoOpenFirstRunSetup } from './main/first-run-runtime'; import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; -import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection'; -import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch'; import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy'; import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup'; -import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps'; import { createRunStatsCliCommandHandler, writeStatsCliCommandResponse, @@ -388,48 +148,45 @@ import { guessAnilistMediaInfo, updateAnilistPostWatchProgress, } from './core/services/anilist/anilist-updater'; -import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher'; -import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter'; import { createJellyfinTokenStore } from './core/services/jellyfin-token-store'; -import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; -import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts'; -import { createMainRuntimeRegistry } from './main/runtime/registry'; -import { - createEnsureOverlayMpvSubtitlesHiddenHandler, - createRestoreOverlayMpvSubtitlesHandler, -} from './main/runtime/overlay-mpv-sub-visibility'; -import { - composeAnilistSetupHandlers, - composeAnilistTrackingHandlers, - composeAppReadyRuntime, - composeCliStartupHandlers, - composeHeadlessStartupHandlers, - composeIpcRuntimeHandlers, - composeJellyfinRuntimeHandlers, - composeMpvRuntimeHandlers, - composeOverlayVisibilityRuntime, - composeShortcutRuntimes, - composeStartupLifecycleHandlers, -} from './main/runtime/composers'; -import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers'; -import { createStartupBootstrapRuntimeDeps } from './main/startup'; -import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle'; import { registerSecondInstanceHandlerEarly, requestSingleInstanceLockEarly, shouldBypassSingleInstanceLockForArgv, } from './main/early-single-instance'; -import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command'; -import { registerIpcRuntimeServices } from './main/ipc-runtime'; -import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; -import { createMainBootServices, type MainBootServicesResult } from './main/boot/services'; +import { createIpcRuntimeBootstrap } from './main/ipc-runtime-bootstrap'; +import { createMainBootRuntime } from './main/main-boot-runtime'; +import { createMainEarlyRuntime } from './main/main-early-runtime'; +import { createMainPlaybackRuntime } from './main/main-playback-runtime'; +import { createDefaultSocketPathResolver } from './main/default-socket-path'; +import { + getRuntimeBooleanOption as getRuntimeBooleanOptionFromManager, + shouldInitializeMecabForAnnotations as shouldInitializeMecabForAnnotationsFromRuntimeOptions, +} from './main/runtime-option-helpers'; +import { + getDefaultPasswordStore, + getPasswordStoreArg, + getStartupModeFlags, + normalizePasswordStoreArg, +} from './main/startup-flags'; import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; +import { createAnilistRuntimeCoordinator } from './main/anilist-runtime-coordinator'; +import { createJellyfinRuntimeCoordinator } from './main/jellyfin-runtime-coordinator'; +import { createOverlayGeometryAccessors } from './main/overlay-geometry-accessors'; +import { createOverlayUiBootstrapFromProcessState } from './main/overlay-ui-bootstrap-from-main-state'; +import type { OverlayGeometryRuntime } from './main/overlay-geometry-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime'; +import type { OverlayUiRuntime } from './main/overlay-ui-runtime'; +import { createShortcutsRuntimeFromMainState } from './main/shortcuts-runtime'; +import { createSubtitleDictionaryRuntimeCoordinator } from './main/subtitle-dictionary-runtime'; +import { createYomitanRuntimeBootstrap } from './main/yomitan-runtime-bootstrap'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; -import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; -import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; +import { createMainStartupRuntimeFromProcessState } from './main/main-startup-runtime-coordinator'; +import { createStartupLifecycleRuntime } from './main/startup-lifecycle-runtime'; +import { type StatsRuntime } from './main/stats-runtime'; +import { createStatsRuntimeFromMainState } from './main/stats-runtime-coordinator'; import { createFrequencyDictionaryRuntimeService, getFrequencyDictionarySearchPaths, @@ -438,9 +195,6 @@ import { createJlptDictionaryRuntimeService, getJlptDictionarySearchPaths, } from './main/jlpt-runtime'; -import { createMediaRuntimeService } from './main/media-runtime'; -import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime'; -import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime'; import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime'; import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync'; import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion'; @@ -451,32 +205,10 @@ import { createRefreshSubtitlePrefetchFromActiveTrackHandler, createResolveActiveSubtitleSidebarSourceHandler, } from './main/runtime/subtitle-prefetch-runtime'; -import { - createCreateAnilistSetupWindowHandler, - createCreateFirstRunSetupWindowHandler, - createCreateJellyfinSetupWindowHandler, -} from './main/runtime/setup-window-factory'; import { isYoutubePlaybackActive } from './main/runtime/youtube-playback'; -import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; -import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; -import { - getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime, - shouldForceOverrideYomitanAnkiServer, -} from './main/runtime/yomitan-anki-server'; -import { - type AnilistMediaGuessRuntimeState, - type StartupState, - applyStartupState, - createAppState, - createInitialAnilistMediaGuessRuntimeState, - createInitialAnilistUpdateInFlightState, - transitionAnilistClientSecretState, - transitionAnilistMediaGuessRuntimeState, - transitionAnilistRetryQueueLastAttemptAt, - transitionAnilistRetryQueueLastError, - transitionAnilistRetryQueueState, - transitionAnilistUpdateInFlightState, -} from './main/state'; +import { shouldForceOverrideYomitanAnkiServer } from './main/runtime/yomitan-anki-server'; +import { createStartupSequenceRuntime } from './main/startup-sequence-runtime'; +import { type StartupState, applyStartupState, createAppState } from './main/state'; import { isAllowedAnilistExternalUrl, isAllowedAnilistSetupNavigationUrl, @@ -490,7 +222,10 @@ import { } from './config'; import { resolveConfigDir } from './config/path-resolution'; import { parseSubtitleCues } from './core/services/subtitle-cue-parser'; -import { createSubtitlePrefetchService, type SubtitlePrefetchService } from './core/services/subtitle-prefetch'; +import { + createSubtitlePrefetchService, + type SubtitlePrefetchService, +} from './core/services/subtitle-prefetch'; import { buildSubtitleSidebarSourceKey, resolveSubtitleSourcePath, @@ -525,12 +260,6 @@ const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000; const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000; const TRAY_TOOLTIP = 'SubMiner'; -let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState = - createInitialAnilistMediaGuessRuntimeState(); -let anilistUpdateInFlightState = createInitialAnilistUpdateInFlightState(); -const anilistAttemptedUpdateKeys = new Set(); -let anilistCachedAccessToken: string | null = null; -let jellyfinPlayQuitOnDisconnectArmed = false; const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US'; const JELLYFIN_TICKS_PER_SECOND = 10_000_000; const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000; @@ -551,29 +280,9 @@ const MPV_JELLYFIN_DEFAULT_ARGS = [ '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', ] as const; -let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null; -let jellyfinRemoteLastProgressAtMs = 0; -let jellyfinMpvAutoLaunchInFlight: Promise | null = null; -let backgroundWarmupsStarted = false; let yomitanLoadInFlight: Promise | null = null; let notifyAnilistTokenStoreWarning: (message: string) => void = () => {}; -const buildApplyJellyfinMpvDefaultsMainDepsHandler = - createBuildApplyJellyfinMpvDefaultsMainDepsHandler({ - sendMpvCommandRuntime: (client, command) => sendMpvCommandRuntime(client, command), - jellyfinLangPref: JELLYFIN_LANG_PREF, - }); -const applyJellyfinMpvDefaultsMainDeps = buildApplyJellyfinMpvDefaultsMainDepsHandler(); -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(); @@ -612,38 +321,15 @@ const texthookerService = new Texthooker(() => { }); let syncOverlayShortcutsForModal: (isActive: boolean) => void = () => {}; let syncOverlayVisibilityForModal: () => void = () => {}; -const buildGetDefaultSocketPathMainDepsHandler = createBuildGetDefaultSocketPathMainDepsHandler({ - platform: process.platform, -}); -const getDefaultSocketPathMainDeps = buildGetDefaultSocketPathMainDepsHandler(); -const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler(getDefaultSocketPathMainDeps); +let overlayUi: OverlayUiRuntime | null = null; +let overlayGeometryRuntime: OverlayGeometryRuntime | null = null; +const resolveDefaultSocketPath = createDefaultSocketPathResolver(process.platform); function getDefaultSocketPath(): string { - return getDefaultSocketPathHandler(); + return resolveDefaultSocketPath(); } -type BootServices = MainBootServicesResult< - ConfigService, - ReturnType, - ReturnType, - ReturnType, - SubtitleWebSocket, - ReturnType, - ReturnType, - ReturnType, - ReturnType, - ReturnType, - ReturnType, - ReturnType, - { - requestSingleInstanceLock: () => boolean; - quit: () => void; - on: (event: string, listener: (...args: unknown[]) => void) => unknown; - whenReady: () => Promise; - } ->; - -const bootServices = createMainBootServices({ +const bootServices = createMainBootRuntime({ platform: process.platform, argv: process.argv, appDataDir: process.env.APPDATA, @@ -653,76 +339,19 @@ const bootServices = createMainBootServices({ envMpvLog: process.env.SUBMINER_MPV_LOG, defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, getDefaultSocketPath: () => getDefaultSocketPath(), - resolveConfigDir, - existsSync: fs.existsSync, - mkdirSync: fs.mkdirSync, - joinPath: (...parts) => path.join(...parts), app, - shouldBypassSingleInstanceLock: () => shouldBypassSingleInstanceLockForArgv(process.argv), - requestSingleInstanceLockEarly: () => requestSingleInstanceLockEarly(app), - registerSecondInstanceHandlerEarly: (listener) => { - registerSecondInstanceHandlerEarly(app, listener); + dialog, + overlay: { + getSyncOverlayShortcutsForModal: () => syncOverlayShortcutsForModal, + getSyncOverlayVisibilityForModal: () => syncOverlayVisibilityForModal, + createModalWindow: () => overlayUi!.createModalWindow(), + getOverlayGeometry: () => getCurrentOverlayGeometry(), }, - onConfigStartupParseError: (error) => { - failStartupFromConfig( - 'SubMiner config parse error', - buildConfigParseErrorDetails(error.path, error.parseError), - { - logError: (details) => console.error(details), - showErrorBox: (title, details) => dialog.showErrorBox(title, details), - quit: () => requestAppQuit(), - }, - ); + notifications: { + notifyAnilistTokenStoreWarning: (message) => notifyAnilistTokenStoreWarning(message), + requestAppQuit: () => requestAppQuit(), }, - createConfigService: (configDir) => new ConfigService(configDir), - createAnilistTokenStore: (targetPath) => - createAnilistTokenStore(targetPath, { - info: (message: string) => console.info(message), - warn: (message: string, details?: unknown) => console.warn(message, details), - error: (message: string, details?: unknown) => console.error(message, details), - warnUser: (message: string) => notifyAnilistTokenStoreWarning(message), - }), - createJellyfinTokenStore: (targetPath) => - createJellyfinTokenStore(targetPath, { - info: (message: string) => console.info(message), - warn: (message: string, details?: unknown) => console.warn(message, details), - error: (message: string, details?: unknown) => console.error(message, details), - }), - createAnilistUpdateQueue: (targetPath) => - createAnilistUpdateQueue(targetPath, { - info: (message: string) => console.info(message), - warn: (message: string, details?: unknown) => console.warn(message, details), - error: (message: string, details?: unknown) => console.error(message, details), - }), - createSubtitleWebSocket: () => new SubtitleWebSocket(), - createLogger, - createMainRuntimeRegistry, - createOverlayManager, - createOverlayModalInputState, - createOverlayContentMeasurementStore: ({ logger }) => { - const buildHandler = createBuildOverlayContentMeasurementStoreMainDepsHandler({ - now: () => Date.now(), - warn: (message: string) => logger.warn(message), - }); - return createOverlayContentMeasurementStore(buildHandler()); - }, - getSyncOverlayShortcutsForModal: () => syncOverlayShortcutsForModal, - getSyncOverlayVisibilityForModal: () => syncOverlayVisibilityForModal, - createOverlayModalRuntime: ({ overlayManager, overlayModalInputState }) => { - const buildHandler = createBuildOverlayModalRuntimeMainDepsHandler({ - getMainWindow: () => overlayManager.getMainWindow(), - getModalWindow: () => overlayManager.getModalWindow(), - createModalWindow: () => createModalWindow(), - getModalGeometry: () => getCurrentOverlayGeometry(), - setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry), - }); - return createOverlayModalRuntimeService(buildHandler(), { - onModalStateChange: (isActive: boolean) => - overlayModalInputState.handleModalInputStateChange(isActive), - }); - }, - createAppState, -}) as BootServices; +}); const { configDir: CONFIG_DIR, userDataPath: USER_DATA_PATH, @@ -735,7 +364,6 @@ const { subtitleWsService, annotationSubtitleWsService, logger, - runtimeRegistry, overlayManager, overlayModalInputState, overlayContentMeasurementStore, @@ -779,49 +407,12 @@ const appLogger = { }; let forceQuitTimer: ReturnType | null = null; -let statsServer: ReturnType | null = null; -const statsDaemonStatePath = path.join(USER_DATA_PATH, 'stats-daemon.json'); - -function readLiveBackgroundStatsDaemonState(): { - pid: number; - port: number; - startedAtMs: number; -} | null { - const state = readBackgroundStatsServerState(statsDaemonStatePath); - if (!state) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return null; - } - if (state.pid === process.pid && !statsServer) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return null; - } - if (!isBackgroundStatsServerProcessAlive(state.pid)) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return null; - } - return state; -} - -function clearOwnedBackgroundStatsDaemonState(): void { - const state = readBackgroundStatsServerState(statsDaemonStatePath); - if (state?.pid === process.pid) { - removeBackgroundStatsServerState(statsDaemonStatePath); - } -} - -function stopStatsServer(): void { - if (!statsServer) { - return; - } - statsServer.close(); - statsServer = null; - clearOwnedBackgroundStatsDaemonState(); -} +let stats: StatsRuntime | null = null; function requestAppQuit(): void { - destroyStatsWindow(); - stopStatsServer(); + if (stats) { + stats.cleanupBeforeQuit(); + } if (!forceQuitTimer) { forceQuitTimer = setTimeout(() => { logger.warn('App quit timed out; forcing process exit.'); @@ -839,1845 +430,312 @@ process.on('SIGTERM', () => { }); const startBackgroundWarmupsIfAllowed = (): void => { - startBackgroundWarmups(); + mpvRuntime.startBackgroundWarmups(); }; -const youtubeFlowRuntime = createYoutubeFlowRuntime({ - probeYoutubeTracks: (url: string) => probeYoutubeTracks(url), - acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input), - acquireYoutubeSubtitleTracks: (input) => acquireYoutubeSubtitleTracks(input), - openPicker: async (payload) => { - return await openYoutubeTrackPicker( - { - sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) => - overlayModalRuntime.sendToActiveOverlayWindow(channel, nextPayload, runtimeOptions), - waitForModalOpen: (modal, timeoutMs) => - overlayModalRuntime.waitForModalOpen(modal, timeoutMs), - logWarn: (message) => logger.warn(message), - }, - payload, - ); - }, - pauseMpv: () => { - sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']); - }, - resumeMpv: () => { - sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'no']); - }, - sendMpvCommand: (command) => { - sendMpvCommandRuntime(appState.mpvClient, command); - }, - requestMpvProperty: async (name: string) => { - const client = appState.mpvClient; - if (!client) return null; - return await client.requestProperty(name); - }, - refreshCurrentSubtitle: (text: string) => { - subtitleProcessingController.refreshCurrentSubtitle(text); - }, - refreshSubtitleSidebarSource: async (sourcePath: string) => { - await subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath); - }, - startTokenizationWarmups: async () => { - await startTokenizationWarmups(); - }, - waitForTokenizationReady: async () => { - await currentMediaTokenizationGate.waitUntilReady( - appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, - ); - }, - waitForAnkiReady: async () => { - const integration = appState.ankiIntegration; - if (!integration) { - return; - } - try { - await Promise.race([ - integration.waitUntilReady(), - new Promise((_, reject) => { - setTimeout( - () => reject(new Error('Timed out waiting for AnkiConnect integration')), - 2500, - ); - }), - ]); - } catch (error) { - logger.warn( - 'Continuing YouTube playback before AnkiConnect integration reported ready:', - error instanceof Error ? error.message : String(error), - ); - } - }, - wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), - waitForPlaybackWindowReady: async () => { - const deadline = Date.now() + 4000; - let stableGeometry: WindowGeometry | null = null; - let stableSinceMs = 0; - while (Date.now() < deadline) { - const tracker = appState.windowTracker; - const trackerGeometry = tracker?.getGeometry() ?? null; - const mediaPath = - appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || ''; - const trackerFocused = tracker?.isTargetWindowFocused() ?? false; - if (tracker && tracker.isTracking() && trackerGeometry && trackerFocused && mediaPath) { - if (!geometryMatches(stableGeometry, trackerGeometry)) { - stableGeometry = trackerGeometry; - stableSinceMs = Date.now(); - } else if (Date.now() - stableSinceMs >= 200) { - return; - } - } else { - stableGeometry = null; - stableSinceMs = 0; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - logger.warn( - 'Timed out waiting for tracked playback window focus/media readiness before opening YouTube subtitle picker.', - ); - }, - waitForOverlayGeometryReady: async () => { - const deadline = Date.now() + 4000; - while (Date.now() < deadline) { - const tracker = appState.windowTracker; - const trackerGeometry = tracker?.getGeometry() ?? null; - if (trackerGeometry && geometryMatches(lastOverlayWindowGeometry, trackerGeometry)) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - logger.warn('Timed out waiting for overlay geometry to match tracked playback window.'); - }, - focusOverlayWindow: () => { - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) { - return; - } - mainWindow.setIgnoreMouseEvents(false); - if (!mainWindow.isFocused()) { - mainWindow.focus(); - } - if (!mainWindow.webContents.isFocused()) { - mainWindow.webContents.focus(); - } - }, - showMpvOsd: (text: string) => showMpvOsd(text), - reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message), - warn: (message: string) => logger.warn(message), - log: (message: string) => logger.info(message), - getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'), -}); -const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({ - requestPath: async () => { - const client = appState.mpvClient; - if (!client) return null; - const value = await client.requestProperty('path').catch(() => null); - return typeof value === 'string' ? value : null; - }, - requestProperty: async (name) => { - const client = appState.mpvClient; - if (!client) return null; - return await client.requestProperty(name); - }, - sendMpvCommand: (command) => { - sendMpvCommandRuntime(appState.mpvClient, command); - }, - wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), -}); -const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({ - getMpvClient: () => appState.mpvClient, - now: () => Date.now(), - sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), -}); -const autoplayReadyGate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => youtubePrimarySubtitleNotificationRuntime.isAppOwnedFlowInFlight(), - getCurrentMediaPath: () => appState.currentMediaPath, - getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath ?? null, - getPlaybackPaused: () => appState.playbackPaused, - getMpvClient: () => appState.mpvClient, - signalPluginAutoplayReady: () => { - sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']); - }, - schedule: (callback, delayMs) => setTimeout(callback, delayMs), - logDebug: (message) => logger.debug(message), -}); -const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ - platform: process.platform, - directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT, - mpvYtdlFormat: YOUTUBE_MPV_YTDL_FORMAT, - autoLaunchTimeoutMs: YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS, - connectTimeoutMs: YOUTUBE_MPV_CONNECT_TIMEOUT_MS, - getSocketPath: () => appState.mpvSocketPath, - getMpvConnected: () => Boolean(appState.mpvClient?.connected), - invalidatePendingAutoplayReadyFallbacks: () => - autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(), - setAppOwnedFlowInFlight: (next) => { - youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(next); - }, - ensureYoutubePlaybackRuntimeReady: async () => { - await ensureYoutubePlaybackRuntimeReady(); - }, - resolveYoutubePlaybackUrl: (url, format) => resolveYoutubePlaybackUrl(url, format), - launchWindowsMpv: (playbackUrl, args) => - launchWindowsMpv( - [playbackUrl], - createWindowsMpvLaunchDeps({ - showError: (title, content) => dialog.showErrorBox(title, content), - }), - [...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`], - ), - waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs), - prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), - runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request), - logInfo: (message) => logger.info(message), - logWarn: (message) => logger.warn(message), - schedule: (callback, delayMs) => setTimeout(callback, delayMs), - clearScheduled: (timer) => clearTimeout(timer), -}); -let firstRunSetupMessage: string | null = null; -const resolveWindowsMpvShortcutRuntimePaths = () => - resolveWindowsMpvShortcutPaths({ - appDataDir: app.getPath('appData'), - desktopDir: app.getPath('desktop'), - }); -syncInstalledFirstRunPluginBinaryPath({ +const mainEarlyRuntime = createMainEarlyRuntime({ platform: process.platform, + configDir: CONFIG_DIR, homeDir: os.homedir(), xdgConfigHome: process.env.XDG_CONFIG_HOME, binaryPath: process.execPath, -}); -const firstRunSetupService = createFirstRunSetupService({ - platform: process.platform, - configDir: CONFIG_DIR, - getYomitanDictionaryCount: async () => { - await ensureYomitanExtensionLoaded(); - const dictionaries = await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), { - error: (message, ...args) => logger.error(message, ...args), - info: (message, ...args) => logger.info(message, ...args), - }); - return dictionaries.length; - }, - isExternalYomitanConfigured: () => - getResolvedConfig().yomitan.externalProfilePath.trim().length > 0, - detectPluginInstalled: () => { - const installPaths = resolveDefaultMpvInstallPaths( - process.platform, - os.homedir(), - process.env.XDG_CONFIG_HOME, - ); - return detectInstalledFirstRunPlugin(installPaths); - }, - installPlugin: async () => - installFirstRunPluginToDefaultLocation({ - platform: process.platform, - homeDir: os.homedir(), - xdgConfigHome: process.env.XDG_CONFIG_HOME, - dirname: __dirname, - appPath: app.getAppPath(), - resourcesPath: process.resourcesPath, - binaryPath: process.execPath, - }), - detectWindowsMpvShortcuts: () => { - if (process.platform !== 'win32') { - return { - startMenuInstalled: false, - desktopInstalled: false, - }; - } - return detectWindowsMpvShortcuts(resolveWindowsMpvShortcutRuntimePaths()); - }, - applyWindowsMpvShortcuts: async (preferences) => { - if (process.platform !== 'win32') { - return { - ok: true, - status: 'unknown' as const, - message: '', - }; - } - return applyWindowsMpvShortcuts({ - preferences, - paths: resolveWindowsMpvShortcutRuntimePaths(), - exePath: process.execPath, - writeShortcutLink: (shortcutPath, operation, details) => - shell.writeShortcutLink(shortcutPath, operation, details), - }); - }, - onStateChanged: (state) => { - appState.firstRunSetupCompleted = state.status === 'completed'; - if (appTray) { - ensureTray(); - } - }, -}); -const discordPresenceSessionStartedAtMs = Date.now(); -let discordPresenceMediaDurationSec: number | null = null; -const discordPresenceRuntime = createDiscordPresenceRuntime({ - getDiscordPresenceService: () => appState.discordPresenceService, - isDiscordPresenceEnabled: () => getResolvedConfig().discordPresence.enabled === true, - getMpvClient: () => appState.mpvClient, - getCurrentMediaTitle: () => appState.currentMediaTitle, - getCurrentMediaPath: () => appState.currentMediaPath, - getCurrentSubtitleText: () => appState.currentSubText, - getPlaybackPaused: () => appState.playbackPaused, - getFallbackMediaDurationSec: () => anilistMediaGuessRuntimeState.mediaDurationSec, - getSessionStartedAtMs: () => discordPresenceSessionStartedAtMs, - getMediaDurationSec: () => discordPresenceMediaDurationSec, - setMediaDurationSec: (next) => { - discordPresenceMediaDurationSec = next; - }, -}); - -async function initializeDiscordPresenceService(): Promise { - if (getResolvedConfig().discordPresence.enabled !== true) { - appState.discordPresenceService = null; - return; - } - - appState.discordPresenceService = createDiscordPresenceService({ - config: getResolvedConfig().discordPresence, - createClient: () => createDiscordRpcClient(DISCORD_PRESENCE_APP_ID), - logDebug: (message, meta) => logger.debug(message, meta), - }); - await appState.discordPresenceService.start(); - discordPresenceRuntime.publishDiscordPresence(); -} -const ensureOverlayMpvSubtitlesHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({ - getMpvClient: () => appState.mpvClient, - getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility, - setSavedSubVisibility: (visible) => { - appState.overlaySavedMpvSubVisibility = visible; - }, - getRevision: () => appState.overlayMpvSubVisibilityRevision, - setRevision: (revision) => { - appState.overlayMpvSubVisibilityRevision = revision; - }, - setMpvSubVisibility: (visible) => { - setMpvSubVisibilityRuntime(appState.mpvClient, visible); - }, - logWarn: (message, error) => { - logger.warn(message, error); - }, -}); -const restoreOverlayMpvSubtitles = createRestoreOverlayMpvSubtitlesHandler({ - getSavedSubVisibility: () => appState.overlaySavedMpvSubVisibility, - setSavedSubVisibility: (visible) => { - appState.overlaySavedMpvSubVisibility = visible; - }, - getRevision: () => appState.overlayMpvSubVisibilityRevision, - setRevision: (revision) => { - appState.overlayMpvSubVisibilityRevision = revision; - }, - isMpvConnected: () => Boolean(appState.mpvClient?.connected), - shouldKeepSuppressedFromVisibleOverlayBinding: () => shouldSuppressMpvSubtitlesForOverlay(), - setMpvSubVisibility: (visible) => { - setMpvSubVisibilityRuntime(appState.mpvClient, visible); - }, -}); - -function shouldSuppressMpvSubtitlesForOverlay(): boolean { - return overlayManager.getVisibleOverlayVisible(); -} - -function syncOverlayMpvSubtitleSuppression(): void { - if (shouldSuppressMpvSubtitlesForOverlay()) { - void ensureOverlayMpvSubtitlesHidden(); - return; - } - - restoreOverlayMpvSubtitles(); -} - -const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRuntimeMainDepsHandler({ - getResolvedConfig: () => getResolvedConfig(), + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + appDataDir: app.getPath('appData'), + desktopDir: app.getPath('desktop'), defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH, - getTracker: () => appState.immersionTracker, - getMpvClient: () => appState.mpvClient, - getCurrentMediaPath: () => appState.currentMediaPath, - getCurrentMediaTitle: () => appState.currentMediaTitle, - logDebug: (message) => logger.debug(message), - logInfo: (message) => logger.info(message), -}); -const buildAnilistStateRuntimeMainDepsHandler = createBuildAnilistStateRuntimeMainDepsHandler({ - getClientSecretState: () => appState.anilistClientSecretState, - setClientSecretState: (next) => { - appState.anilistClientSecretState = transitionAnilistClientSecretState( - appState.anilistClientSecretState, - next, - ); - }, - getRetryQueueState: () => appState.anilistRetryQueueState, - setRetryQueueState: (next) => { - appState.anilistRetryQueueState = transitionAnilistRetryQueueState( - appState.anilistRetryQueueState, - next, - ); - }, - getUpdateQueueSnapshot: () => anilistUpdateQueue.getSnapshot(), - clearStoredToken: () => anilistTokenStore.clearToken(), - clearCachedAccessToken: () => { - anilistCachedAccessToken = null; - }, -}); -const buildConfigDerivedRuntimeMainDepsHandler = createBuildConfigDerivedRuntimeMainDepsHandler({ - getResolvedConfig: () => getResolvedConfig(), - getRuntimeOptionsManager: () => appState.runtimeOptionsManager, defaultJimakuLanguagePreference: DEFAULT_CONFIG.jimaku.languagePreference, defaultJimakuMaxEntryResults: DEFAULT_CONFIG.jimaku.maxEntryResults, defaultJimakuApiBaseUrl: DEFAULT_CONFIG.jimaku.apiBaseUrl, -}); -const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMainDepsHandler({ - getMpvClient: () => appState.mpvClient, + jellyfinLangPref: JELLYFIN_LANG_PREF, + youtube: { + directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT, + mpvYtdlFormat: YOUTUBE_MPV_YTDL_FORMAT, + autoLaunchTimeoutMs: YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS, + connectTimeoutMs: YOUTUBE_MPV_CONNECT_TIMEOUT_MS, + logPath: DEFAULT_MPV_LOG_PATH, + }, + discordPresenceAppId: DISCORD_PRESENCE_APP_ID, + appState, getResolvedConfig: () => getResolvedConfig(), - getSubsyncInProgress: () => appState.subsyncInProgress, - setSubsyncInProgress: (inProgress) => { - appState.subsyncInProgress = inProgress; + getFallbackDiscordMediaDurationSec: () => + anilist.getAnilistMediaGuessRuntimeState().mediaDurationSec, + configService, + overlay: { + overlayManager, + overlayModalRuntime, + getOverlayUi: () => overlayUi, + getOverlayGeometry: () => overlayGeometryRuntime!, + ensureTray: () => { + overlayUi?.ensureTray(); + }, + hasTray: () => Boolean(appTray), }, - showMpvOsd: (text) => showMpvOsd(text), - openManualPicker: (payload) => { - sendToActiveOverlayWindow('subsync:open-manual', payload, { - restoreOnModalClose: 'subsync', - }); + yomitan: { + ensureYomitanExtensionLoaded: () => yomitan.ensureYomitanExtensionLoaded(), + getParserRuntimeDeps: () => yomitan.getParserRuntimeDeps(), + openYomitanSettings: () => yomitan.openYomitanSettings(), }, + subtitle: { + getSubtitle: () => subtitle, + }, + tokenization: { + startTokenizationWarmups: () => mpvRuntime.startTokenizationWarmups(), + getGate: () => currentMediaTokenizationGate, + }, + appReady: { + ensureYoutubePlaybackRuntimeReady: () => ensureYoutubePlaybackRuntimeReady(), + }, + shortcuts: { + refreshGlobalAndOverlayShortcuts: () => shortcuts.refreshGlobalAndOverlayShortcuts(), + }, + notifications: { + showDesktopNotification, + showErrorBox: (title, content) => dialog.showErrorBox(title, content), + }, + mpv: { + sendMpvCommandRuntime: (client, command) => sendMpvCommandRuntime(client, command), + setSubVisibility: (visible) => setMpvSubVisibilityRuntime(appState.mpvClient, visible), + showMpvOsd: (text) => mpvRuntime.showMpvOsd(text), + }, + actions: { + requestAppQuit, + writeShortcutLink: (shortcutPath, operation, details) => + shell.writeShortcutLink(shortcutPath, operation, details), + }, + logger, }); -const immersionMediaRuntime = createImmersionMediaRuntime( - buildImmersionMediaRuntimeMainDepsHandler(), -); -const statsCoverArtFetcher = createCoverArtFetcher( - createAnilistRateLimiter(), - createLogger('main:stats-cover-art'), -); -const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler()); -const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler()); -const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler()); +const { + firstRun, + discordPresenceRuntime, + initializeDiscordPresenceService, + overlaySubtitleSuppression, + startupSupport, + youtube, +} = mainEarlyRuntime; +const { + ensureOverlayMpvSubtitlesHidden, + restoreOverlayMpvSubtitles, + syncOverlayMpvSubtitleSuppression, +} = overlaySubtitleSuppression; +const { immersionMediaRuntime, configDerivedRuntime, subsyncRuntime, configHotReloadRuntime } = + startupSupport; const currentMediaTokenizationGate = createCurrentMediaTokenizationGate(); const startupOsdSequencer = createStartupOsdSequencer({ - showOsd: (message) => showMpvOsd(message), + showOsd: (message) => mpvRuntime.showMpvOsd(message), }); -const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({ - getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, - notifyFailure: (message) => reportYoutubeSubtitleFailure(message), - schedule: (fn, delayMs) => setTimeout(fn, delayMs), - clearSchedule: clearYoutubePrimarySubtitleNotificationTimer, -}); - -function isYoutubePlaybackActiveNow(): boolean { - return isYoutubePlaybackActive( - appState.currentMediaPath, - appState.mpvClient?.currentVideoPath ?? null, - ); -} - -function reportYoutubeSubtitleFailure(message: string): void { - const type = getResolvedConfig().ankiConnect.behavior.notificationType; - if (type === 'osd' || type === 'both') { - showMpvOsd(message); - } - if (type === 'system' || type === 'both') { - try { - showDesktopNotification('SubMiner', { body: message }); - } catch { - logger.warn(`Unable to show desktop notification: ${message}`); - } - } -} - -async function openYoutubeTrackPickerFromPlayback(): Promise { - if (youtubeFlowRuntime.hasActiveSession()) { - showMpvOsd('YouTube subtitle flow already in progress.'); - return; - } - const currentMediaPath = - appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || ''; - if (!isYoutubePlaybackActiveNow() || !currentMediaPath) { - showMpvOsd('YouTube subtitle picker is only available during YouTube playback.'); - return; - } - await youtubeFlowRuntime.openManualPicker({ - url: currentMediaPath, - }); -} +const isYoutubePlaybackActiveNow = (): boolean => + isYoutubePlaybackActive(appState.currentMediaPath, appState.mpvClient?.currentVideoPath ?? null); let appTray: Tray | null = null; -let tokenizeSubtitleDeferred: ((text: string) => Promise) | null = null; -function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData { - return { - ...payload, - startTime: appState.mpvClient?.currentSubStart ?? null, - endTime: appState.mpvClient?.currentSubEnd ?? null, - }; -} -function emitSubtitlePayload(payload: SubtitleData): void { - const timedPayload = withCurrentSubtitleTiming(payload); - 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, - }); - subtitlePrefetchService?.resume(); -} -const buildSubtitleProcessingControllerMainDepsHandler = - createBuildSubtitleProcessingControllerMainDepsHandler({ - tokenizeSubtitle: async (text: string) => - tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null }, - emitSubtitle: (payload) => emitSubtitlePayload(payload), - logDebug: (message) => { - logger.debug(`[subtitle-processing] ${message}`); - }, - now: () => Date.now(), - }); -const subtitleProcessingControllerMainDeps = buildSubtitleProcessingControllerMainDepsHandler(); -const subtitleProcessingController = createSubtitleProcessingController( - subtitleProcessingControllerMainDeps, -); -let subtitlePrefetchService: SubtitlePrefetchService | null = null; -let subtitlePrefetchRefreshTimer: ReturnType | null = null; -let lastObservedTimePos = 0; -const SEEK_THRESHOLD_SECONDS = 3; - -function clearScheduledSubtitlePrefetchRefresh(): void { - if (subtitlePrefetchRefreshTimer) { - clearTimeout(subtitlePrefetchRefreshTimer); - subtitlePrefetchRefreshTimer = null; - } -} - -const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ - getCurrentService: () => subtitlePrefetchService, - setCurrentService: (service) => { - subtitlePrefetchService = service; - }, - loadSubtitleSourceText, - parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename), - createSubtitlePrefetchService: (deps) => createSubtitlePrefetchService(deps), - tokenizeSubtitle: async (text) => - tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, - preCacheTokenization: (text, data) => { - subtitleProcessingController.preCacheTokenization(text, data); - }, - isCacheFull: () => subtitleProcessingController.isCacheFull(), - logInfo: (message) => logger.info(message), - logWarn: (message) => logger.warn(message), - onParsedSubtitleCuesChanged: (cues, sourceKey) => { - appState.activeParsedSubtitleCues = cues ?? []; - appState.activeParsedSubtitleSource = sourceKey; - }, -}); -const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({ - getFfmpegPath: () => getResolvedConfig().subsync.ffmpeg_path.trim() || 'ffmpeg', - extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) => - extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track), -}); - -async function refreshSubtitleSidebarFromSource(sourcePath: string): Promise { - const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim()); - if (!normalizedSourcePath) { - return; - } - await subtitlePrefetchInitController.initSubtitlePrefetch( - normalizedSourcePath, - lastObservedTimePos, - normalizedSourcePath, - ); -} -const refreshSubtitlePrefetchFromActiveTrackHandler = - createRefreshSubtitlePrefetchFromActiveTrackHandler({ - getMpvClient: () => appState.mpvClient, - getLastObservedTimePos: () => lastObservedTimePos, - subtitlePrefetchInitController, - resolveActiveSubtitleSidebarSource: (input) => - resolveActiveSubtitleSidebarSourceHandler(input), - }); - -function scheduleSubtitlePrefetchRefresh(delayMs = 0): void { - clearScheduledSubtitlePrefetchRefresh(); - subtitlePrefetchRefreshTimer = setTimeout(() => { - subtitlePrefetchRefreshTimer = null; - void refreshSubtitlePrefetchFromActiveTrackHandler(); - }, delayMs); -} const subtitlePrefetchRuntime = { - cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(), - initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch, - refreshSubtitleSidebarFromSource: (sourcePath: string) => refreshSubtitleSidebarFromSource(sourcePath), - refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), - scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs), - clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(), + cancelPendingInit: () => subtitle.cancelPendingSubtitlePrefetchInit(), + refreshSubtitleSidebarFromSource: (sourcePath: string) => + subtitle.refreshSubtitleSidebarFromSource(sourcePath), + refreshSubtitlePrefetchFromActiveTrack: () => subtitle.refreshSubtitlePrefetchFromActiveTrack(), + scheduleSubtitlePrefetchRefresh: (delayMs?: number) => + subtitle.scheduleSubtitlePrefetchRefresh(delayMs), + clearScheduledSubtitlePrefetchRefresh: () => subtitle.clearScheduledSubtitlePrefetchRefresh(), +} as const; +const startupOverlayUiAdapter = { + broadcastRuntimeOptionsChanged: () => overlayUi?.broadcastRuntimeOptionsChanged(), + ensureOverlayWindowsReadyForVisibilityActions: () => + overlayUi?.ensureOverlayWindowsReadyForVisibilityActions(), + ensureTray: () => overlayUi?.ensureTray(), + initializeOverlayRuntime: () => overlayUi?.initializeOverlayRuntime(), + openRuntimeOptionsPalette: () => overlayUi?.openRuntimeOptionsPalette(), + setOverlayVisible: (visible: boolean) => overlayUi?.setOverlayVisible(visible), + setVisibleOverlayVisible: (visible: boolean) => overlayUi?.setVisibleOverlayVisible(visible), + toggleVisibleOverlay: () => overlayUi?.toggleVisibleOverlay(), } as const; -const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( - createBuildOverlayShortcutsRuntimeMainDepsHandler({ - getConfiguredShortcuts: () => getConfiguredShortcuts(), - getShortcutsRegistered: () => appState.shortcutsRegistered, - setShortcutsRegistered: (registered: boolean) => { - appState.shortcutsRegistered = registered; - }, - isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - isOverlayShortcutContextActive: () => { - if (process.platform !== 'win32') { - return true; - } - - if (!overlayManager.getVisibleOverlayVisible()) { - return false; - } - - const windowTracker = appState.windowTracker; - if (!windowTracker || !windowTracker.isTracking()) { - return false; - } - - return windowTracker.isTargetWindowFocused(); - }, - showMpvOsd: (text: string) => showMpvOsd(text), - openRuntimeOptionsPalette: () => { - openRuntimeOptionsPalette(); - }, - openJimaku: () => { - sendToActiveOverlayWindow('jimaku:open', undefined, { - restoreOnModalClose: 'jimaku', - }); - }, - markAudioCard: () => markLastCardAsAudioCard(), - copySubtitleMultiple: (timeoutMs: number) => { - startPendingMultiCopy(timeoutMs); - }, - copySubtitle: () => { - copyCurrentSubtitle(); - }, - toggleSecondarySubMode: () => handleCycleSecondarySubMode(), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - mineSentenceCard: () => mineSentenceCard(), - mineSentenceMultiple: (timeoutMs: number) => { - startPendingMineSentenceMultiple(timeoutMs); - }, - cancelPendingMultiCopy: () => { - cancelPendingMultiCopy(); - }, - cancelPendingMineSentenceMultiple: () => { - cancelPendingMineSentenceMultiple(); - }, - })(), -); +const shortcutsBootstrap = createShortcutsRuntimeFromMainState({ + appState, + getResolvedConfig: () => getResolvedConfig(), + globalShortcut, + registerGlobalShortcutsCore, + isDev, + overlay: { + getOverlayUi: () => overlayUi, + overlayManager, + overlayModalRuntime, + }, + actions: { + showMpvOsd: (text) => mpvRuntime.showMpvOsd(text), + openYomitanSettings: () => yomitan.openYomitanSettings(), + triggerSubsyncFromConfig: () => subsyncRuntime.triggerFromConfig(), + handleCycleSecondarySubMode: () => mpvRuntime.cycleSecondarySubMode(), + handleMultiCopyDigit: (count) => mining.handleMultiCopyDigit(count), + }, + mining: { + copyCurrentSubtitle: () => mining.copyCurrentSubtitle(), + handleMineSentenceDigit: (count) => mining.handleMineSentenceDigit(count), + markLastCardAsAudioCard: () => mining.markLastCardAsAudioCard(), + mineSentenceCard: () => mining.mineSentenceCard(), + triggerFieldGrouping: () => mining.triggerFieldGrouping(), + updateLastCardFromClipboard: () => mining.updateLastCardFromClipboard(), + }, +}); +const shortcuts = shortcutsBootstrap.shortcuts; +const overlayShortcutsRuntime = shortcutsBootstrap.overlayShortcutsRuntime; syncOverlayShortcutsForModal = (isActive: boolean): void => { - if (isActive) { - overlayShortcutsRuntime.unregisterOverlayShortcuts(); - } else { - overlayShortcutsRuntime.syncOverlayShortcuts(); - } + shortcutsBootstrap.syncOverlayShortcutsForModal(isActive); }; -const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler( - { - showMpvOsd: (message) => showMpvOsd(message), - showDesktopNotification: (title, options) => showDesktopNotification(title, options), - }, -); -const configHotReloadMessageMainDeps = buildConfigHotReloadMessageMainDepsHandler(); -const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler( - configHotReloadMessageMainDeps, -); -const buildWatchConfigPathMainDepsHandler = createBuildWatchConfigPathMainDepsHandler({ - fileExists: (targetPath) => fs.existsSync(targetPath), - dirname: (targetPath) => path.dirname(targetPath), - watchPath: (targetPath, listener) => fs.watch(targetPath, listener), -}); -const watchConfigPathHandler = createWatchConfigPathHandler(buildWatchConfigPathMainDepsHandler()); -const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadAppliedMainDepsHandler( - { - setKeybindings: (keybindings) => { - appState.keybindings = keybindings; +const subtitleDictionaryOverlayUiAdapter = { + setVisibleOverlayVisible: (visible: boolean) => overlayUi?.setVisibleOverlayVisible(visible), + getRestoreVisibleOverlayOnModalClose: () => + overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; }, - refreshGlobalAndOverlayShortcuts: () => { - refreshGlobalAndOverlayShortcuts(); - }, - setSecondarySubMode: (mode) => { - setSecondarySubMode(mode); - }, - broadcastToOverlayWindows: (channel, payload) => { - broadcastToOverlayWindows(channel, payload); - }, - applyAnkiRuntimeConfigPatch: (patch) => { - if (appState.ankiIntegration) { - appState.ankiIntegration.applyRuntimeConfigPatch(patch); - } - }, - }, -); -const buildConfigHotReloadRuntimeMainDepsHandler = createBuildConfigHotReloadRuntimeMainDepsHandler( - { - getCurrentConfig: () => getResolvedConfig(), - reloadConfigStrict: () => configService.reloadConfigStrict(), - watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange), - setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), - clearTimeout: (timeout) => clearTimeout(timeout), - debounceMs: 250, - onHotReloadApplied: createConfigHotReloadAppliedHandler( - buildConfigHotReloadAppliedMainDepsHandler(), - ), - onRestartRequired: (fields) => - notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)), - onInvalidConfig: notifyConfigHotReloadMessage, - onValidationWarnings: (configPath, warnings) => { - showDesktopNotification('SubMiner', { - body: buildConfigWarningNotificationBody(configPath, warnings), - }); - if (process.platform === 'darwin') { - dialog.showErrorBox( - 'SubMiner config validation warning', - buildConfigWarningDialogDetails(configPath, warnings), - ); - } - }, - }, -); -const configHotReloadRuntime = createConfigHotReloadRuntime( - buildConfigHotReloadRuntimeMainDepsHandler(), -); + ) => overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), +} as const; -const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({ - platform: process.platform, - dirname: __dirname, - appPath: app.getAppPath(), - resourcesPath: process.resourcesPath, - userDataPath: USER_DATA_PATH, - appUserDataPath: app.getPath('userData'), - homeDir: os.homedir(), - appDataDir: process.env.APPDATA, - cwd: process.cwd(), - joinPath: (...parts) => path.join(...parts), -}); -const buildFrequencyDictionaryRootsHandler = createBuildFrequencyDictionaryRootsMainHandler({ - platform: process.platform, - dirname: __dirname, - appPath: app.getAppPath(), - resourcesPath: process.resourcesPath, - userDataPath: USER_DATA_PATH, - appUserDataPath: app.getPath('userData'), - homeDir: os.homedir(), - appDataDir: process.env.APPDATA, - cwd: process.cwd(), - joinPath: (...parts) => path.join(...parts), -}); - -const jlptDictionaryRuntime = createJlptDictionaryRuntimeService( - createBuildJlptDictionaryRuntimeMainDepsHandler({ - isJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, - getDictionaryRoots: () => buildDictionaryRootsHandler(), - getJlptDictionarySearchPaths, - setJlptLevelLookup: (lookup) => { - appState.jlptLevelLookup = lookup; - }, - logInfo: (message) => logger.info(message), - })(), -); - -const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService( - createBuildFrequencyDictionaryRuntimeMainDepsHandler({ - isFrequencyDictionaryEnabled: () => - getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, - getDictionaryRoots: () => buildFrequencyDictionaryRootsHandler(), - getFrequencyDictionarySearchPaths, - getSourcePath: () => getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath, - setFrequencyRankLookup: (lookup) => { - appState.frequencyRankLookup = lookup; - }, - logInfo: (message) => logger.info(message), - })(), -); - -const buildGetFieldGroupingResolverMainDepsHandler = - createBuildGetFieldGroupingResolverMainDepsHandler({ - getResolver: () => appState.fieldGroupingResolver, - }); -const getFieldGroupingResolverMainDeps = buildGetFieldGroupingResolverMainDepsHandler(); -const getFieldGroupingResolverHandler = createGetFieldGroupingResolverHandler( - getFieldGroupingResolverMainDeps, -); - -function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null { - return getFieldGroupingResolverHandler(); -} - -const buildSetFieldGroupingResolverMainDepsHandler = - createBuildSetFieldGroupingResolverMainDepsHandler({ - setResolver: (resolver) => { - appState.fieldGroupingResolver = resolver; - }, - nextSequence: () => { - appState.fieldGroupingResolverSequence += 1; - return appState.fieldGroupingResolverSequence; - }, - getSequence: () => appState.fieldGroupingResolverSequence, - }); -const setFieldGroupingResolverMainDeps = buildSetFieldGroupingResolverMainDepsHandler(); -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), - getRestoreVisibleOverlayOnModalClose: () => - overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), - sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => - overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), - })(), -); -const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback; - -const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions'); - -const mediaRuntime = createMediaRuntimeService( - createBuildMediaRuntimeMainDepsHandler({ - isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), - loadSubtitlePosition: () => loadSubtitlePosition(), - getCurrentMediaPath: () => appState.currentMediaPath, - getPendingSubtitlePosition: () => appState.pendingSubtitlePosition, - getSubtitlePositionsDir: () => SUBTITLE_POSITIONS_DIR, - setCurrentMediaPath: (nextPath: string | null) => { - appState.currentMediaPath = nextPath; - }, - clearPendingSubtitlePosition: () => { - appState.pendingSubtitlePosition = null; - }, - setSubtitlePosition: (position: SubtitlePosition | null) => { - appState.subtitlePosition = position; - }, - broadcastToOverlayWindows: (channel, payload) => { - broadcastToOverlayWindows(channel, payload); - }, - getCurrentMediaTitle: () => appState.currentMediaTitle, - setCurrentMediaTitle: (title) => { - appState.currentMediaTitle = title; - }, - })(), -); - -const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({ - userDataPath: USER_DATA_PATH, - getCurrentMediaPath: () => appState.currentMediaPath, - getCurrentMediaTitle: () => appState.currentMediaTitle, - resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath), - guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), - getCollapsibleSectionOpenState: (section) => - getResolvedConfig().anilist.characterDictionary.collapsibleSections[section], - now: () => Date.now(), - logInfo: (message) => logger.info(message), - logWarn: (message) => logger.warn(message), -}); - -const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath: USER_DATA_PATH, - getConfig: () => { - const config = getResolvedConfig().anilist.characterDictionary; - return { - enabled: - config.enabled && - yomitanProfilePolicy.isCharacterDictionaryEnabled() && - !isYoutubePlaybackActiveNow(), - maxLoaded: config.maxLoaded, - profileScope: config.profileScope, - }; - }, - getOrCreateCurrentSnapshot: (targetPath, progress) => - characterDictionaryRuntime.getOrCreateCurrentSnapshot(targetPath, progress), - buildMergedDictionary: (mediaIds) => characterDictionaryRuntime.buildMergedDictionary(mediaIds), - waitForYomitanMutationReady: () => - currentMediaTokenizationGate.waitUntilReady( - appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, - ), - getYomitanDictionaryInfo: async () => { - await ensureYomitanExtensionLoaded(); - return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), { - error: (message, ...args) => logger.error(message, ...args), - info: (message, ...args) => logger.info(message, ...args), - }); - }, - importYomitanDictionary: async (zipPath) => { - if (yomitanProfilePolicy.isExternalReadOnlyMode()) { - yomitanProfilePolicy.logSkippedWrite( - formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath), - ); - return false; - } - await ensureYomitanExtensionLoaded(); - return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), { - error: (message, ...args) => logger.error(message, ...args), - info: (message, ...args) => logger.info(message, ...args), - }); - }, - deleteYomitanDictionary: async (dictionaryTitle) => { - if (yomitanProfilePolicy.isExternalReadOnlyMode()) { - yomitanProfilePolicy.logSkippedWrite( - formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle), - ); - return false; - } - await ensureYomitanExtensionLoaded(); - return await deleteYomitanDictionaryByTitle(dictionaryTitle, getYomitanParserRuntimeDeps(), { - error: (message, ...args) => logger.error(message, ...args), - info: (message, ...args) => logger.info(message, ...args), - }); - }, - upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => { - if (yomitanProfilePolicy.isExternalReadOnlyMode()) { - yomitanProfilePolicy.logSkippedWrite( - formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle), - ); - return false; - } - await ensureYomitanExtensionLoaded(); - return await upsertYomitanDictionarySettings( - dictionaryTitle, - profileScope, - getYomitanParserRuntimeDeps(), - { - error: (message, ...args) => logger.error(message, ...args), - info: (message, ...args) => logger.info(message, ...args), - }, - ); - }, - now: () => Date.now(), - schedule: (fn, delayMs) => setTimeout(fn, delayMs), - clearSchedule: (timer) => clearTimeout(timer), - logInfo: (message) => logger.info(message), - logWarn: (message) => logger.warn(message), - onSyncStatus: (event) => { - notifyCharacterDictionaryAutoSyncStatus(event, { - getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType, - showOsd: (message) => showMpvOsd(message), - showDesktopNotification: (title, options) => showDesktopNotification(title, options), - startupOsdSequencer, - }); - }, - onSyncComplete: ({ mediaId, mediaTitle, changed }) => { - handleCharacterDictionaryAutoSyncComplete( - { - mediaId, - mediaTitle, - changed, - }, - { - hasParserWindow: () => Boolean(appState.yomitanParserWindow), - clearParserCaches: () => { - if (appState.yomitanParserWindow) { - clearYomitanParserCachesForWindow(appState.yomitanParserWindow); - } - }, - invalidateTokenizationCache: () => { - subtitleProcessingController.invalidateTokenizationCache(); - }, - refreshSubtitlePrefetch: () => { - subtitlePrefetchService?.onSeek(lastObservedTimePos); - }, - refreshCurrentSubtitle: () => { - subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); - }, - logInfo: (message) => logger.info(message), - }, - ); - }, -}); - -const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( - createBuildOverlayVisibilityRuntimeMainDepsHandler({ - getMainWindow: () => overlayManager.getMainWindow(), - getModalActive: () => overlayModalInputState.getModalInputExclusive(), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getForceMousePassthrough: () => appState.statsOverlayVisible, - getWindowTracker: () => appState.windowTracker, - getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, - setTrackerNotReadyWarningShown: (shown: boolean) => { - appState.trackerNotReadyWarningShown = shown; - }, - updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), - ensureOverlayWindowLevel: (window) => { - ensureOverlayWindowLevel(window); - }, - syncPrimaryOverlayWindowLayer: (layer) => { - syncPrimaryOverlayWindowLayer(layer); - }, - enforceOverlayLayerOrder: () => { - enforceOverlayLayerOrder(); - }, - syncOverlayShortcuts: () => { - overlayShortcutsRuntime.syncOverlayShortcuts(); - }, - isMacOSPlatform: () => process.platform === 'darwin', - isWindowsPlatform: () => process.platform === 'win32', - showOverlayLoadingOsd: (message: string) => { - showMpvOsd(message); - }, - resolveFallbackBounds: () => { - const cursorPoint = screen.getCursorScreenPoint(); - const display = screen.getDisplayNearestPoint(cursorPoint); - const fallbackBounds = display.workArea; - return { - x: fallbackBounds.x, - y: fallbackBounds.y, - width: fallbackBounds.width, - height: fallbackBounds.height, - }; - }, - })(), -); -const buildGetRuntimeOptionsStateMainDepsHandler = - createBuildGetRuntimeOptionsStateMainDepsHandler({ - getRuntimeOptionsManager: () => appState.runtimeOptionsManager, - }); -const getRuntimeOptionsStateMainDeps = buildGetRuntimeOptionsStateMainDepsHandler(); -const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler( - getRuntimeOptionsStateMainDeps, -); - -function getRuntimeOptionsState(): RuntimeOptionState[] { - return getRuntimeOptionsStateHandler(); -} - -function getOverlayWindows(): BrowserWindow[] { - return overlayManager.getOverlayWindows(); -} - -const buildRestorePreviousSecondarySubVisibilityMainDepsHandler = - createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({ - getMpvClient: () => appState.mpvClient, - }); -syncOverlayVisibilityForModal = () => { - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); -}; - -function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { - overlayManager.broadcastToOverlayWindows(channel, ...args); -} - -const buildBroadcastRuntimeOptionsChangedMainDepsHandler = - createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({ - broadcastRuntimeOptionsChangedRuntime, - getRuntimeOptionsState: () => getRuntimeOptionsState(), - broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args), - }); - -const buildSendToActiveOverlayWindowMainDepsHandler = - createBuildSendToActiveOverlayWindowMainDepsHandler({ - sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) => - overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), - }); - -const buildSetOverlayDebugVisualizationEnabledMainDepsHandler = - createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler({ - setOverlayDebugVisualizationEnabledRuntime, - getCurrentEnabled: () => appState.overlayDebugVisualizationEnabled, - setCurrentEnabled: (next) => { - appState.overlayDebugVisualizationEnabled = next; - }, - }); - -const buildOpenRuntimeOptionsPaletteMainDepsHandler = - createBuildOpenRuntimeOptionsPaletteMainDepsHandler({ - openRuntimeOptionsPaletteRuntime: () => overlayModalRuntime.openRuntimeOptionsPalette(), - }); -const overlayVisibilityComposer = composeOverlayVisibilityRuntime({ - overlayVisibilityRuntime, - restorePreviousSecondarySubVisibilityMainDeps: - buildRestorePreviousSecondarySubVisibilityMainDepsHandler(), - broadcastRuntimeOptionsChangedMainDeps: buildBroadcastRuntimeOptionsChangedMainDepsHandler(), - sendToActiveOverlayWindowMainDeps: buildSendToActiveOverlayWindowMainDepsHandler(), - setOverlayDebugVisualizationEnabledMainDeps: - buildSetOverlayDebugVisualizationEnabledMainDepsHandler(), - 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 openRuntimeOptionsPalette(): void { - overlayVisibilityComposer.openRuntimeOptionsPalette(); -} - -function openPlaylistBrowser(): void { - if (!appState.mpvClient?.connected) { - showMpvOsd('Playlist browser requires active playback.'); - return; - } - const opened = openPlaylistBrowserRuntime({ - ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), - ensureOverlayWindowsReadyForVisibilityActions: () => - ensureOverlayWindowsReadyForVisibilityActions(), - sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => - sendToActiveOverlayWindow(channel, payload, runtimeOptions), - }); - if (!opened) { - showMpvOsd('Playlist browser overlay unavailable.'); - } -} - -function getResolvedConfig() { - return configService.getConfig(); -} - -function getRuntimeBooleanOption( - id: 'subtitle.annotation.nPlusOne' | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency', - fallback: boolean, -): boolean { - const value = appState.runtimeOptionsManager?.getOptionValue(id); - return typeof value === 'boolean' ? value : fallback; -} - -function shouldInitializeMecabForAnnotations(): boolean { - const config = getResolvedConfig(); - const nPlusOneEnabled = getRuntimeBooleanOption( - 'subtitle.annotation.nPlusOne', - config.ankiConnect.knownWords.highlightEnabled, - ); - const jlptEnabled = getRuntimeBooleanOption( - 'subtitle.annotation.jlpt', - config.subtitleStyle.enableJlpt, - ); - const frequencyEnabled = getRuntimeBooleanOption( - 'subtitle.annotation.frequency', - config.subtitleStyle.frequencyDictionary.enabled, - ); - return nPlusOneEnabled || jlptEnabled || frequencyEnabled; -} - -const { - getResolvedJellyfinConfig, - reportJellyfinRemoteProgress, - reportJellyfinRemoteStopped, - startJellyfinRemoteSession, - stopJellyfinRemoteSession, - runJellyfinCommand, - openJellyfinSetupWindow, -} = composeJellyfinRuntimeHandlers({ - getResolvedJellyfinConfigMainDeps: { - getResolvedConfig: () => getResolvedConfig(), - loadStoredSession: () => jellyfinTokenStore.loadSession(), - getEnv: (name) => process.env[name], - }, - getJellyfinClientInfoMainDeps: { - getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), - getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin, - }, - waitForMpvConnectedMainDeps: { - getMpvClient: () => appState.mpvClient, - now: () => Date.now(), - sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), - }, - launchMpvIdleForJellyfinPlaybackMainDeps: { - getSocketPath: () => appState.mpvSocketPath, +const { subtitle, dictionarySupport } = createSubtitleDictionaryRuntimeCoordinator({ + env: { platform: process.platform, - execPath: process.execPath, - defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, - defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS, - removeSocketPath: (socketPath) => { - fs.rmSync(socketPath, { force: true }); - }, - spawnMpv: (args) => - spawn('mpv', args, { - detached: true, - stdio: 'ignore', - }), - logWarn: (message, error) => logger.warn(message, error), - logInfo: (message) => logger.info(message), + dirname: __dirname, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + userDataPath: USER_DATA_PATH, + appUserDataPath: app.getPath('userData'), + homeDir: os.homedir(), + appDataDir: process.env.APPDATA, + cwd: process.cwd(), + configDir: CONFIG_DIR, + defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH, }, - ensureMpvConnectedForJellyfinPlaybackMainDeps: { - getMpvClient: () => appState.mpvClient, - setMpvClient: (client) => { - appState.mpvClient = client as MpvIpcClient | null; - }, - createMpvClient: () => createMpvClientRuntimeService(), - getAutoLaunchInFlight: () => jellyfinMpvAutoLaunchInFlight, - setAutoLaunchInFlight: (promise) => { - jellyfinMpvAutoLaunchInFlight = promise; - }, - connectTimeoutMs: JELLYFIN_MPV_CONNECT_TIMEOUT_MS, - autoLaunchTimeoutMs: JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS, + appState, + getResolvedConfig: () => getResolvedConfig(), + services: { + subtitleWsService, + annotationSubtitleWsService, + overlayManager, + startupOsdSequencer, }, - preloadJellyfinExternalSubtitlesMainDeps: { - listJellyfinSubtitleTracks: (session, clientInfo, itemId) => - listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId), - getMpvClient: () => appState.mpvClient, - sendMpvCommand: (command) => { - sendMpvCommandRuntime(appState.mpvClient, command); - }, - wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - logDebug: (message, error) => { - logger.debug(message, error); - }, + logging: { + debug: (message, ...args) => logger.debug(message, ...args), + info: (message, ...args) => logger.info(message, ...args), + warn: (message, ...args) => logger.warn(message, ...args), + error: (message, ...args) => logger.error(message, ...args), }, - playJellyfinItemInMpvMainDeps: { - getMpvClient: () => appState.mpvClient, - resolvePlaybackPlan: (params) => - resolveJellyfinPlaybackPlanRuntime( - params.session, - params.clientInfo, - params.jellyfinConfig as ReturnType, - { - itemId: params.itemId, - audioStreamIndex: params.audioStreamIndex ?? undefined, - subtitleStreamIndex: params.subtitleStreamIndex ?? undefined, - }, - ), - applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient), - sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), - armQuitOnDisconnect: () => { - jellyfinPlayQuitOnDisconnectArmed = false; - setTimeout(() => { - jellyfinPlayQuitOnDisconnectArmed = true; - }, 3000); - }, - schedule: (callback, delayMs) => { - setTimeout(callback, delayMs); - }, - convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks), - setActivePlayback: (state) => { - activeJellyfinRemotePlayback = state as ActiveJellyfinRemotePlaybackState; - }, - setLastProgressAtMs: (value) => { - jellyfinRemoteLastProgressAtMs = value; - }, - reportPlaying: (payload) => { - void appState.jellyfinRemoteSession?.reportPlaying(payload); - }, - showMpvOsd: (text) => { - showMpvOsd(text); - }, + subtitle: { + parseSubtitleCues, + createSubtitlePrefetchService: (deps) => createSubtitlePrefetchService(deps), }, - remoteComposerOptions: { - getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()), - logWarn: (message) => logger.warn(message), - getMpvClient: () => appState.mpvClient, - sendMpvCommand: (client, command) => sendMpvCommandRuntime(client as MpvIpcClient, command), - jellyfinTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks), - getActivePlayback: () => activeJellyfinRemotePlayback, - clearActivePlayback: () => { - activeJellyfinRemotePlayback = null; - }, - getSession: () => appState.jellyfinRemoteSession, - getNow: () => Date.now(), - getLastProgressAtMs: () => jellyfinRemoteLastProgressAtMs, - setLastProgressAtMs: (value) => { - jellyfinRemoteLastProgressAtMs = value; - }, - progressIntervalMs: JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS, - ticksPerSecond: JELLYFIN_TICKS_PER_SECOND, - logDebug: (message, error) => logger.debug(message, error), - }, - handleJellyfinAuthCommandsMainDeps: { - patchRawConfig: (patch) => { - configService.patchRawConfig(patch); - }, - authenticateWithPassword: (serverUrl, username, password, clientInfo) => - authenticateWithPasswordRuntime(serverUrl, username, password, clientInfo), - saveStoredSession: (session) => jellyfinTokenStore.saveSession(session), - clearStoredSession: () => jellyfinTokenStore.clearSession(), - logInfo: (message) => logger.info(message), - }, - handleJellyfinListCommandsMainDeps: { - listJellyfinLibraries: (session, clientInfo) => - listJellyfinLibrariesRuntime(session, clientInfo), - listJellyfinItems: (session, clientInfo, params) => - listJellyfinItemsRuntime(session, clientInfo, params), - listJellyfinSubtitleTracks: (session, clientInfo, itemId) => - listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId), - writeJellyfinPreviewAuth: (responsePath, payload) => { - fs.mkdirSync(path.dirname(responsePath), { recursive: true }); - fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf-8'); - }, - logInfo: (message) => logger.info(message), - }, - handleJellyfinPlayCommandMainDeps: { - logWarn: (message) => logger.warn(message), - }, - handleJellyfinRemoteAnnounceCommandMainDeps: { - getRemoteSession: () => appState.jellyfinRemoteSession, - logInfo: (message) => logger.info(message), - logWarn: (message) => logger.warn(message), - }, - startJellyfinRemoteSessionMainDeps: { - getCurrentSession: () => appState.jellyfinRemoteSession, - setCurrentSession: (session) => { - appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession; - }, - createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options), - defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId, - defaultClientName: DEFAULT_CONFIG.jellyfin.clientName, - defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion, - logInfo: (message) => logger.info(message), - logWarn: (message, details) => logger.warn(message, details), - }, - stopJellyfinRemoteSessionMainDeps: { - getCurrentSession: () => appState.jellyfinRemoteSession, - setCurrentSession: (session) => { - appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession; - }, - clearActivePlayback: () => { - activeJellyfinRemotePlayback = null; - }, - }, - runJellyfinCommandMainDeps: { - defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl, - }, - maybeFocusExistingJellyfinSetupWindowMainDeps: { - getSetupWindow: () => appState.jellyfinSetupWindow, - }, - openJellyfinSetupWindowMainDeps: { - createSetupWindow: createCreateJellyfinSetupWindowHandler({ - createBrowserWindow: (options) => new BrowserWindow(options), - }), - buildSetupFormHtml: (defaultServer, defaultUser) => - buildJellyfinSetupFormHtml(defaultServer, defaultUser), - parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), - authenticateWithPassword: (server, username, password, clientInfo) => - authenticateWithPasswordRuntime(server, username, password, clientInfo), - saveStoredSession: (session) => jellyfinTokenStore.saveSession(session), - patchJellyfinConfig: (session) => { - configService.patchRawConfig({ - jellyfin: { - enabled: true, - serverUrl: session.serverUrl, - username: session.username, - }, - }); - }, - logInfo: (message) => logger.info(message), - logError: (message, error) => logger.error(message, error), - showMpvOsd: (message) => showMpvOsd(message), - clearSetupWindow: () => { - appState.jellyfinSetupWindow = null; - }, - setSetupWindow: (window) => { - appState.jellyfinSetupWindow = window as BrowserWindow; - }, - encodeURIComponent: (value) => encodeURIComponent(value), - }, -}); - -const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({ - getSetupWindow: () => appState.firstRunSetupWindow, -}); -const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ - maybeFocusExistingSetupWindow: maybeFocusExistingFirstRunSetupWindow, - createSetupWindow: createCreateFirstRunSetupWindowHandler({ - createBrowserWindow: (options) => new BrowserWindow(options), - }), - getSetupSnapshot: async () => { - const snapshot = await firstRunSetupService.getSetupStatus(); - return { - configReady: snapshot.configReady, - dictionaryCount: snapshot.dictionaryCount, - canFinish: snapshot.canFinish, - externalYomitanConfigured: snapshot.externalYomitanConfigured, - pluginStatus: snapshot.pluginStatus, - pluginInstallPathSummary: snapshot.pluginInstallPathSummary, - windowsMpvShortcuts: snapshot.windowsMpvShortcuts, - message: firstRunSetupMessage, - }; - }, - buildSetupHtml: (model) => buildFirstRunSetupHtml(model), - parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl), - handleAction: async (submission: FirstRunSetupSubmission) => { - if (submission.action === 'install-plugin') { - const snapshot = await firstRunSetupService.installMpvPlugin(); - firstRunSetupMessage = snapshot.message; - return; - } - if (submission.action === 'configure-windows-mpv-shortcuts') { - const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({ - startMenuEnabled: submission.startMenuEnabled === true, - desktopEnabled: submission.desktopEnabled === true, - }); - firstRunSetupMessage = snapshot.message; - return; - } - if (submission.action === 'open-yomitan-settings') { - firstRunSetupMessage = openYomitanSettings() - ? 'Opened Yomitan settings. Install dictionaries, then refresh status.' - : 'Yomitan settings are unavailable while external read-only profile mode is enabled.'; - return; - } - if (submission.action === 'refresh') { - const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.'); - firstRunSetupMessage = snapshot.message; - return; - } - if (submission.action === 'skip-plugin') { - await firstRunSetupService.skipPluginInstall(); - firstRunSetupMessage = 'mpv plugin installation skipped.'; - return; - } - - const snapshot = await firstRunSetupService.markSetupCompleted(); - if (snapshot.state.status === 'completed') { - firstRunSetupMessage = null; - return { closeWindow: true }; - } - firstRunSetupMessage = 'Install at least one Yomitan dictionary before finishing setup.'; - return; - }, - markSetupInProgress: async () => { - firstRunSetupMessage = null; - await firstRunSetupService.markSetupInProgress(); - }, - markSetupCancelled: async () => { - firstRunSetupMessage = null; - await firstRunSetupService.markSetupCancelled(); - }, - isSetupCompleted: () => firstRunSetupService.isSetupCompleted(), - shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode, - quitApp: () => requestAppQuit(), - clearSetupWindow: () => { - appState.firstRunSetupWindow = null; - }, - setSetupWindow: (window) => { - appState.firstRunSetupWindow = window as BrowserWindow; - }, - encodeURIComponent: (value) => encodeURIComponent(value), - logError: (message, error) => logger.error(message, error), -}); - -function openFirstRunSetupWindow(): void { - if (firstRunSetupService.isSetupCompleted()) { - return; - } - openFirstRunSetupWindowHandler(); -} - -const { - notifyAnilistSetup, - consumeAnilistSetupTokenFromUrl, - handleAnilistSetupProtocolUrl, - registerSubminerProtocolClient, -} = composeAnilistSetupHandlers({ - notifyDeps: { - hasMpvClient: () => Boolean(appState.mpvClient), - showMpvOsd: (message) => showMpvOsd(message), + overlay: { + getOverlayUi: () => subtitleDictionaryOverlayUiAdapter, + showMpvOsd: (message) => mpvRuntime.showMpvOsd(message), showDesktopNotification: (title, options) => showDesktopNotification(title, options), - logInfo: (message) => logger.info(message), }, - consumeTokenDeps: { - consumeAnilistSetupCallbackUrl, - saveToken: (token) => anilistTokenStore.saveToken(token), - setCachedToken: (token) => { - anilistCachedAccessToken = token; - }, - setResolvedState: (resolvedAt) => { - anilistStateRuntime.setClientSecretState({ - status: 'resolved', - source: 'stored', - message: 'saved token from AniList login', - resolvedAt, - errorAt: null, - }); - }, - setSetupPageOpened: (opened) => { - appState.anilistSetupPageOpened = opened; - }, - onSuccess: () => { - notifyAnilistSetup('AniList login success'); - }, - closeWindow: () => { - if (appState.anilistSetupWindow && !appState.anilistSetupWindow.isDestroyed()) { - appState.anilistSetupWindow.close(); - } - }, + playback: { + isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), + isYoutubePlaybackActive, + waitForYomitanMutationReady: (mediaKey) => + currentMediaTokenizationGate.waitUntilReady(mediaKey), }, - handleProtocolDeps: { - consumeAnilistSetupTokenFromUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl), - logWarn: (message, details) => logger.warn(message, details), - }, - registerProtocolClientDeps: { - isDefaultApp: () => Boolean(process.defaultApp), - getArgv: () => process.argv, - execPath: process.execPath, - resolvePath: (value) => path.resolve(value), - setAsDefaultProtocolClient: (scheme, appPath, args) => - appPath - ? app.setAsDefaultProtocolClient(scheme, appPath, args) - : app.setAsDefaultProtocolClient(scheme), - logDebug: (message, details) => logger.debug(message, details), - }, -}); - -const maybeFocusExistingAnilistSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({ - getSetupWindow: () => appState.anilistSetupWindow, -}); -const buildOpenAnilistSetupWindowMainDepsHandler = createBuildOpenAnilistSetupWindowMainDepsHandler( - { - maybeFocusExistingSetupWindow: maybeFocusExistingAnilistSetupWindow, - createSetupWindow: createCreateAnilistSetupWindowHandler({ - createBrowserWindow: (options) => new BrowserWindow(options), - }), - buildAuthorizeUrl: () => - buildAnilistSetupUrl({ - authorizeUrl: ANILIST_SETUP_CLIENT_ID_URL, - clientId: ANILIST_DEFAULT_CLIENT_ID, - responseType: ANILIST_SETUP_RESPONSE_TYPE, - }), - consumeCallbackUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl), - openSetupInBrowser: (authorizeUrl) => - openAnilistSetupInBrowser({ - authorizeUrl, - openExternal: (url) => shell.openExternal(url), - logError: (message, error) => logger.error(message, error), - }), - loadManualTokenEntry: (setupWindow, authorizeUrl) => - loadAnilistManualTokenEntry({ - setupWindow: setupWindow as BrowserWindow, - authorizeUrl, - developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, - logWarn: (message, data) => logger.warn(message, data), - }), - redirectUri: ANILIST_REDIRECT_URI, - developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, - isAllowedExternalUrl: (url) => isAllowedAnilistExternalUrl(url), - isAllowedNavigationUrl: (url) => isAllowedAnilistSetupNavigationUrl(url), - logWarn: (message, details) => logger.warn(message, details), - logError: (message, details) => logger.error(message, details), - clearSetupWindow: () => { - appState.anilistSetupWindow = null; - }, - setSetupPageOpened: (opened) => { - appState.anilistSetupPageOpened = opened; - }, - setSetupWindow: (setupWindow) => { - appState.anilistSetupWindow = setupWindow as BrowserWindow; - }, - openExternal: (url) => { - void shell.openExternal(url); - }, - }, -); - -function openAnilistSetupWindow(): void { - createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(); -} - -const { - refreshAnilistClientSecretState, - getCurrentAnilistMediaKey, - resetAnilistMediaTracking, - getAnilistMediaGuessRuntimeState, - setAnilistMediaGuessRuntimeState, - resetAnilistMediaGuessState, - maybeProbeAnilistDuration, - ensureAnilistMediaGuess, - processNextAnilistRetryUpdate, - maybeRunAnilistPostWatchUpdate, -} = composeAnilistTrackingHandlers({ - refreshClientSecretMainDeps: { - getResolvedConfig: () => getResolvedConfig(), - isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), - getCachedAccessToken: () => anilistCachedAccessToken, - setCachedAccessToken: (token) => { - anilistCachedAccessToken = token; - }, - saveStoredToken: (token) => { - anilistTokenStore.saveToken(token); - }, - loadStoredToken: () => anilistTokenStore.loadToken(), - setClientSecretState: (state) => { - anilistStateRuntime.setClientSecretState(state); - }, - getAnilistSetupPageOpened: () => appState.anilistSetupPageOpened, - setAnilistSetupPageOpened: (opened) => { - appState.anilistSetupPageOpened = opened; - }, - openAnilistSetupWindow: () => { - openAnilistSetupWindow(); - }, - now: () => Date.now(), - }, - getCurrentMediaKeyMainDeps: { - getCurrentMediaPath: () => appState.currentMediaPath, - }, - resetMediaTrackingMainDeps: { - setMediaKey: (value) => { - anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( - anilistMediaGuessRuntimeState, - { mediaKey: value }, - ); - }, - setMediaDurationSec: (value) => { - anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( - anilistMediaGuessRuntimeState, - { mediaDurationSec: value }, - ); - }, - setMediaGuess: (value) => { - anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( - anilistMediaGuessRuntimeState, - { mediaGuess: value }, - ); - }, - setMediaGuessPromise: (value) => { - anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( - anilistMediaGuessRuntimeState, - { mediaGuessPromise: value }, - ); - }, - setLastDurationProbeAtMs: (value) => { - anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( - anilistMediaGuessRuntimeState, - { lastDurationProbeAtMs: value }, - ); - }, - }, - getMediaGuessRuntimeStateMainDeps: { - getMediaKey: () => anilistMediaGuessRuntimeState.mediaKey, - getMediaDurationSec: () => anilistMediaGuessRuntimeState.mediaDurationSec, - getMediaGuess: () => anilistMediaGuessRuntimeState.mediaGuess, - getMediaGuessPromise: () => anilistMediaGuessRuntimeState.mediaGuessPromise, - getLastDurationProbeAtMs: () => anilistMediaGuessRuntimeState.lastDurationProbeAtMs, - }, - setMediaGuessRuntimeStateMainDeps: { - setMediaKey: (value) => { - anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( - anilistMediaGuessRuntimeState, - { mediaKey: value }, - ); - }, - setMediaDurationSec: (value) => { - anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( - anilistMediaGuessRuntimeState, - { mediaDurationSec: value }, - ); - }, - setMediaGuess: (value) => { - anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( - anilistMediaGuessRuntimeState, - { mediaGuess: value }, - ); - }, - setMediaGuessPromise: (value) => { - anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( - anilistMediaGuessRuntimeState, - { mediaGuessPromise: value }, - ); - }, - setLastDurationProbeAtMs: (value) => { - anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( - anilistMediaGuessRuntimeState, - { lastDurationProbeAtMs: value }, - ); - }, - }, - resetMediaGuessStateMainDeps: { - setMediaGuess: (value) => { - anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( - anilistMediaGuessRuntimeState, - { mediaGuess: value }, - ); - }, - setMediaGuessPromise: (value) => { - anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( - anilistMediaGuessRuntimeState, - { mediaGuessPromise: value }, - ); - }, - }, - maybeProbeDurationMainDeps: { - getState: () => getAnilistMediaGuessRuntimeState(), - setState: (state) => { - setAnilistMediaGuessRuntimeState(state); - }, - durationRetryIntervalMs: ANILIST_DURATION_RETRY_INTERVAL_MS, - now: () => Date.now(), - requestMpvDuration: async () => appState.mpvClient?.requestProperty('duration'), - logWarn: (message, error) => logger.warn(message, error), - }, - ensureMediaGuessMainDeps: { - getState: () => getAnilistMediaGuessRuntimeState(), - setState: (state) => { - setAnilistMediaGuessRuntimeState(state); - }, - resolveMediaPathForJimaku: (currentMediaPath) => - mediaRuntime.resolveMediaPathForJimaku(currentMediaPath), - getCurrentMediaPath: () => appState.currentMediaPath, - getCurrentMediaTitle: () => appState.currentMediaTitle, + anilist: { guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), }, - processNextRetryUpdateMainDeps: { - nextReady: () => anilistUpdateQueue.nextReady(), - refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(), - setLastAttemptAt: (value) => { - appState.anilistRetryQueueState = transitionAnilistRetryQueueLastAttemptAt( - appState.anilistRetryQueueState, - value, - ); - }, - setLastError: (value) => { - appState.anilistRetryQueueState = transitionAnilistRetryQueueLastError( - appState.anilistRetryQueueState, - value, - ); - }, - refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(), - updateAnilistPostWatchProgress: (accessToken, title, episode) => - updateAnilistPostWatchProgress(accessToken, title, episode), - markSuccess: (key) => { - anilistUpdateQueue.markSuccess(key); - }, - rememberAttemptedUpdateKey: (key) => { - rememberAnilistAttemptedUpdate(key); - }, - markFailure: (key, message) => { - anilistUpdateQueue.markFailure(key, message); - }, - logInfo: (message) => logger.info(message), - now: () => Date.now(), - }, - maybeRunPostWatchUpdateMainDeps: { - getInFlight: () => anilistUpdateInFlightState.inFlight, - setInFlight: (value) => { - anilistUpdateInFlightState = transitionAnilistUpdateInFlightState( - anilistUpdateInFlightState, - value, - ); - }, - getResolvedConfig: () => getResolvedConfig(), - isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), - getCurrentMediaKey: () => getCurrentAnilistMediaKey(), - hasMpvClient: () => Boolean(appState.mpvClient), - getTrackedMediaKey: () => anilistMediaGuessRuntimeState.mediaKey, - resetTrackedMedia: (mediaKey) => { - resetAnilistMediaTracking(mediaKey); - }, - getWatchedSeconds: () => appState.mpvClient?.currentTimePos ?? Number.NaN, - maybeProbeAnilistDuration: (mediaKey) => maybeProbeAnilistDuration(mediaKey), - ensureAnilistMediaGuess: (mediaKey) => ensureAnilistMediaGuess(mediaKey), - hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key), - processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), - refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(), - enqueueRetry: (key, title, episode) => { - anilistUpdateQueue.enqueue(key, title, episode); - }, - markRetryFailure: (key, message) => { - anilistUpdateQueue.markFailure(key, message); - }, - markRetrySuccess: (key) => { - anilistUpdateQueue.markSuccess(key); - }, - refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(), - updateAnilistPostWatchProgress: (accessToken, title, episode) => - updateAnilistPostWatchProgress(accessToken, title, episode), - rememberAttemptedUpdateKey: (key) => { - rememberAnilistAttemptedUpdate(key); - }, - showMpvOsd: (message) => showMpvOsd(message), - logInfo: (message) => logger.info(message), - logWarn: (message) => logger.warn(message), - minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS, - minWatchRatio: DEFAULT_MIN_WATCH_RATIO, + yomitan: { + isCharacterDictionaryEnabled: () => yomitanProfilePolicy.isCharacterDictionaryEnabled(), + isExternalReadOnlyMode: () => yomitanProfilePolicy.isExternalReadOnlyMode(), + logSkippedWrite: (message) => yomitanProfilePolicy.logSkippedWrite(message), + ensureYomitanExtensionLoaded: () => yomitan.ensureYomitanExtensionLoaded(), + getParserRuntimeDeps: () => yomitan.getParserRuntimeDeps(), }, }); -function refreshAnilistClientSecretStateIfEnabled(options?: { - force?: boolean; - allowSetupPrompt?: boolean; -}): Promise { - if (!isAnilistTrackingEnabled(getResolvedConfig())) { - return Promise.resolve(null); - } - return refreshAnilistClientSecretState(options); -} +const getResolvedConfig = () => configService.getConfig(); -const rememberAnilistAttemptedUpdate = (key: string): void => { - rememberAnilistAttemptedUpdateKey( - anilistAttemptedUpdateKeys, - key, - ANILIST_MAX_ATTEMPTED_UPDATE_KEYS, +const getRuntimeBooleanOption = ( + id: 'subtitle.annotation.nPlusOne' | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency', + fallback: boolean, +): boolean => + getRuntimeBooleanOptionFromManager( + (optionId) => appState.runtimeOptionsManager?.getOptionValue(optionId), + id, + fallback, ); -}; -const buildLoadSubtitlePositionMainDepsHandler = createBuildLoadSubtitlePositionMainDepsHandler({ - loadSubtitlePositionCore: () => - loadSubtitlePositionCore({ - currentMediaPath: appState.currentMediaPath, - fallbackPosition: getResolvedConfig().subtitlePosition, - subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, - }), - setSubtitlePosition: (position) => { - appState.subtitlePosition = position; +const shouldInitializeMecabForAnnotations = (): boolean => + shouldInitializeMecabForAnnotationsFromRuntimeOptions({ + getResolvedConfig: () => getResolvedConfig(), + getRuntimeBooleanOption: (id, fallback) => getRuntimeBooleanOption(id, fallback), + }); + +const jellyfin = createJellyfinRuntimeCoordinator({ + getResolvedConfig: () => getResolvedConfig(), + configService: { + patchRawConfig: (patch) => { + configService.patchRawConfig(patch as Parameters[0]); + }, + }, + tokenStore: jellyfinTokenStore, + platform: process.platform, + execPath: process.execPath, + defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, + defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS, + connectTimeoutMs: JELLYFIN_MPV_CONNECT_TIMEOUT_MS, + autoLaunchTimeoutMs: JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS, + langPref: JELLYFIN_LANG_PREF, + progressIntervalMs: JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS, + ticksPerSecond: JELLYFIN_TICKS_PER_SECOND, + appState, + actions: { + createMpvClient: () => mpvRuntime.createMpvClientRuntimeService(), + applyJellyfinMpvDefaults: (client) => startupSupport.applyJellyfinMpvDefaults(client), + showMpvOsd: (message) => mpvRuntime.showMpvOsd(message), + }, + logger, +}); + +const anilist = createAnilistRuntimeCoordinator({ + getResolvedConfig: () => getResolvedConfig(), + isTrackingEnabled: (config) => isAnilistTrackingEnabled(config), + tokenStore: anilistTokenStore, + updateQueue: anilistUpdateQueue, + appState, + dictionarySupport, + actions: { + showMpvOsd: (message) => mpvRuntime.showMpvOsd(message), + showDesktopNotification: (title, options) => showDesktopNotification(title, options), + }, + logger, + constants: { + authorizeUrl: ANILIST_SETUP_CLIENT_ID_URL, + clientId: ANILIST_DEFAULT_CLIENT_ID, + responseType: ANILIST_SETUP_RESPONSE_TYPE, + redirectUri: ANILIST_REDIRECT_URI, + developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, + durationRetryIntervalMs: ANILIST_DURATION_RETRY_INTERVAL_MS, + minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS, + maxAttemptedUpdateKeys: ANILIST_MAX_ATTEMPTED_UPDATE_KEYS, }, }); -const loadSubtitlePositionMainDeps = buildLoadSubtitlePositionMainDepsHandler(); -const loadSubtitlePosition = createLoadSubtitlePositionHandler(loadSubtitlePositionMainDeps); -const buildSaveSubtitlePositionMainDepsHandler = createBuildSaveSubtitlePositionMainDepsHandler({ - saveSubtitlePositionCore: (position) => { - saveSubtitlePositionCore({ - position, - currentMediaPath: appState.currentMediaPath, - subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, - onQueuePending: (queued) => { - appState.pendingSubtitlePosition = queued; - }, - onPersisted: () => { - appState.pendingSubtitlePosition = null; - }, - }); - }, - setSubtitlePosition: (position) => { - appState.subtitlePosition = position; - }, -}); -const saveSubtitlePositionMainDeps = buildSaveSubtitlePositionMainDepsHandler(); -const saveSubtitlePosition = createSaveSubtitlePositionHandler(saveSubtitlePositionMainDeps); - -registerSubminerProtocolClient(); +anilist.registerSubminerProtocolClient(); let flushPendingMpvLogWrites = (): void => {}; const { registerProtocolUrlHandlers: registerProtocolUrlHandlersHandler, onWillQuitCleanup: onWillQuitCleanupHandler, shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler, restoreWindowsOnActivate: restoreWindowsOnActivateHandler, -} = composeStartupLifecycleHandlers({ - registerProtocolUrlHandlersMainDeps: { +} = createStartupLifecycleRuntime({ + protocolUrl: { registerOpenUrl: (listener) => { app.on('open-url', listener); }, registerSecondInstance: (listener) => { registerSecondInstanceHandlerEarly(app, listener); }, - handleAnilistSetupProtocolUrl: (rawUrl) => handleAnilistSetupProtocolUrl(rawUrl), + handleAnilistSetupProtocolUrl: (rawUrl) => anilist.handleAnilistSetupProtocolUrl(rawUrl), findAnilistSetupDeepLinkArgvUrl: (argv) => findAnilistSetupDeepLinkArgvUrl(argv), logUnhandledOpenUrl: (rawUrl) => { logger.warn('Unhandled app protocol URL', { rawUrl }); @@ -2686,10 +744,10 @@ const { logger.warn('Unhandled second-instance protocol URL', { rawUrl }); }, }, - onWillQuitCleanupMainDeps: { - destroyTray: () => destroyTray(), + cleanup: { + destroyTray: () => overlayUi?.destroyTray(), stopConfigHotReload: () => configHotReloadRuntime.stop(), - restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(), + restorePreviousSecondarySubVisibility: () => overlayUi?.restorePreviousSecondarySubVisibility(), restoreMpvSubVisibility: () => { restoreOverlayMpvSubtitles(); }, @@ -2720,8 +778,7 @@ const { getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getImmersionTracker: () => appState.immersionTracker, clearImmersionTracker: () => { - stopStatsServer(); - appState.statsServer = null; + stats?.stopStatsServer(); appState.immersionTracker = null; }, getAnkiIntegration: () => appState.ankiIntegration, @@ -2741,22 +798,20 @@ const { clearYomitanSettingsWindow: () => { appState.yomitanSettingsWindow = null; }, - stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), + stopJellyfinRemoteSession: () => jellyfin.stopJellyfinRemoteSession(), stopDiscordPresenceService: () => { void appState.discordPresenceService?.stop(); appState.discordPresenceService = null; }, }, - shouldRestoreWindowsOnActivateMainDeps: { + shouldRestoreWindowsOnActivate: { isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, getAllWindowCount: () => BrowserWindow.getAllWindows().length, }, - restoreWindowsOnActivateMainDeps: { - createMainWindow: () => { - createMainWindow(); - }, + restoreWindowsOnActivate: { + createMainWindow: () => overlayUi!.createMainWindow(), updateVisibleOverlayVisibility: () => { - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + overlayUi?.updateVisibleOverlayVisibility(); }, syncOverlayMpvSubtitleSuppression: () => { syncOverlayMpvSubtitleSuppression(); @@ -2765,2012 +820,376 @@ const { }); registerProtocolUrlHandlersHandler(); -const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); -const statsPreloadPath = path.join(__dirname, 'preload-stats.js'); - -const ensureStatsServerStarted = (): string => { - const liveDaemon = readLiveBackgroundStatsDaemonState(); - if (liveDaemon && liveDaemon.pid !== process.pid) { - return resolveBackgroundStatsServerUrl(liveDaemon); - } - const tracker = appState.immersionTracker; - if (!tracker) { - throw new Error('Immersion tracker failed to initialize.'); - } - if (!statsServer) { - const yomitanDeps = { - getYomitanExt: () => appState.yomitanExt, - getYomitanSession: () => appState.yomitanSession, - getYomitanParserWindow: () => appState.yomitanParserWindow, - setYomitanParserWindow: (w: BrowserWindow | null) => { - appState.yomitanParserWindow = w; - }, - getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, - setYomitanParserReadyPromise: (p: Promise | null) => { - appState.yomitanParserReadyPromise = p; - }, - getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, - setYomitanParserInitPromise: (p: Promise | null) => { - appState.yomitanParserInitPromise = p; - }, - }; - const yomitanLogger = createLogger('main:yomitan-stats'); - statsServer = startStatsServer({ - port: getResolvedConfig().stats.serverPort, - staticDir: statsDistPath, - tracker, - knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'), - mpvSocketPath: appState.mpvSocketPath, - ankiConnectConfig: getResolvedConfig().ankiConnect, - resolveAnkiNoteId: (noteId: number) => - appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, - addYomitanNote: async (word: string) => { - const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765'; - await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { - forceOverride: true, - }); - return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); - }, - }); - appState.statsServer = statsServer; - } - appState.statsServer = statsServer; - return `http://127.0.0.1:${getResolvedConfig().stats.serverPort}`; -}; - +const statsCoordinator = createStatsRuntimeFromMainState({ + dirname: __dirname, + userDataPath: USER_DATA_PATH, + appState, + getResolvedConfig: () => getResolvedConfig(), + dictionarySupport: { + getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), + seedImmersionMediaFromCurrentMedia: () => immersionMediaRuntime.seedFromCurrentMedia(), + }, + overlay: { + getOverlayGeometry: () => ({ + getCurrentOverlayGeometry: () => getCurrentOverlayGeometry(), + }), + updateVisibleOverlayVisibility: () => { + overlayUi?.updateVisibleOverlayVisibility(); + }, + }, + mpvRuntime: { + createMecabTokenizerAndCheck: () => mpvRuntime.createMecabTokenizerAndCheck(), + }, + actions: { + requestAppQuit, + }, + logger, +}); +const statsBootstrap = statsCoordinator.statsBootstrap; +stats = statsCoordinator.stats; +const ensureStatsServerStarted = (): string => statsCoordinator.ensureStatsServerStarted(); const ensureBackgroundStatsServerStarted = (): { url: string; runningInCurrentProcess: boolean; -} => { - const liveDaemon = readLiveBackgroundStatsDaemonState(); - if (liveDaemon && liveDaemon.pid !== process.pid) { - return { - url: resolveBackgroundStatsServerUrl(liveDaemon), - runningInCurrentProcess: false, - }; - } - - appState.statsStartupInProgress = true; - try { - ensureImmersionTrackerStarted(); - } finally { - appState.statsStartupInProgress = false; - } - - const port = getResolvedConfig().stats.serverPort; - const url = ensureStatsServerStarted(); - writeBackgroundStatsServerState(statsDaemonStatePath, { - pid: process.pid, - port, - startedAtMs: Date.now(), - }); - return { url, runningInCurrentProcess: true }; -}; - -const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => { - const state = readBackgroundStatsServerState(statsDaemonStatePath); - if (!state) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return { ok: true, stale: true }; - } - if (!isBackgroundStatsServerProcessAlive(state.pid)) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return { ok: true, stale: true }; - } - - try { - process.kill(state.pid, 'SIGTERM'); - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') { - removeBackgroundStatsServerState(statsDaemonStatePath); - return { ok: true, stale: true }; - } - if ((error as NodeJS.ErrnoException)?.code === 'EPERM') { - throw new Error( - `Insufficient permissions to stop background stats server (pid ${state.pid}).`, - ); - } - throw error; - } - - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - if (!isBackgroundStatsServerProcessAlive(state.pid)) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return { ok: true, stale: false }; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - throw new Error('Timed out stopping background stats server.'); -}; - -const resolveLegacyVocabularyPos = async (row: { - headword: string; - word: string; - reading: string | null; -}) => { - const tokenizer = appState.mecabTokenizer; - if (!tokenizer) { - return null; - } - - const lookupTexts = [...new Set([row.headword, row.word, row.reading ?? ''])] - .map((value) => value.trim()) - .filter((value) => value.length > 0); - - for (const lookupText of lookupTexts) { - const tokens = await tokenizer.tokenize(lookupText); - const resolved = resolveLegacyVocabularyPosFromTokens(lookupText, tokens); - if (resolved) { - return resolved; - } - } - - return null; -}; - -const immersionTrackerStartupMainDeps: Parameters< - typeof createBuildImmersionTrackerStartupMainDepsHandler ->[0] = { - getResolvedConfig: () => getResolvedConfig(), - getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), - createTrackerService: (params) => - new ImmersionTrackerService({ - ...params, - resolveLegacyVocabularyPos, - }), - setTracker: (tracker) => { - const trackerHasChanged = - appState.immersionTracker !== null && appState.immersionTracker !== tracker; - if (trackerHasChanged && appState.statsServer) { - stopStatsServer(); - appState.statsServer = null; - } - - appState.immersionTracker = tracker as ImmersionTrackerService | null; - appState.immersionTracker?.setCoverArtFetcher(statsCoverArtFetcher); - if (tracker) { - // Start HTTP stats server - if (!appState.statsServer) { - const config = getResolvedConfig(); - if (config.stats.autoStartServer) { - ensureStatsServerStarted(); - } - } - - // Register stats overlay toggle IPC handler (idempotent) - registerStatsOverlayToggle({ - staticDir: statsDistPath, - preloadPath: statsPreloadPath, - getApiBaseUrl: () => ensureStatsServerStarted(), - getToggleKey: () => getResolvedConfig().stats.toggleKey, - resolveBounds: () => getCurrentOverlayGeometry(), - onVisibilityChanged: (visible) => { - appState.statsOverlayVisible = visible; - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - }, - }); - } - }, - getMpvClient: () => appState.mpvClient, - shouldAutoConnectMpv: () => !appState.statsStartupInProgress, - seedTrackerFromCurrentMedia: () => { - void immersionMediaRuntime.seedFromCurrentMedia(); - }, - logInfo: (message) => logger.info(message), - logDebug: (message) => logger.debug(message), - logWarn: (message, details) => logger.warn(message, details), -}; -const createImmersionTrackerStartup = createImmersionTrackerStartupHandler( - createBuildImmersionTrackerStartupMainDepsHandler(immersionTrackerStartupMainDeps)(), -); -const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => { - ensureImmersionTrackerStarted(); - appState.immersionTracker?.recordCardsMined(count, noteIds); -}; -let hasAttemptedImmersionTrackerStartup = false; +} => statsCoordinator.ensureBackgroundStatsServerStarted(); +const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => + await statsCoordinator.stopBackgroundStatsServer(); const ensureImmersionTrackerStarted = (): void => { - if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) { - return; - } - hasAttemptedImmersionTrackerStartup = true; - createImmersionTrackerStartup(); + statsCoordinator.ensureImmersionTrackerStarted(); +}; +const recordTrackedCardsMined = statsBootstrap.recordTrackedCardsMined; +const runStatsCliCommand = async ( + args: Pick< + CliArgs, + | 'statsResponsePath' + | 'statsBackground' + | 'statsStop' + | 'statsCleanup' + | 'statsCleanupVocab' + | 'statsCleanupLifetime' + >, + source: CliCommandSource, +): Promise => { + await statsCoordinator.runStatsCliCommand(args, source); }; -const statsStartupRuntime = { - ensureStatsServerStarted: () => ensureStatsServerStarted(), - ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(), - stopBackgroundStatsServer: () => stopBackgroundStatsServer(), - ensureImmersionTrackerStarted: () => { - appState.statsStartupInProgress = true; - try { - ensureImmersionTrackerStarted(); - } finally { - appState.statsStartupInProgress = false; - } - }, -} as const; -const runStatsCliCommand = createRunStatsCliCommandHandler({ +const { mpvRuntime, mining } = createMainPlaybackRuntime({ + appState, + logPath: DEFAULT_MPV_LOG_PATH, + logger, getResolvedConfig: () => getResolvedConfig(), - ensureImmersionTrackerStarted: () => statsStartupRuntime.ensureImmersionTrackerStarted(), - ensureVocabularyCleanupTokenizerReady: async () => { - await createMecabTokenizerAndCheck(); + getRuntimeBooleanOption, + subtitle, + yomitan: { + ensureYomitanExtensionLoaded: () => yomitan.ensureYomitanExtensionLoaded(), + isCharacterDictionaryEnabled: () => + getResolvedConfig().anilist.characterDictionary.enabled && + yomitanProfilePolicy.isCharacterDictionaryEnabled() && + !isYoutubePlaybackActiveNow(), }, - getImmersionTracker: () => appState.immersionTracker, - ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted(), - ensureBackgroundStatsServerStarted: () => statsStartupRuntime.ensureBackgroundStatsServerStarted(), - stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(), - openExternal: (url: string) => shell.openExternal(url), - writeResponse: (responsePath, payload) => { - writeStatsCliCommandResponse(responsePath, payload); - }, - exitAppWithCode: (code) => { - process.exitCode = code; - requestAppQuit(); - }, - logInfo: (message) => logger.info(message), - logWarn: (message, error) => logger.warn(message, error), - logError: (message, error) => logger.error(message, error), -}); - -async function runHeadlessInitialCommand(): Promise { - if (!appState.initialArgs?.refreshKnownWords) { - handleInitialArgs(); - return; - } - - const resolvedConfig = getResolvedConfig(); - if (resolvedConfig.ankiConnect.enabled !== true) { - logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled'); - process.exitCode = 1; - requestAppQuit(); - return; - } - - const effectiveAnkiConfig = - appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ?? - resolvedConfig.ankiConnect; - const integration = new AnkiIntegration( - effectiveAnkiConfig, - new SubtitleTimingTracker(), - { send: () => undefined } as never, - undefined, - undefined, - async () => ({ - keepNoteId: 0, - deleteNoteId: 0, - deleteDuplicate: false, - cancelled: true, - }), - path.join(USER_DATA_PATH, 'known-words-cache.json'), - mergeAiConfig(resolvedConfig.ai, resolvedConfig.ankiConnect?.ai), - ); - - try { - await integration.refreshKnownWordCache(); - } catch (error) { - logger.error('Headless known-word refresh failed:', error); - process.exitCode = 1; - } finally { - integration.stop(); - requestAppQuit(); - } -} - -const { appReadyRuntimeRunner } = composeAppReadyRuntime({ - reloadConfigMainDeps: { - reloadConfigStrict: () => configService.reloadConfigStrict(), - logInfo: (message) => appLogger.logInfo(message), - logWarning: (message) => appLogger.logWarning(message), - showDesktopNotification: (title, options) => showDesktopNotification(title, options), - startConfigHotReload: () => configHotReloadRuntime.start(), - refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretStateIfEnabled(options), - failHandlers: { - logError: (details) => logger.error(details), - showErrorBox: (title, details) => dialog.showErrorBox(title, details), - quit: () => requestAppQuit(), - }, - }, - criticalConfigErrorMainDeps: { - getConfigPath: () => configService.getConfigPath(), - failHandlers: { - logError: (message) => logger.error(message), - showErrorBox: (title, message) => dialog.showErrorBox(title, message), - quit: () => requestAppQuit(), - }, - }, - appReadyRuntimeMainDeps: { - ensureDefaultConfigBootstrap: () => { - ensureDefaultConfigBootstrap({ - configDir: CONFIG_DIR, - configFilePaths: getDefaultConfigFilePaths(CONFIG_DIR), - generateTemplate: () => generateConfigTemplate(DEFAULT_CONFIG), - }); - }, - loadSubtitlePosition: () => loadSubtitlePosition(), - resolveKeybindings: () => { - appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); - }, - createMpvClient: () => { - appState.mpvClient = createMpvClientRuntimeService(); - }, - getResolvedConfig: () => getResolvedConfig(), - getConfigWarnings: () => configService.getWarnings(), - logConfigWarning: (warning) => appLogger.logConfigWarning(warning), - setLogLevel: (level: string, source: LogLevelSource) => setLogLevel(level, source), - initRuntimeOptionsManager: () => { - appState.runtimeOptionsManager = new RuntimeOptionsManager( - () => configService.getConfig().ankiConnect, - { - applyAnkiPatch: (patch) => { - if (appState.ankiIntegration) { - appState.ankiIntegration.applyRuntimeConfigPatch(patch); - } - }, - getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle, - onOptionsChanged: () => { - subtitleProcessingController.invalidateTokenizationCache(); - subtitlePrefetchService?.onSeek(lastObservedTimePos); - broadcastRuntimeOptionsChanged(); - refreshOverlayShortcuts(); - }, - }, - ); - }, - setSecondarySubMode: (mode: SecondarySubMode) => { - setSecondarySubMode(mode); - }, - defaultSecondarySubMode: 'hover', - defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, - defaultAnnotationWebsocketPort: DEFAULT_CONFIG.annotationWebsocket.port, - defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, - hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), - startSubtitleWebsocket: (port: number) => { - subtitleWsService.start( - port, - () => - appState.currentSubtitleData ?? - (appState.currentSubText - ? { - text: appState.currentSubText, - tokens: null, - startTime: appState.mpvClient?.currentSubStart ?? null, - endTime: appState.mpvClient?.currentSubEnd ?? null, - } - : null), - () => ({ - enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, - topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, - mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, - }), - ); - }, - startAnnotationWebsocket: (port: number) => { - annotationSubtitleWsService.start( - port, - () => - appState.currentSubtitleData ?? - (appState.currentSubText - ? { - text: appState.currentSubText, - tokens: null, - startTime: appState.mpvClient?.currentSubStart ?? null, - endTime: appState.mpvClient?.currentSubEnd ?? null, - } - : null), - () => ({ - enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, - topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, - mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, - }), - ); - }, - startTexthooker: (port: number, websocketUrl?: string) => { - if (!texthookerService.isRunning()) { - texthookerService.start(port, websocketUrl); - } - }, - log: (message) => appLogger.logInfo(message), - createMecabTokenizerAndCheck: async () => { - await createMecabTokenizerAndCheck(); - }, - createSubtitleTimingTracker: () => { - const tracker = new SubtitleTimingTracker(); - appState.subtitleTimingTracker = tracker; - }, - loadYomitanExtension: async () => { - await loadYomitanExtension(); - }, - handleFirstRunSetup: async () => { - const snapshot = await firstRunSetupService.ensureSetupStateInitialized(); - appState.firstRunSetupCompleted = snapshot.state.status === 'completed'; - if ( - appState.initialArgs && - shouldAutoOpenFirstRunSetup(appState.initialArgs) && - snapshot.state.status !== 'completed' - ) { - openFirstRunSetupWindow(); - } - }, - startJellyfinRemoteSession: async () => { - await startJellyfinRemoteSession(); - }, - prewarmSubtitleDictionaries: async () => { - await prewarmSubtitleDictionaries(); - }, - startBackgroundWarmups: () => { - startBackgroundWarmupsIfAllowed(); - }, - texthookerOnlyMode: appState.texthookerOnlyMode, - shouldAutoInitializeOverlayRuntimeFromConfig: () => - appState.backgroundMode - ? false - : configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(), - setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), - initializeOverlayRuntime: () => initializeOverlayRuntime(), - runHeadlessInitialCommand: () => runHeadlessInitialCommand(), - handleInitialArgs: () => handleInitialArgs(), - shouldRunHeadlessInitialCommand: () => - Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)), - shouldUseMinimalStartup: () => - getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup, - shouldSkipHeavyStartup: () => - getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup, - createImmersionTracker: () => { - ensureImmersionTrackerStarted(); - }, - logDebug: (message: string) => { - logger.debug(message); - }, - now: () => Date.now(), - }, - immersionTrackerStartupMainDeps, -}); - -function ensureOverlayStartupPrereqs(): void { - if (appState.subtitlePosition === null) { - loadSubtitlePosition(); - } - if (appState.keybindings.length === 0) { - appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); - } - if (!appState.mpvClient) { - appState.mpvClient = createMpvClientRuntimeService(); - } - if (!appState.runtimeOptionsManager) { - appState.runtimeOptionsManager = new RuntimeOptionsManager( - () => configService.getConfig().ankiConnect, - { - applyAnkiPatch: (patch) => { - if (appState.ankiIntegration) { - appState.ankiIntegration.applyRuntimeConfigPatch(patch); - } - }, - getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle, - onOptionsChanged: () => { - subtitleProcessingController.invalidateTokenizationCache(); - subtitlePrefetchService?.onSeek(lastObservedTimePos); - broadcastRuntimeOptionsChanged(); - refreshOverlayShortcuts(); - }, - }, - ); - } - if (!appState.subtitleTimingTracker) { - appState.subtitleTimingTracker = new SubtitleTimingTracker(); - } -} - -async function ensureYoutubePlaybackRuntimeReady(): Promise { - ensureOverlayStartupPrereqs(); - await ensureYomitanExtensionLoaded(); - if (!appState.overlayRuntimeInitialized) { - initializeOverlayRuntime(); - return; - } - ensureOverlayWindowsReadyForVisibilityActions(); -} - -const { - createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, - updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler, - tokenizeSubtitle, - createMecabTokenizerAndCheck, - prewarmSubtitleDictionaries, - startBackgroundWarmups, - startTokenizationWarmups, - isTokenizationWarmupReady, -} = composeMpvRuntimeHandlers< - MpvIpcClient, - ReturnType, - SubtitleData ->({ - bindMpvMainEventHandlersMainDeps: { - appState, - getQuitOnDisconnectArmed: () => - jellyfinPlayQuitOnDisconnectArmed || youtubePlaybackRuntime.getQuitOnDisconnectArmed(), - scheduleQuitCheck: (callback) => { - setTimeout(callback, 500); - }, - quitApp: () => requestAppQuit(), - reportJellyfinRemoteStopped: () => { - void reportJellyfinRemoteStopped(); - }, - maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(), - logSubtitleTimingError: (message, error) => logger.error(message, error), + currentMediaTokenizationGate, + startupOsdSequencer, + dictionarySupport, + overlay: { broadcastToOverlayWindows: (channel, payload) => { - broadcastToOverlayWindows(channel, payload); + overlayManager.broadcastToOverlayWindows(channel, payload); }, - getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text), - emitImmediateSubtitle: (payload) => { - emitSubtitlePayload(payload); - }, - onSubtitleChange: (text) => { - subtitlePrefetchService?.pause(); - subtitleProcessingController.onSubtitleChange(text); - }, - refreshDiscordPresence: () => { + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getOverlayUi: () => startupOverlayUiAdapter, + }, + lifecycle: { + requestAppQuit, + restoreOverlayMpvSubtitles, + syncOverlayMpvSubtitleSuppression, + publishDiscordPresence: () => { discordPresenceRuntime.publishDiscordPresence(); }, - ensureImmersionTrackerInitialized: () => { + }, + stats: { + ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(), + }, + anilist, + jellyfin, + youtube, + mining: { + getSubtitleTimingTracker: () => appState.subtitleTimingTracker as SubtitleTimingTracker, + getAnkiIntegration: () => appState.ankiIntegration, + getMpvClient: () => appState.mpvClient, + readClipboardText: () => clipboard.readText(), + writeClipboardText: (text) => clipboard.writeText(text), + updateLastCardFromClipboardCore, + triggerFieldGroupingCore, + markLastCardAsAudioCardCore, + mineSentenceCardCore, + handleMultiCopyDigitCore, + copyCurrentSubtitleCore, + handleMineSentenceDigitCore, + getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined, + recordCardsMined: (count, noteIds) => { ensureImmersionTrackerStarted(); - }, - tokenizeSubtitleForImmersion: async (text): Promise => - tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, - updateCurrentMediaPath: (path) => { - autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(); - currentMediaTokenizationGate.updateCurrentMediaPath(path); - startupOsdSequencer.reset(); - subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh(); - subtitlePrefetchRuntime.cancelPendingInit(); - youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path); - if (path) { - ensureImmersionTrackerStarted(); - // Delay slightly to allow MPV's track-list to be populated. - subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500); - } - mediaRuntime.updateCurrentMediaPath(path); - }, - restoreMpvSubVisibility: () => { - restoreOverlayMpvSubtitles(); - }, - resetSubtitleSidebarEmbeddedLayout: () => { - resetSubtitleSidebarEmbeddedLayoutRuntime(); - }, - getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(), - resetAnilistMediaTracking: (mediaKey) => { - resetAnilistMediaTracking(mediaKey); - }, - maybeProbeAnilistDuration: (mediaKey) => { - void maybeProbeAnilistDuration(mediaKey); - }, - ensureAnilistMediaGuess: (mediaKey) => { - void ensureAnilistMediaGuess(mediaKey); - }, - syncImmersionMediaState: () => { - immersionMediaRuntime.syncFromCurrentMediaState(); - }, - signalAutoplayReadyIfWarm: () => { - if (!isTokenizationWarmupReady()) { - return; - } - autoplayReadyGate.maybeSignalPluginAutoplayReady( - { text: '__warm__', tokens: null }, - { forceWhilePaused: true }, - ); - }, - scheduleCharacterDictionarySync: () => { - if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) { - return; - } - characterDictionaryAutoSyncRuntime.scheduleSync(); - }, - updateCurrentMediaTitle: (title) => { - mediaRuntime.updateCurrentMediaTitle(title); - }, - resetAnilistMediaGuessState: () => { - resetAnilistMediaGuessState(); - }, - reportJellyfinRemoteProgress: (forceImmediate) => { - void reportJellyfinRemoteProgress(forceImmediate); - }, - onTimePosUpdate: (time) => { - const delta = time - lastObservedTimePos; - if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) { - subtitlePrefetchService.onSeek(time); - } - lastObservedTimePos = time; - }, - onSubtitleTrackChange: (sid) => { - scheduleSubtitlePrefetchRefresh(); - youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid); - }, - onSubtitleTrackListChange: (trackList) => { - scheduleSubtitlePrefetchRefresh(); - youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList); - }, - updateSubtitleRenderMetrics: (patch) => { - updateMpvSubtitleRenderMetrics(patch as Partial); - }, - syncOverlayMpvSubtitleSuppression: () => { - syncOverlayMpvSubtitleSuppression(); - }, - }, - mpvClientRuntimeServiceFactoryMainDeps: { - createClient: MpvIpcClient, - getSocketPath: () => appState.mpvSocketPath, - getResolvedConfig: () => getResolvedConfig(), - isAutoStartOverlayEnabled: () => appState.autoStartOverlay, - setOverlayVisible: (visible: boolean) => setOverlayVisible(visible), - isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getReconnectTimer: () => appState.reconnectTimer, - setReconnectTimer: (timer: ReturnType | null) => { - appState.reconnectTimer = timer; - }, - }, - updateMpvSubtitleRenderMetricsMainDeps: { - getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics, - setCurrentMetrics: (metrics) => { - appState.mpvSubtitleRenderMetrics = metrics; - }, - applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch), - broadcastMetrics: () => { - // no renderer consumer for subtitle render metrics updates at present - }, - }, - tokenizer: { - buildTokenizerDepsMainDeps: { - getYomitanExt: () => appState.yomitanExt, - getYomitanSession: () => appState.yomitanSession, - getYomitanParserWindow: () => appState.yomitanParserWindow, - setYomitanParserWindow: (window) => { - appState.yomitanParserWindow = window as BrowserWindow | null; - }, - getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, - setYomitanParserReadyPromise: (promise) => { - appState.yomitanParserReadyPromise = promise; - }, - getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, - setYomitanParserInitPromise: (promise) => { - appState.yomitanParserInitPromise = promise; - }, - isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)), - recordLookup: (hit) => { - ensureImmersionTrackerStarted(); - appState.immersionTracker?.recordLookup(hit); - }, - getKnownWordMatchMode: () => - appState.ankiIntegration?.getKnownWordMatchMode() ?? - getResolvedConfig().ankiConnect.knownWords.matchMode, - getNPlusOneEnabled: () => - getRuntimeBooleanOption( - 'subtitle.annotation.nPlusOne', - getResolvedConfig().ankiConnect.knownWords.highlightEnabled, - ), - getMinSentenceWordsForNPlusOne: () => - getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords, - getJlptLevel: (text) => appState.jlptLevelLookup(text), - getJlptEnabled: () => - getRuntimeBooleanOption( - 'subtitle.annotation.jlpt', - getResolvedConfig().subtitleStyle.enableJlpt, - ), - getCharacterDictionaryEnabled: () => - getResolvedConfig().anilist.characterDictionary.enabled && - yomitanProfilePolicy.isCharacterDictionaryEnabled() && - !isYoutubePlaybackActiveNow(), - getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, - getFrequencyDictionaryEnabled: () => - getRuntimeBooleanOption( - 'subtitle.annotation.frequency', - getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, - ), - getFrequencyDictionaryMatchMode: () => - getResolvedConfig().subtitleStyle.frequencyDictionary.matchMode, - getFrequencyRank: (text) => appState.frequencyRankLookup(text), - getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, - getMecabTokenizer: () => appState.mecabTokenizer, - onTokenizationReady: (text) => { - currentMediaTokenizationGate.markReady( - appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, - ); - startupOsdSequencer.markTokenizationReady(); - autoplayReadyGate.maybeSignalPluginAutoplayReady( - { text, tokens: null }, - { forceWhilePaused: true }, - ); - }, - }, - createTokenizerRuntimeDeps: (deps) => - createTokenizerDepsRuntime(deps as Parameters[0]), - tokenizeSubtitle: (text, deps) => tokenizeSubtitleCore(text, deps), - createMecabTokenizerAndCheckMainDeps: { - getMecabTokenizer: () => appState.mecabTokenizer, - setMecabTokenizer: (tokenizer) => { - appState.mecabTokenizer = tokenizer as MecabTokenizer | null; - }, - createMecabTokenizer: () => new MecabTokenizer(), - checkAvailability: async (tokenizer) => (tokenizer as MecabTokenizer).checkAvailability(), - }, - prewarmSubtitleDictionariesMainDeps: { - ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(), - ensureFrequencyDictionaryLookup: () => - frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), - showMpvOsd: (message: string) => showMpvOsd(message), - showLoadingOsd: (message: string) => startupOsdSequencer.showAnnotationLoading(message), - showLoadedOsd: (message: string) => - startupOsdSequencer.markAnnotationLoadingComplete(message), - shouldShowOsdNotification: () => { - const type = getResolvedConfig().ankiConnect.behavior.notificationType; - return type === 'osd' || type === 'both'; - }, - }, - }, - warmups: { - launchBackgroundWarmupTaskMainDeps: { - now: () => Date.now(), - logDebug: (message) => logger.debug(message), - logWarn: (message) => logger.warn(message), - }, - startBackgroundWarmupsMainDeps: { - getStarted: () => backgroundWarmupsStarted, - setStarted: (started) => { - backgroundWarmupsStarted = started; - }, - isTexthookerOnlyMode: () => appState.texthookerOnlyMode, - ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}), - shouldWarmupMecab: () => { - const startupWarmups = getResolvedConfig().startupWarmups; - if (startupWarmups.lowPowerMode) { - return false; - } - if (!startupWarmups.mecab) { - return false; - } - return shouldInitializeMecabForAnnotations(); - }, - shouldWarmupYomitanExtension: () => getResolvedConfig().startupWarmups.yomitanExtension, - shouldWarmupSubtitleDictionaries: () => { - const startupWarmups = getResolvedConfig().startupWarmups; - if (startupWarmups.lowPowerMode) { - return false; - } - return startupWarmups.subtitleDictionaries; - }, - shouldWarmupJellyfinRemoteSession: () => { - const startupWarmups = getResolvedConfig().startupWarmups; - if (startupWarmups.lowPowerMode) { - return false; - } - return startupWarmups.jellyfinRemoteSession; - }, - shouldAutoConnectJellyfinRemote: () => { - const jellyfin = getResolvedConfig().jellyfin; - return ( - jellyfin.enabled && jellyfin.remoteControlEnabled && jellyfin.remoteControlAutoConnect - ); - }, - startJellyfinRemoteSession: () => startJellyfinRemoteSession(), - logDebug: (message) => logger.debug(message), + appState.immersionTracker?.recordCardsMined(count, noteIds); }, }, }); -tokenizeSubtitleDeferred = tokenizeSubtitle; function createMpvClientRuntimeService(): MpvIpcClient { - const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient; - client.on('connection-change', ({ connected }) => { - if (connected) { - return; - } - if (!youtubeFlowRuntime.hasActiveSession()) { - return; - } - youtubeFlowRuntime.cancelActivePicker(); - broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null); - overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker'); - }); - return client; -} - -function resetSubtitleSidebarEmbeddedLayoutRuntime(): void { - sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-margin-ratio-right', 0]); - sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-pan-x', 0]); + return mpvRuntime.createMpvClientRuntimeService(); } function updateMpvSubtitleRenderMetrics(patch: Partial): void { - updateMpvSubtitleRenderMetricsHandler(patch); + mpvRuntime.updateMpvSubtitleRenderMetrics(patch); } -let lastOverlayWindowGeometry: WindowGeometry | null = null; - -function getOverlayGeometryFallback(): WindowGeometry { - const cursorPoint = screen.getCursorScreenPoint(); - const display = screen.getDisplayNearestPoint(cursorPoint); - const bounds = display.workArea; - return { - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - }; +function isTokenizationWarmupReady(): boolean { + return mpvRuntime.isTokenizationWarmupReady(); } -function getCurrentOverlayGeometry(): WindowGeometry { - if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry; - const trackerGeometry = appState.windowTracker?.getGeometry(); - if (trackerGeometry) return trackerGeometry; - return getOverlayGeometryFallback(); -} - -function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean { - if (!a || !b) return false; - return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; -} - -function applyOverlayRegions(geometry: WindowGeometry): void { - lastOverlayWindowGeometry = geometry; - overlayManager.setOverlayWindowBounds(geometry); - overlayManager.setModalWindowBounds(geometry); -} - -const buildUpdateVisibleOverlayBoundsMainDepsHandler = - createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ - setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), - }); -const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); -const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( - updateVisibleOverlayBoundsMainDeps, -); - -const buildEnsureOverlayWindowLevelMainDepsHandler = - createBuildEnsureOverlayWindowLevelMainDepsHandler({ - ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow), - }); -const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler(); -const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler( - ensureOverlayWindowLevelMainDeps, -); - -function syncPrimaryOverlayWindowLayer(layer: 'visible'): void { - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) return; - syncOverlayWindowLayer(mainWindow, layer); -} - -const buildEnforceOverlayLayerOrderMainDepsHandler = - createBuildEnforceOverlayLayerOrderMainDepsHandler({ - enforceOverlayLayerOrderCore: (params) => - enforceOverlayLayerOrderCore({ - visibleOverlayVisible: params.visibleOverlayVisible, - mainWindow: params.mainWindow as BrowserWindow | null, - ensureOverlayWindowLevel: (window) => - params.ensureOverlayWindowLevel(window as BrowserWindow), - }), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getMainWindow: () => overlayManager.getMainWindow(), - ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow), - }); -const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler(); -const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler( - enforceOverlayLayerOrderMainDeps, -); - -async function loadYomitanExtension(): Promise { - const extension = await yomitanExtensionRuntime.loadYomitanExtension(); - if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) { - await syncYomitanDefaultProfileAnkiServer(); - } - return extension; -} - -async function ensureYomitanExtensionLoaded(): Promise { - const extension = await yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); - if (extension && !yomitanProfilePolicy.isExternalReadOnlyMode()) { - await syncYomitanDefaultProfileAnkiServer(); - } - return extension; -} - -let lastSyncedYomitanAnkiServer: string | null = null; - -function getPreferredYomitanAnkiServerUrl(): string { - return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect); -} - -function getYomitanParserRuntimeDeps() { - return { - getYomitanExt: () => appState.yomitanExt, - getYomitanSession: () => appState.yomitanSession, - getYomitanParserWindow: () => appState.yomitanParserWindow, - setYomitanParserWindow: (window: BrowserWindow | null) => { - appState.yomitanParserWindow = window; - }, - getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, - setYomitanParserReadyPromise: (promise: Promise | null) => { - appState.yomitanParserReadyPromise = promise; - }, - getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, - setYomitanParserInitPromise: (promise: Promise | null) => { - appState.yomitanParserInitPromise = promise; - }, - }; -} - -async function syncYomitanDefaultProfileAnkiServer(): Promise { - if (yomitanProfilePolicy.isExternalReadOnlyMode()) { - return; - } - - const targetUrl = getPreferredYomitanAnkiServerUrl().trim(); - if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) { - return; - } - - const synced = await syncYomitanDefaultAnkiServerCore( - targetUrl, - getYomitanParserRuntimeDeps(), - { - error: (message, ...args) => { - logger.error(message, ...args); - }, - info: (message, ...args) => { - logger.info(message, ...args); - }, - }, - { - forceOverride: shouldForceOverrideYomitanAnkiServer(getResolvedConfig().ankiConnect), - }, - ); - - if (synced) { - lastSyncedYomitanAnkiServer = targetUrl; - } -} - -function createModalWindow(): BrowserWindow { - const existingWindow = overlayManager.getModalWindow(); - if (existingWindow && !existingWindow.isDestroyed()) { - return existingWindow; - } - const window = createModalWindowHandler(); - overlayManager.setModalWindowBounds(getCurrentOverlayGeometry()); - return window; -} - -function createMainWindow(): BrowserWindow { - return createMainWindowHandler(); -} - -function ensureTray(): void { - ensureTrayHandler(); -} - -function destroyTray(): void { - destroyTrayHandler(); -} - -function initializeOverlayRuntime(): void { - initializeOverlayRuntimeHandler(); - appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); - syncOverlayMpvSubtitleSuppression(); -} - -function openYomitanSettings(): boolean { - if (yomitanProfilePolicy.isExternalReadOnlyMode()) { - const message = 'Yomitan settings unavailable while using read-only external-profile mode.'; - logger.warn( - 'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.', - ); - showDesktopNotification('SubMiner', { body: message }); - showMpvOsd(message); - return false; - } - openYomitanSettingsHandler(); - return true; -} - -const { - getConfiguredShortcuts, - registerGlobalShortcuts, - refreshGlobalAndOverlayShortcuts, - cancelPendingMultiCopy, - startPendingMultiCopy, - cancelPendingMineSentenceMultiple, - startPendingMineSentenceMultiple, - syncOverlayShortcuts, - refreshOverlayShortcuts, -} = composeShortcutRuntimes({ - globalShortcuts: { - getConfiguredShortcutsMainDeps: { - getResolvedConfig: () => getResolvedConfig(), - defaultConfig: DEFAULT_CONFIG, - resolveConfiguredShortcuts, - }, - buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcutsHandler) => ({ - getConfiguredShortcuts: () => getConfiguredShortcutsHandler(), - registerGlobalShortcutsCore, - toggleVisibleOverlay: () => toggleVisibleOverlay(), - openYomitanSettings: () => openYomitanSettings(), - isDev, - getMainWindow: () => overlayManager.getMainWindow(), - }), - buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcutsHandler) => ({ - unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), - registerGlobalShortcuts: () => registerGlobalShortcutsHandler(), - syncOverlayShortcuts: () => syncOverlayShortcuts(), - }), - }, - numericShortcutRuntimeMainDeps: { - globalShortcut, - showMpvOsd: (text) => showMpvOsd(text), - setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), - clearTimer: (timer) => clearTimeout(timer), - }, - numericSessions: { - onMultiCopyDigit: (count) => handleMultiCopyDigit(count), - onMineSentenceDigit: (count) => handleMineSentenceDigit(count), - }, - overlayShortcutsRuntimeMainDeps: { - overlayShortcutsRuntime, - }, +const overlayGeometryAccessors = createOverlayGeometryAccessors({ + getOverlayGeometryRuntime: () => overlayGeometryRuntime, + getWindowTracker: () => appState.windowTracker, + screen, }); +const { getOverlayGeometryFallback, getCurrentOverlayGeometry, geometryMatches } = + overlayGeometryAccessors; -const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ - appendToMpvLogMainDeps: { - logPath: DEFAULT_MPV_LOG_PATH, - dirname: (targetPath) => path.dirname(targetPath), - mkdir: async (targetPath, options) => { - await fs.promises.mkdir(targetPath, options); - }, - appendFile: async (targetPath, data, options) => { - await fs.promises.appendFile(targetPath, data, options); - }, - now: () => new Date(), - }, - buildShowMpvOsdMainDeps: (appendToMpvLogHandler) => ({ - appendToMpvLog: (message) => appendToMpvLogHandler(message), - showMpvOsdRuntime: (mpvClient, text, fallbackLog) => - showMpvOsdRuntime(mpvClient, text, fallbackLog), - getMpvClient: () => appState.mpvClient, - logInfo: (line) => logger.info(line), - }), -}); flushPendingMpvLogWrites = () => { - void flushMpvLog(); + void mpvRuntime.flushMpvLog(); }; -const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({ - cycleSecondarySubModeMainDeps: { - getSecondarySubMode: () => appState.secondarySubMode, - setSecondarySubMode: (mode: SecondarySubMode) => { - setSecondarySubMode(mode); - }, - getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs, - setLastSecondarySubToggleAtMs: (timestampMs: number) => { - appState.lastSecondarySubToggleAtMs = timestampMs; - }, - broadcastToOverlayWindows: (channel, mode) => { - broadcastToOverlayWindows(channel, mode); - }, - showMpvOsd: (text: string) => showMpvOsd(text), +const startupSequence = createStartupSequenceRuntime({ + appState: { + initialArgs: appState.initialArgs, + runtimeOptionsManager: appState.runtimeOptionsManager, }, - cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps), -}); - -function setSecondarySubMode(mode: SecondarySubMode): void { - appState.secondarySubMode = mode; -} - -function handleCycleSecondarySubMode(): void { - cycleSecondarySubMode(); -} - -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) => showMpvOsd(text), - updateLastCardFromClipboardCore, - }); -const updateLastCardFromClipboardMainDeps = buildUpdateLastCardFromClipboardMainDepsHandler(); -const updateLastCardFromClipboardHandler = createUpdateLastCardFromClipboardHandler( - updateLastCardFromClipboardMainDeps, -); - -const buildRefreshKnownWordCacheMainDepsHandler = createBuildRefreshKnownWordCacheMainDepsHandler({ - getAnkiIntegration: () => appState.ankiIntegration, - missingIntegrationMessage: 'AnkiConnect integration not enabled', -}); -const refreshKnownWordCacheMainDeps = buildRefreshKnownWordCacheMainDepsHandler(); -const refreshKnownWordCacheHandler = createRefreshKnownWordCacheHandler( - refreshKnownWordCacheMainDeps, -); - -const buildTriggerFieldGroupingMainDepsHandler = createBuildTriggerFieldGroupingMainDepsHandler({ - getAnkiIntegration: () => appState.ankiIntegration, - showMpvOsd: (text) => showMpvOsd(text), - triggerFieldGroupingCore, -}); -const triggerFieldGroupingMainDeps = buildTriggerFieldGroupingMainDepsHandler(); -const triggerFieldGroupingHandler = createTriggerFieldGroupingHandler(triggerFieldGroupingMainDeps); - -const buildMarkLastCardAsAudioCardMainDepsHandler = - createBuildMarkLastCardAsAudioCardMainDepsHandler({ - getAnkiIntegration: () => appState.ankiIntegration, - showMpvOsd: (text) => showMpvOsd(text), - markLastCardAsAudioCardCore, - }); -const markLastCardAsAudioCardMainDeps = buildMarkLastCardAsAudioCardMainDepsHandler(); -const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler( - markLastCardAsAudioCardMainDeps, -); - -const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({ - getAnkiIntegration: () => appState.ankiIntegration, - getMpvClient: () => appState.mpvClient, - showMpvOsd: (text) => showMpvOsd(text), - mineSentenceCardCore, - recordCardsMined: (count, noteIds) => { - ensureImmersionTrackerStarted(); - appState.immersionTracker?.recordCardsMined(count, noteIds); + userDataPath: USER_DATA_PATH, + getResolvedConfig: () => getResolvedConfig(), + anilist, + actions: { + initializeDiscordPresenceService: async () => { + await initializeDiscordPresenceService(); + }, + requestAppQuit, }, + logger, }); -const mineSentenceCardHandler = createMineSentenceCardHandler( - buildMineSentenceCardMainDepsHandler(), -); -const buildHandleMultiCopyDigitMainDepsHandler = createBuildHandleMultiCopyDigitMainDepsHandler({ - getSubtitleTimingTracker: () => appState.subtitleTimingTracker, - writeClipboardText: (text) => clipboard.writeText(text), - showMpvOsd: (text) => showMpvOsd(text), - handleMultiCopyDigitCore, -}); -const handleMultiCopyDigitMainDeps = buildHandleMultiCopyDigitMainDepsHandler(); -const handleMultiCopyDigitHandler = createHandleMultiCopyDigitHandler(handleMultiCopyDigitMainDeps); +let handleInitialArgsRef: (() => void) | null = null; -const buildCopyCurrentSubtitleMainDepsHandler = createBuildCopyCurrentSubtitleMainDepsHandler({ - getSubtitleTimingTracker: () => appState.subtitleTimingTracker, - writeClipboardText: (text) => clipboard.writeText(text), - showMpvOsd: (text) => showMpvOsd(text), - copyCurrentSubtitleCore, -}); -const copyCurrentSubtitleMainDeps = buildCopyCurrentSubtitleMainDepsHandler(); -const copyCurrentSubtitleHandler = createCopyCurrentSubtitleHandler(copyCurrentSubtitleMainDeps); - -const buildHandleMineSentenceDigitMainDepsHandler = - createBuildHandleMineSentenceDigitMainDepsHandler({ - getSubtitleTimingTracker: () => appState.subtitleTimingTracker, - getAnkiIntegration: () => appState.ankiIntegration, - getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined, - showMpvOsd: (text) => showMpvOsd(text), - logError: (message, err) => { - logger.error(message, err); - }, - onCardsMined: (cards) => { - ensureImmersionTrackerStarted(); - appState.immersionTracker?.recordCardsMined(cards); - }, - handleMineSentenceDigitCore, - }); -const handleMineSentenceDigitMainDeps = buildHandleMineSentenceDigitMainDepsHandler(); -const handleMineSentenceDigitHandler = createHandleMineSentenceDigitHandler( - handleMineSentenceDigitMainDeps, -); -const { - setVisibleOverlayVisible: setVisibleOverlayVisibleHandler, - toggleVisibleOverlay: toggleVisibleOverlayHandler, - setOverlayVisible: setOverlayVisibleHandler, -} = createOverlayVisibilityRuntime({ - setVisibleOverlayVisibleDeps: { - setVisibleOverlayVisibleCore, - setVisibleOverlayVisibleState: (nextVisible) => { - overlayManager.setVisibleOverlayVisible(nextVisible); - }, - updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), +const startupRuntime = createMainStartupRuntimeFromProcessState({ + appState, + appLifecycle: { + app: appLifecycleApp, + argv: process.argv, + platform: process.platform, }, - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), -}); - -const buildHandleOverlayModalClosedMainDepsHandler = - createBuildHandleOverlayModalClosedMainDepsHandler({ - handleOverlayModalClosedRuntime: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal), - }); -const handleOverlayModalClosedMainDeps = buildHandleOverlayModalClosedMainDepsHandler(); -const handleOverlayModalClosedHandler = createHandleOverlayModalClosedHandler( - handleOverlayModalClosedMainDeps, -); - -const buildAppendClipboardVideoToQueueMainDepsHandler = - createBuildAppendClipboardVideoToQueueMainDepsHandler({ - appendClipboardVideoToQueueRuntime, - getMpvClient: () => appState.mpvClient, - readClipboardText: () => clipboard.readText(), - showMpvOsd: (text) => showMpvOsd(text), - sendMpvCommand: (command) => { - sendMpvCommandRuntime(appState.mpvClient, command); - }, - }); -const appendClipboardVideoToQueueMainDeps = buildAppendClipboardVideoToQueueMainDepsHandler(); -const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHandler( - appendClipboardVideoToQueueMainDeps, -); - -async function loadSubtitleSourceText(source: string): Promise { - if (/^https?:\/\//i.test(source)) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 4000); - try { - const response = await fetch(source, { signal: controller.signal }); - if (!response.ok) { - throw new Error(`Failed to download subtitle source (${response.status})`); - } - return await response.text(); - } finally { - clearTimeout(timeoutId); - } - } - - const filePath = resolveSubtitleSourcePath(source); - return fs.promises.readFile(filePath, 'utf8'); -} - -type MpvSubtitleTrackLike = { - type?: unknown; - id?: unknown; - selected?: unknown; - external?: unknown; - codec?: unknown; - 'ff-index'?: unknown; - 'external-filename'?: unknown; -}; - -function parseTrackId(value: unknown): number | null { - if (typeof value === 'number' && Number.isInteger(value)) { - return value; - } - if (typeof value === 'string') { - const parsed = Number(value.trim()); - return Number.isInteger(parsed) ? parsed : null; - } - return null; -} - -function buildFfmpegSubtitleExtractionArgs( - videoPath: string, - ffIndex: number, - outputPath: string, -): string[] { - return [ - '-hide_banner', - '-nostdin', - '-y', - '-loglevel', - 'error', - '-an', - '-vn', - '-i', - videoPath, - '-map', - `0:${ffIndex}`, - '-f', - path.extname(outputPath).slice(1), - outputPath, - ]; -} - -async function extractInternalSubtitleTrackToTempFile( - ffmpegPath: string, - videoPath: string, - track: MpvSubtitleTrackLike, -): Promise<{ path: string; cleanup: () => Promise } | null> { - const ffIndex = parseTrackId(track['ff-index']); - const codec = typeof track.codec === 'string' ? track.codec : null; - const extension = codecToExtension(codec ?? undefined); - if (ffIndex === null || extension === null) { - return null; - } - - const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-')); - const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`); - - try { - await new Promise((resolve, reject) => { - const child = spawn( - ffmpegPath, - buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath), - ); - let stderr = ''; - child.stderr.on('data', (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.on('error', (error) => { - reject(error); - }); - child.on('close', (code) => { - if (code === 0) { - resolve(); - return; - } - reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`)); - }); - }); - } catch (error) { - await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); - throw error; - } - - return { - path: outputPath, - cleanup: async () => { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - }, - }; -} - -const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({ - getMpvClient: () => appState.mpvClient, - loadSubtitleSourceText, - sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), - showMpvOsd: (text) => showMpvOsd(text), -}); - -const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient); - -const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ - mpvCommandMainDeps: { - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), - openPlaylistBrowser: () => openPlaylistBrowser(), - cycleRuntimeOption: (id, direction) => { - if (!appState.runtimeOptionsManager) { - return { ok: false, error: 'Runtime options manager unavailable' }; - } - return applyRuntimeOptionResultRuntime( - appState.runtimeOptionsManager.cycleOption(id, direction), - (text) => showMpvOsd(text), - ); - }, - showMpvOsd: (text: string) => showMpvOsd(text), - replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), - playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient), - shiftSubDelayToAdjacentSubtitle: (direction) => - shiftSubtitleDelayToAdjacentCueHandler(direction), - sendMpvCommand: (rawCommand: (string | number)[]) => - sendMpvCommandRuntime(appState.mpvClient, rawCommand), - getMpvClient: () => appState.mpvClient, - isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), - hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null, + config: { + configService, + configHotReloadRuntime, + configDerivedRuntime, + ensureDefaultConfigBootstrap: (options) => ensureDefaultConfigBootstrap(options as never), + getDefaultConfigFilePaths, + generateConfigTemplate, + defaultConfig: DEFAULT_CONFIG, + defaultKeybindings: DEFAULT_KEYBINDINGS, + configDir: CONFIG_DIR, }, - handleMpvCommandFromIpcRuntime, - runSubsyncManualFromIpc: (request) => subsyncRuntime.runManualFromIpc(request), - registration: { - runtimeOptions: { - getRuntimeOptionsManager: () => appState.runtimeOptionsManager, - showMpvOsd: (text: string) => showMpvOsd(text), - }, - mainDeps: { - getMainWindow: () => overlayManager.getMainWindow(), - getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), - focusMainWindow: () => { - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) return; - if (!mainWindow.isFocused()) { - mainWindow.focus(); - } - }, - onOverlayModalClosed: (modal) => { - handleOverlayModalClosed(modal); - }, - onOverlayModalOpened: (modal) => { - overlayModalRuntime.notifyOverlayModalOpened(modal); - }, - onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), - openYomitanSettings: () => openYomitanSettings(), - quitApp: () => requestAppQuit(), - toggleVisibleOverlay: () => toggleVisibleOverlay(), - tokenizeCurrentSubtitle: async () => - withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)), - getCurrentSubtitleRaw: () => appState.currentSubText, - getCurrentSubtitleAss: () => appState.currentSubAssText, - getSubtitleSidebarSnapshot: async () => { - const currentSubtitle = { - text: appState.currentSubText, - startTime: appState.mpvClient?.currentSubStart ?? null, - endTime: appState.mpvClient?.currentSubEnd ?? null, - }; - const currentTimeSec = appState.mpvClient?.currentTimePos ?? null; - const config = getResolvedConfig().subtitleSidebar; - const client = appState.mpvClient; - if (!client?.connected) { - return { - cues: appState.activeParsedSubtitleCues, - currentTimeSec, - currentSubtitle, - config, - }; - } - - try { - const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] = - await Promise.all([ - client.requestProperty('current-tracks/sub/external-filename').catch(() => null), - client.requestProperty('current-tracks/sub').catch(() => null), - client.requestProperty('track-list'), - client.requestProperty('sid'), - client.requestProperty('path'), - ]); - const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : ''; - if (!videoPath) { - return { - cues: appState.activeParsedSubtitleCues, - currentTimeSec, - currentSubtitle, - config, - }; - } - - const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({ - currentExternalFilenameRaw, - currentTrackRaw, - trackListRaw, - sidRaw, - videoPath, - }); - if (!resolvedSource) { - return { - cues: appState.activeParsedSubtitleCues, - currentTimeSec, - currentSubtitle, - config, - }; - } - - try { - if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) { - return { - cues: appState.activeParsedSubtitleCues, - currentTimeSec, - currentSubtitle, - config, - }; - } - - const content = await loadSubtitleSourceText(resolvedSource.path); - const cues = parseSubtitleCues(content, resolvedSource.path); - appState.activeParsedSubtitleCues = cues; - appState.activeParsedSubtitleSource = resolvedSource.sourceKey; - return { - cues, - currentTimeSec, - currentSubtitle, - config, - }; - } finally { - await resolvedSource.cleanup?.(); - } - } catch { - return { - cues: appState.activeParsedSubtitleCues, - currentTimeSec, - currentSubtitle, - config, - }; - } - }, - getPlaybackPaused: () => appState.playbackPaused, - getSubtitlePosition: () => loadSubtitlePosition(), - getSubtitleStyle: () => { - const resolvedConfig = getResolvedConfig(); - return resolveSubtitleStyleForRenderer(resolvedConfig); - }, - saveSubtitlePosition: (position) => saveSubtitlePosition(position), - getMecabTokenizer: () => appState.mecabTokenizer, - getKeybindings: () => appState.keybindings, - getConfiguredShortcuts: () => getConfiguredShortcuts(), - getStatsToggleKey: () => getResolvedConfig().stats.toggleKey, - getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey, - getControllerConfig: () => getResolvedConfig().controller, - saveControllerConfig: (update) => { - const currentRawConfig = configService.getRawConfig(); - configService.patchRawConfig({ - controller: applyControllerConfigUpdate(currentRawConfig.controller, update), - }); - }, - saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => { - configService.patchRawConfig({ - controller: { - preferredGamepadId, - preferredGamepadLabel, - }, - }); - }, - getSecondarySubMode: () => appState.secondarySubMode, - getMpvClient: () => appState.mpvClient, - getAnkiConnectStatus: () => appState.ankiIntegration !== null, - getRuntimeOptions: () => getRuntimeOptionsState(), - reportOverlayContentBounds: (payload: unknown) => { - overlayContentMeasurementStore.report(payload); - }, - getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), - clearAnilistToken: () => anilistStateRuntime.clearTokenState(), - openAnilistSetup: () => openAnilistSetupWindow(), - getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), - retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), - appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), - ...playlistBrowserMainDeps, - getImmersionTracker: () => appState.immersionTracker, - }, - ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({ - patchAnkiConnectEnabled: (enabled: boolean) => { - configService.patchRawConfig({ ankiConnect: { enabled } }); - }, - getResolvedConfig: () => getResolvedConfig(), - getRuntimeOptionsManager: () => appState.runtimeOptionsManager, - getSubtitleTimingTracker: () => appState.subtitleTimingTracker, - getMpvClient: () => appState.mpvClient, - getAnkiIntegration: () => appState.ankiIntegration, - setAnkiIntegration: (integration: AnkiIntegration | null) => { - appState.ankiIntegration = integration; - appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); - }, - getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), - showDesktopNotification, - createFieldGroupingCallback: () => createFieldGroupingCallback(), - broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), - getFieldGroupingResolver: () => getFieldGroupingResolver(), - setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => - setFieldGroupingResolver(resolver), - parseMediaInfo: (mediaPath: string | null) => - parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath)), - getCurrentMediaPath: () => appState.currentMediaPath, - jimakuFetchJson: ( - endpoint: string, - query?: Record, - ): Promise> => configDerivedRuntime.jimakuFetchJson(endpoint, query), - getJimakuMaxEntryResults: () => configDerivedRuntime.getJimakuMaxEntryResults(), - getJimakuLanguagePreference: () => configDerivedRuntime.getJimakuLanguagePreference(), - resolveJimakuApiKey: () => configDerivedRuntime.resolveJimakuApiKey(), - isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath), - downloadToFile: (url: string, destPath: string, headers: Record) => - downloadToFile(url, destPath, headers), - }), - registerIpcRuntimeServices, + logging: { + appLogger, + logger, + setLogLevel, }, -}); -const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ - cliCommandContextMainDeps: { - appState, - setLogLevel: (level) => setLogLevel(level, 'cli'), + shell: { + dialog, + shell, + showDesktopNotification, + }, + runtime: { + subtitle, + startupOverlayUiAdapter, + overlayManager, + firstRun, + anilist, + jellyfin, + stats: { + ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(), + runStatsCliCommand: (argsFromCommand, source) => runStatsCliCommand(argsFromCommand, source), + immersion: statsBootstrap.immersion, + }, + mining, texthookerService, - getResolvedConfig: () => getResolvedConfig(), - defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, - defaultAnnotationWebsocketPort: DEFAULT_CONFIG.annotationWebsocket.port, + yomitan: { + loadYomitanExtension: () => yomitan.loadYomitanExtension(), + ensureYomitanExtensionLoaded: () => yomitan.ensureYomitanExtensionLoaded(), + openYomitanSettings: () => yomitan.openYomitanSettings(), + getCharacterDictionaryDisabledReason: () => + yomitanProfilePolicy.getCharacterDictionaryDisabledReason(), + }, + subsyncRuntime, + dictionarySupport, + subtitleWsService, + annotationSubtitleWsService, + }, + commands: { + mpvRuntime, + runHeadlessInitialCommand: async (): Promise => + startupSequence.runHeadlessInitialCommand({ + handleInitialArgs: () => { + if (!handleInitialArgsRef) { + throw new Error('Initial args handler not initialized'); + } + handleInitialArgsRef(); + }, + }), + shortcuts, + cycleSecondarySubMode: () => mpvRuntime.cycleSecondarySubMode(), 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) => showMpvOsd(text), - initializeOverlayRuntime: () => initializeOverlayRuntime(), - toggleVisibleOverlay: () => toggleVisibleOverlay(), - openFirstRunSetupWindow: () => openFirstRunSetupWindow(), - setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), - copyCurrentSubtitle: () => copyCurrentSubtitle(), - startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), - mineSentenceCard: () => mineSentenceCard(), - startPendingMineSentenceMultiple: (timeoutMs: number) => - startPendingMineSentenceMultiple(timeoutMs), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - refreshKnownWordCache: () => refreshKnownWordCache(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - markLastCardAsAudioCard: () => markLastCardAsAudioCard(), - getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), - clearAnilistToken: () => anilistStateRuntime.clearTokenState(), - openAnilistSetupWindow: () => openAnilistSetupWindow(), - openJellyfinSetupWindow: () => openJellyfinSetupWindow(), - getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), - processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), - generateCharacterDictionary: async (targetPath?: string) => { - const disabledReason = yomitanProfilePolicy.getCharacterDictionaryDisabledReason(); - if (disabledReason) { - throw new Error(disabledReason); - } - return await characterDictionaryRuntime.generateForCurrentMedia(targetPath); - }, - runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), - runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) => - runStatsCliCommand(argsFromCommand, source), - runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request), - openYomitanSettings: () => openYomitanSettings(), - cycleSecondarySubMode: () => handleCycleSecondarySubMode(), - openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), - stopApp: () => requestAppQuit(), - hasMainWindow: () => Boolean(overlayManager.getMainWindow()), - getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, - schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), - logInfo: (message: string) => logger.info(message), - logWarn: (message: string) => logger.warn(message), - logError: (message: string, err: unknown) => logger.error(message, err), - }, - cliCommandRuntimeHandlerMainDeps: { - handleTexthookerOnlyModeTransitionMainDeps: { - isTexthookerOnlyMode: () => appState.texthookerOnlyMode, - ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), - setTexthookerOnlyMode: (enabled) => { - appState.texthookerOnlyMode = enabled; - }, - commandNeedsOverlayStartupPrereqs: (inputArgs) => - commandNeedsOverlayStartupPrereqs(inputArgs), - startBackgroundWarmups: () => startBackgroundWarmups(), - logInfo: (message: string) => logger.info(message), - }, - handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) => - handleCliCommandRuntimeServiceWithContext(args, source, cliContext), - }, - initialArgsRuntimeHandlerMainDeps: { - getInitialArgs: () => appState.initialArgs, - isBackgroundMode: () => appState.backgroundMode, - shouldEnsureTrayOnStartup: () => - shouldEnsureTrayOnStartupForInitialArgs(process.platform, appState.initialArgs), - shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args), - ensureTray: () => ensureTray(), - isTexthookerOnlyMode: () => appState.texthookerOnlyMode, - hasImmersionTracker: () => Boolean(appState.immersionTracker), - getMpvClient: () => appState.mpvClient, + showMpvOsd: (text) => mpvRuntime.showMpvOsd(text), + shouldAutoOpenFirstRunSetup: (args) => shouldAutoOpenFirstRunSetup(args), + youtube, + shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) => + shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs ?? null), + isHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args), commandNeedsOverlayStartupPrereqs: (args) => commandNeedsOverlayStartupPrereqs(args), commandNeedsOverlayRuntime: (args) => commandNeedsOverlayRuntime(args), - ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), - isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - initializeOverlayRuntime: () => initializeOverlayRuntime(), - logInfo: (message) => logger.info(message), - }, -}); -const { runAndApplyStartupState } = composeHeadlessStartupHandlers< - CliArgs, - StartupState, - ReturnType ->({ - startupRuntimeHandlersDeps: { - appLifecycleRuntimeRunnerMainDeps: { - app: appLifecycleApp, - platform: process.platform, - shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs), - parseArgs: (argv: string[]) => parseArgs(argv), - handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => - handleCliCommand(nextArgs, source), - printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), - logNoRunningInstance: () => appLogger.logNoRunningInstance(), - onReady: appReadyRuntimeRunner, - onWillQuitCleanup: () => onWillQuitCleanupHandler(), - shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(), - restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(), - shouldQuitOnWindowAllClosed: () => !appState.backgroundMode, - }, - createAppLifecycleRuntimeRunner: (params) => createAppLifecycleRuntimeRunner(params), - buildStartupBootstrapMainDeps: (startAppLifecycle) => ({ - argv: process.argv, - parseArgs: (argv: string[]) => parseArgs(argv), - setLogLevel: (level: string, source: LogLevelSource) => { - setLogLevel(level, source); - }, - forceX11Backend: (args: CliArgs) => { - forceX11Backend(args); - }, - enforceUnsupportedWaylandMode: (args: CliArgs) => { - enforceUnsupportedWaylandMode(args); - }, - shouldStartApp: (args: CliArgs) => shouldStartApp(args), - getDefaultSocketPath: () => getDefaultSocketPath(), - defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, - configDir: CONFIG_DIR, - defaultConfig: DEFAULT_CONFIG, - generateConfigTemplate: (config: ResolvedConfig) => generateConfigTemplate(config), - generateDefaultConfigFile: ( - args: CliArgs, - options: { - configDir: string; - defaultConfig: unknown; - generateTemplate: (config: unknown) => string; - }, - ) => generateDefaultConfigFile(args, options), - setExitCode: (code) => { - process.exitCode = code; - }, - quitApp: () => requestAppQuit(), - logGenerateConfigError: (message) => logger.error(message), - startAppLifecycle, - }), - createStartupBootstrapRuntimeDeps: (deps) => createStartupBootstrapRuntimeDeps(deps), - runStartupBootstrapRuntime, + handleCliCommandRuntimeServiceWithContext: ( + args, + source, + cliContext: Parameters[2], + ) => handleCliCommandRuntimeServiceWithContext(args, source, cliContext), + shouldStartApp: (args) => shouldStartApp(args), + parseArgs: (argv) => parseArgs(argv), + printHelp, + onWillQuitCleanupHandler: () => onWillQuitCleanupHandler(), + shouldRestoreWindowsOnActivateHandler: () => shouldRestoreWindowsOnActivateHandler(), + restoreWindowsOnActivateHandler: () => restoreWindowsOnActivateHandler(), + forceX11Backend: (args) => forceX11Backend(args), + enforceUnsupportedWaylandMode: (args) => enforceUnsupportedWaylandMode(args), + getDefaultSocketPath: () => startupSupport.getDefaultSocketPath(), + generateDefaultConfigFile, + runStartupBootstrapRuntime: (deps) => runStartupBootstrapRuntime(deps), applyStartupState: (startupState) => applyStartupState(appState, startupState), + getStartupModeFlags, + requestAppQuit, + }, + constants: { + defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, }, }); -runAndApplyStartupState(); -const startupModeFlags = getStartupModeFlags(appState.initialArgs); -const shouldUseMinimalStartup = startupModeFlags.shouldUseMinimalStartup; -const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup; -if (!appState.initialArgs || (!shouldUseMinimalStartup && !shouldSkipHeavyStartup)) { - if (isAnilistTrackingEnabled(getResolvedConfig())) { - void refreshAnilistClientSecretStateIfEnabled({ - force: true, - allowSetupPrompt: false, - }).catch((error) => { - logger.error('Failed to refresh AniList client secret state during startup', error); - }); - anilistStateRuntime.refreshRetryQueueState(); - } - void initializeDiscordPresenceService().catch((error) => { - logger.error('Failed to initialize Discord presence service during startup', error); - }); +function ensureOverlayStartupPrereqs(): void { + startupRuntime.appReady.ensureOverlayStartupPrereqs(); } -const { createMainWindow: createMainWindowHandler, createModalWindow: createModalWindowHandler } = - createOverlayWindowRuntimeHandlers({ - createOverlayWindowDeps: { - createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), - isDev, - ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), - onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), - setOverlayDebugVisualizationEnabled: (enabled) => - setOverlayDebugVisualizationEnabled(enabled), - isOverlayVisible: (windowKind) => - windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false, - getYomitanSession: () => appState.yomitanSession, - tryHandleOverlayShortcutLocalFallback: (input) => - overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), - forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), - onWindowClosed: (windowKind) => { - if (windowKind === 'visible') { - overlayManager.setMainWindow(null); - } else { - overlayManager.setModalWindow(null); - } - }, - }, - setMainWindow: (window) => overlayManager.setMainWindow(window), - setModalWindow: (window) => overlayManager.setModalWindow(window), - }); -const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = - createTrayRuntimeHandlers({ - resolveTrayIconPathDeps: { - resolveTrayIconPathRuntime, - platform: process.platform, - resourcesPath: process.resourcesPath, - appPath: app.getAppPath(), - dirname: __dirname, - joinPath: (...parts) => path.join(...parts), - fileExists: (candidate) => fs.existsSync(candidate), - }, - buildTrayMenuTemplateDeps: { - buildTrayMenuTemplateRuntime, - initializeOverlayRuntime: () => initializeOverlayRuntime(), - isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), - showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(), - openFirstRunSetupWindow: () => openFirstRunSetupWindow(), - showWindowsMpvLauncherSetup: () => process.platform === 'win32', - openYomitanSettings: () => openYomitanSettings(), - openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - openJellyfinSetupWindow: () => openJellyfinSetupWindow(), - openAnilistSetupWindow: () => openAnilistSetupWindow(), - quitApp: () => requestAppQuit(), - }, - ensureTrayDeps: { - getTray: () => appTray, - setTray: (tray) => { - appTray = tray as Tray | null; - }, - createImageFromPath: (iconPath) => nativeImage.createFromPath(iconPath), - createEmptyImage: () => nativeImage.createEmpty(), - createTray: (icon) => new Tray(icon as ConstructorParameters[0]), - trayTooltip: TRAY_TOOLTIP, - platform: process.platform, - logWarn: (message) => logger.warn(message), - initializeOverlayRuntime: () => initializeOverlayRuntime(), - isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), - }, - destroyTrayDeps: { - getTray: () => appTray, - setTray: (tray) => { - appTray = tray as Tray | null; - }, - }, - buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), - }); -const yomitanProfilePolicy = createYomitanProfilePolicy({ - externalProfilePath: getResolvedConfig().yomitan.externalProfilePath, - logInfo: (message) => logger.info(message), -}); -const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath; -const yomitanExtensionRuntime = createYomitanExtensionRuntime({ - loadYomitanExtensionCore, + +async function ensureYoutubePlaybackRuntimeReady(): Promise { + await startupRuntime.appReady.ensureYoutubePlaybackRuntimeReady(); +} + +const { registerIpcRuntimeHandlers } = createIpcRuntimeBootstrap({ + appState, userDataPath: USER_DATA_PATH, - externalProfilePath: configuredExternalYomitanProfilePath, - getYomitanParserWindow: () => appState.yomitanParserWindow, - setYomitanParserWindow: (window) => { - appState.yomitanParserWindow = window as BrowserWindow | null; + getResolvedConfig: () => getResolvedConfig(), + configService, + overlay: { + manager: overlayManager, + getOverlayUi: () => overlayUi ?? undefined, + modalRuntime: overlayModalRuntime, + contentMeasurementStore: overlayContentMeasurementStore, }, - setYomitanParserReadyPromise: (promise) => { - appState.yomitanParserReadyPromise = promise; + subtitle, + mpvRuntime, + shortcuts, + actions: { + requestAppQuit, + openYomitanSettings: () => yomitan.openYomitanSettings(), + showDesktopNotification, + setAnkiIntegration: (integration: AnkiIntegration | null) => { + appState.ankiIntegration = integration; + appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); + }, }, - setYomitanParserInitPromise: (promise) => { - appState.yomitanParserInitPromise = promise; + runtimes: { + youtube, + anilist, + mining, + dictionarySupport, + configDerived: configDerivedRuntime, + subsync: subsyncRuntime, }, - setYomitanExtension: (extension) => { - appState.yomitanExt = extension; - }, - setYomitanSession: (nextSession) => { - appState.yomitanSession = nextSession; - }, - getYomitanExtension: () => appState.yomitanExt, +}); +const { handleCliCommand, handleInitialArgs, runAndApplyStartupState } = startupRuntime; +handleInitialArgsRef = handleInitialArgs; + +runAndApplyStartupState(); +startupSequence.runPostStartupInitialization(); +const { yomitan, yomitanProfilePolicy } = createYomitanRuntimeBootstrap({ + userDataPath: USER_DATA_PATH, + getResolvedConfig: () => getResolvedConfig(), + appState, + loadYomitanExtensionCore, getLoadInFlight: () => yomitanLoadInFlight, setLoadInFlight: (promise) => { yomitanLoadInFlight = promise; }, -}); -const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = - runtimeRegistry.overlay.createOverlayRuntimeBootstrapHandlers({ - initializeOverlayRuntimeMainDeps: { - appState, - overlayManager: { - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - }, - overlayVisibilityRuntime: { - updateVisibleOverlayVisibility: () => - overlayVisibilityRuntime.updateVisibleOverlayVisibility(), - }, - overlayShortcutsRuntime: { - syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), - }, - createMainWindow: () => { - if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) { - return; - } - createMainWindow(); - }, - registerGlobalShortcuts: () => { - if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) { - return; - } - registerGlobalShortcuts(); - }, - createWindowTracker: (override, targetMpvSocketPath) => { - if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) { - return null; - } - return createWindowTrackerCore(override, targetMpvSocketPath); - }, - updateVisibleOverlayBounds: (geometry: WindowGeometry) => - updateVisibleOverlayBounds(geometry), - getOverlayWindows: () => getOverlayWindows(), - getResolvedConfig: () => getResolvedConfig(), - showDesktopNotification, - createFieldGroupingCallback: () => createFieldGroupingCallback(), - getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), - shouldStartAnkiIntegration: () => - !(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)), - }, - initializeOverlayRuntimeBootstrapDeps: { - isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - initializeOverlayRuntimeCore, - setOverlayRuntimeInitialized: (initialized) => { - appState.overlayRuntimeInitialized = initialized; - }, - startBackgroundWarmups: () => { - if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) { - return; - } - startBackgroundWarmups(); - }, - }, - }); -const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({ - ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), - getYomitanSession: () => appState.yomitanSession, - openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => { - openYomitanSettingsWindow({ - yomitanExt: yomitanExt as Extension, - getExistingWindow: () => getExistingWindow() as BrowserWindow | null, - setWindow: (window) => setWindow(window as BrowserWindow | null), - yomitanSession: (yomitanSession as Session | null | undefined) ?? appState.yomitanSession, - onWindowClosed: () => { - if (appState.yomitanParserWindow) { - clearYomitanParserCachesForWindow(appState.yomitanParserWindow); - } - }, - }); - }, - getExistingWindow: () => appState.yomitanSettingsWindow, - setWindow: (window) => { - appState.yomitanSettingsWindow = window as BrowserWindow | null; - }, + openYomitanSettingsWindow, + logInfo: (message) => logger.info(message), logWarn: (message) => logger.warn(message), logError: (message, error) => logger.error(message, error), + showMpvOsd: (message) => mpvRuntime.showMpvOsd(message), + showDesktopNotification: (title, options) => showDesktopNotification(title, options), }); - -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(); - return; - } - - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) { - createMainWindow(); - } -} - -function setVisibleOverlayVisible(visible: boolean): void { - ensureOverlayWindowsReadyForVisibilityActions(); - if (visible) { - void ensureOverlayMpvSubtitlesHidden(); - } - setVisibleOverlayVisibleHandler(visible); - syncOverlayMpvSubtitleSuppression(); -} - -function toggleVisibleOverlay(): void { - ensureOverlayWindowsReadyForVisibilityActions(); - if (!overlayManager.getVisibleOverlayVisible()) { - void ensureOverlayMpvSubtitlesHidden(); - } - toggleVisibleOverlayHandler(); - syncOverlayMpvSubtitleSuppression(); -} -function setOverlayVisible(visible: boolean): void { - if (visible) { - void ensureOverlayMpvSubtitlesHidden(); - } - setOverlayVisibleHandler(visible); - syncOverlayMpvSubtitleSuppression(); -} -function handleOverlayModalClosed(modal: OverlayHostedModal): void { - handleOverlayModalClosedHandler(modal); -} - -function appendClipboardVideoToQueue(): { ok: boolean; message: string } { - return appendClipboardVideoToQueueHandler(); -} +const overlayUiBootstrap = createOverlayUiBootstrapFromProcessState({ + appState, + overlayManager, + overlayModalInputState, + overlayModalRuntime, + overlayShortcutsRuntime, + runtimes: { + dictionarySupport, + firstRun, + yomitan: { + openYomitanSettings: () => yomitan.openYomitanSettings(), + }, + jellyfin, + anilist, + shortcuts, + mpvRuntime, + }, + env: { + screen, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + dirname: __dirname, + platform: process.platform, + isDev, + }, + actions: { + showMpvOsd: (message) => mpvRuntime.showMpvOsd(message), + showDesktopNotification, + sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), + ensureOverlayMpvSubtitlesHidden, + syncOverlayMpvSubtitleSuppression: () => syncOverlayMpvSubtitleSuppression(), + getResolvedConfig: () => getResolvedConfig(), + requestAppQuit, + }, + trayState: { + getTray: () => appTray, + setTray: (tray) => { + appTray = tray as Tray | null; + }, + trayTooltip: TRAY_TOOLTIP, + logWarn: (message) => logger.warn(message), + }, + startup: { + shouldSkipHeadlessOverlayBootstrap: () => + Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)), + getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), + onInitialized: () => { + appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); + }, + }, +}); +overlayGeometryRuntime = overlayUiBootstrap.overlayGeometry; +overlayUi = overlayUiBootstrap.overlayUi; +syncOverlayVisibilityForModal = overlayUiBootstrap.syncOverlayVisibilityForModal; registerIpcRuntimeHandlers(); diff --git a/src/main/anilist-runtime-coordinator.ts b/src/main/anilist-runtime-coordinator.ts new file mode 100644 index 00000000..1426f545 --- /dev/null +++ b/src/main/anilist-runtime-coordinator.ts @@ -0,0 +1,110 @@ +import path from 'node:path'; + +import { app, BrowserWindow, shell } from 'electron'; + +import { DEFAULT_MIN_WATCH_RATIO } from '../shared/watch-threshold'; +import type { ResolvedConfig } from '../types'; +import { + guessAnilistMediaInfo, + updateAnilistPostWatchProgress, +} from '../core/services/anilist/anilist-updater'; +import type { AnilistSetupWindowLike } from './anilist-runtime'; +import { createAnilistRuntime } from './anilist-runtime'; +import { + isAllowedAnilistExternalUrl, + isAllowedAnilistSetupNavigationUrl, +} from './anilist-url-guard'; + +export interface AnilistRuntimeCoordinatorInput { + getResolvedConfig: () => ResolvedConfig; + isTrackingEnabled: (config: ResolvedConfig) => boolean; + tokenStore: Parameters[0]['tokenStore']; + updateQueue: Parameters[0]['updateQueue']; + appState: { + currentMediaPath: string | null; + currentMediaTitle: string | null; + mpvClient: { + currentTimePos?: number | null; + requestProperty: (name: string) => Promise; + } | null; + anilistSetupWindow: BrowserWindow | null; + }; + dictionarySupport: { + resolveMediaPathForJimaku: (mediaPath: string | null) => string | null; + }; + actions: { + showMpvOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body?: string }) => void; + }; + logger: { + info: (message: string) => void; + warn: (message: string, details?: unknown) => void; + error: (message: string, error?: unknown) => void; + debug: (message: string, details?: unknown) => void; + }; + constants: { + authorizeUrl: string; + clientId: string; + responseType: string; + redirectUri: string; + developerSettingsUrl: string; + durationRetryIntervalMs: number; + minWatchSeconds: number; + maxAttemptedUpdateKeys: number; + }; +} + +export function createAnilistRuntimeCoordinator(input: AnilistRuntimeCoordinatorInput) { + return createAnilistRuntime({ + getResolvedConfig: () => input.getResolvedConfig(), + isTrackingEnabled: (config) => input.isTrackingEnabled(config), + tokenStore: input.tokenStore, + updateQueue: input.updateQueue, + getCurrentMediaPath: () => input.appState.currentMediaPath, + getCurrentMediaTitle: () => input.appState.currentMediaTitle, + getWatchedSeconds: () => input.appState.mpvClient?.currentTimePos ?? Number.NaN, + hasMpvClient: () => Boolean(input.appState.mpvClient), + requestMpvDuration: async () => input.appState.mpvClient?.requestProperty('duration'), + resolveMediaPathForJimaku: (currentMediaPath) => + input.dictionarySupport.resolveMediaPathForJimaku(currentMediaPath), + guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), + updateAnilistPostWatchProgress: (accessToken, title, episode) => + updateAnilistPostWatchProgress(accessToken, title, episode), + createBrowserWindow: (options) => { + const window = new BrowserWindow(options); + input.appState.anilistSetupWindow = window; + window.on('closed', () => { + input.appState.anilistSetupWindow = null; + }); + return window as unknown as AnilistSetupWindowLike; + }, + authorizeUrl: input.constants.authorizeUrl, + clientId: input.constants.clientId, + responseType: input.constants.responseType, + redirectUri: input.constants.redirectUri, + developerSettingsUrl: input.constants.developerSettingsUrl, + isAllowedExternalUrl: (url) => isAllowedAnilistExternalUrl(url), + isAllowedNavigationUrl: (url) => isAllowedAnilistSetupNavigationUrl(url), + openExternal: (url) => shell.openExternal(url), + showMpvOsd: (message) => input.actions.showMpvOsd(message), + showDesktopNotification: (title, options) => + input.actions.showDesktopNotification(title, options), + logInfo: (message) => input.logger.info(message), + logWarn: (message, details) => input.logger.warn(message, details), + logError: (message, error) => input.logger.error(message, error), + logDebug: (message, details) => input.logger.debug(message, details), + isDefaultApp: () => Boolean(process.defaultApp), + getArgv: () => process.argv, + execPath: process.execPath, + resolvePath: (value) => path.resolve(value), + setAsDefaultProtocolClient: (scheme, appPath, args) => + appPath + ? app.setAsDefaultProtocolClient(scheme, appPath, args) + : app.setAsDefaultProtocolClient(scheme), + now: () => Date.now(), + durationRetryIntervalMs: input.constants.durationRetryIntervalMs, + minWatchSeconds: input.constants.minWatchSeconds, + minWatchRatio: DEFAULT_MIN_WATCH_RATIO, + maxAttemptedUpdateKeys: input.constants.maxAttemptedUpdateKeys, + }); +} diff --git a/src/main/anilist-runtime.test.ts b/src/main/anilist-runtime.test.ts new file mode 100644 index 00000000..f1d8119b --- /dev/null +++ b/src/main/anilist-runtime.test.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createAnilistRuntime } from './anilist-runtime'; + +function createSetupWindow() { + const handlers = new Map void>>(); + let destroyed = false; + return { + window: { + focus: () => {}, + close: () => { + destroyed = true; + for (const handler of handlers.get('closed') ?? []) { + handler(); + } + }, + isDestroyed: () => destroyed, + on: (event: 'closed', handler: () => void) => { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + }, + loadURL: async () => {}, + webContents: { + setWindowOpenHandler: () => ({ action: 'deny' as const }), + on: (event: string, handler: (...args: unknown[]) => void) => { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + }, + getURL: () => 'about:blank', + }, + }, + }; +} + +function createRuntime(overrides: Partial[0]> = {}) { + const savedTokens: string[] = []; + const queueCalls: string[] = []; + const notifications: string[] = []; + const state = { + config: { + anilist: { + enabled: true, + accessToken: '', + }, + }, + }; + const setup = createSetupWindow(); + + const runtime = createAnilistRuntime({ + getResolvedConfig: () => state.config, + isTrackingEnabled: (config) => config.anilist.enabled === true, + tokenStore: { + saveToken: (token) => { + savedTokens.push(token); + }, + loadToken: () => null, + clearToken: () => { + savedTokens.push('cleared'); + }, + }, + updateQueue: { + enqueue: (key, title, episode) => { + queueCalls.push(`enqueue:${key}:${title}:${episode}`); + }, + nextReady: () => ({ + key: 'retry-1', + title: 'Demo', + episode: 2, + createdAt: 1, + attemptCount: 0, + nextAttemptAt: 0, + lastError: null, + }), + markSuccess: (key) => { + queueCalls.push(`success:${key}`); + }, + markFailure: (key, message) => { + queueCalls.push(`failure:${key}:${message}`); + }, + getSnapshot: () => ({ + pending: 3, + ready: 1, + deadLetter: 2, + }), + }, + getCurrentMediaPath: () => '/tmp/demo.mkv', + getCurrentMediaTitle: () => 'Demo', + getWatchedSeconds: () => 0, + hasMpvClient: () => false, + requestMpvDuration: async () => 120, + resolveMediaPathForJimaku: (value) => value, + guessAnilistMediaInfo: async () => null, + updateAnilistPostWatchProgress: async () => ({ + status: 'updated', + message: 'updated ok', + }), + createBrowserWindow: () => setup.window, + authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize', + clientId: '36084', + responseType: 'token', + redirectUri: 'https://anilist.subminer.moe/', + developerSettingsUrl: 'https://anilist.co/settings/developer', + isAllowedExternalUrl: () => true, + isAllowedNavigationUrl: () => true, + openExternal: async () => {}, + showMpvOsd: (message) => { + notifications.push(`osd:${message}`); + }, + showDesktopNotification: (_title, options) => { + notifications.push(`notify:${options.body}`); + }, + logInfo: (message) => { + notifications.push(`info:${message}`); + }, + logWarn: () => {}, + logError: () => {}, + logDebug: () => {}, + isDefaultApp: () => false, + getArgv: () => [], + execPath: process.execPath, + resolvePath: (value) => value, + setAsDefaultProtocolClient: () => true, + now: () => 1234, + ...overrides, + }); + + return { + runtime, + state, + savedTokens, + queueCalls, + notifications, + }; +} + +test('anilist runtime saves setup token and updates resolved state', () => { + const harness = createRuntime(); + + const consumed = harness.runtime.consumeAnilistSetupTokenFromUrl( + 'subminer://anilist-setup?access_token=token-123', + ); + + assert.equal(consumed, true); + assert.deepEqual(harness.savedTokens, ['token-123']); + assert.equal(harness.runtime.getStatusSnapshot().tokenStatus, 'resolved'); + assert.equal(harness.runtime.getStatusSnapshot().tokenSource, 'stored'); + assert.equal(harness.runtime.getStatusSnapshot().tokenMessage, 'saved token from AniList login'); + assert.ok(harness.notifications.includes('notify:AniList login success')); +}); + +test('anilist runtime bypasses refresh when tracking disabled', async () => { + const harness = createRuntime(); + harness.state.config = { + anilist: { + enabled: false, + accessToken: '', + }, + }; + + const token = await harness.runtime.refreshAnilistClientSecretStateIfEnabled({ + force: true, + }); + + assert.equal(token, null); + assert.equal(harness.runtime.getStatusSnapshot().tokenStatus, 'not_checked'); + assert.equal(harness.runtime.getStatusSnapshot().tokenSource, 'none'); +}); + +test('anilist runtime refreshes queue snapshot and retry state after processing', async () => { + const harness = createRuntime({ + tokenStore: { + saveToken: () => {}, + loadToken: () => 'stored-token', + clearToken: () => {}, + }, + }); + + harness.runtime.refreshRetryQueueState(); + assert.deepEqual(harness.runtime.getQueueStatusSnapshot(), { + pending: 3, + ready: 1, + deadLetter: 2, + lastAttemptAt: null, + lastError: null, + }); + + const result = await harness.runtime.processNextAnilistRetryUpdate(); + + assert.deepEqual(result, { ok: true, message: 'updated ok' }); + assert.ok(harness.queueCalls.includes('success:retry-1')); + assert.equal(harness.runtime.getQueueStatusSnapshot().lastAttemptAt, 1234); + assert.equal(harness.runtime.getQueueStatusSnapshot().lastError, null); +}); diff --git a/src/main/anilist-runtime.ts b/src/main/anilist-runtime.ts new file mode 100644 index 00000000..b54a5aac --- /dev/null +++ b/src/main/anilist-runtime.ts @@ -0,0 +1,495 @@ +import { + createInitialAnilistMediaGuessRuntimeState, + createInitialAnilistRetryQueueState, + createInitialAnilistSecretResolutionState, + createInitialAnilistUpdateInFlightState, + type AnilistMediaGuessRuntimeState, + type AnilistRetryQueueState, + type AnilistSecretResolutionState, +} from './state'; +import { createAnilistStateRuntime } from './runtime/anilist-state'; +import { composeAnilistSetupHandlers } from './runtime/composers/anilist-setup-composer'; +import { composeAnilistTrackingHandlers } from './runtime/composers/anilist-tracking-composer'; +import { + buildAnilistSetupUrl, + consumeAnilistSetupCallbackUrl, + loadAnilistManualTokenEntry, + openAnilistSetupInBrowser, +} from './runtime/anilist-setup'; +import { + createMaybeFocusExistingAnilistSetupWindowHandler, + createOpenAnilistSetupWindowHandler, +} from './runtime/anilist-setup-window'; +import { + buildAnilistAttemptKey, + rememberAnilistAttemptedUpdateKey, +} from './runtime/anilist-post-watch'; +import { createCreateAnilistSetupWindowHandler } from './runtime/setup-window-factory'; +import type { + AnilistMediaGuess, + AnilistPostWatchUpdateResult, +} from '../core/services/anilist/anilist-updater'; +import type { AnilistUpdateQueue } from '../core/services/anilist/anilist-update-queue'; + +export interface AnilistSetupWindowLike { + focus: () => void; + close: () => void; + isDestroyed: () => boolean; + on: (event: 'closed', handler: () => void) => void; + loadURL: (url: string) => Promise | void; + webContents: { + setWindowOpenHandler: (handler: (details: { url: string }) => { action: 'deny' }) => void; + on: (event: string, handler: (...args: unknown[]) => void) => void; + getURL: () => string; + }; +} + +export interface AnilistTokenStoreLike { + saveToken: (token: string) => void; + loadToken: () => string | null | undefined; + clearToken: () => void; +} + +export interface AnilistRuntimeInput< + TConfig extends { anilist: { accessToken: string; enabled?: boolean } } = { + anilist: { accessToken: string; enabled?: boolean }; + }, + TWindow extends AnilistSetupWindowLike = AnilistSetupWindowLike, +> { + getResolvedConfig: () => TConfig; + isTrackingEnabled: (config: TConfig) => boolean; + tokenStore: AnilistTokenStoreLike; + updateQueue: AnilistUpdateQueue; + getCurrentMediaPath: () => string | null; + getCurrentMediaTitle: () => string | null; + getWatchedSeconds: () => number; + hasMpvClient: () => boolean; + requestMpvDuration: () => Promise; + resolveMediaPathForJimaku: (currentMediaPath: string | null) => string | null; + guessAnilistMediaInfo: ( + mediaPath: string | null, + mediaTitle: string | null, + ) => Promise; + updateAnilistPostWatchProgress: ( + accessToken: string, + title: string, + episode: number, + ) => Promise; + createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; + authorizeUrl: string; + clientId: string; + responseType: string; + redirectUri: string; + developerSettingsUrl: string; + isAllowedExternalUrl: (url: string) => boolean; + isAllowedNavigationUrl: (url: string) => boolean; + openExternal: (url: string) => Promise | void; + showMpvOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body: string }) => void; + logInfo: (message: string) => void; + logWarn: (message: string, details?: unknown) => void; + logError: (message: string, error: unknown) => void; + logDebug: (message: string, details?: unknown) => void; + isDefaultApp: () => boolean; + getArgv: () => string[]; + execPath: string; + resolvePath: (value: string) => string; + setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) => boolean; + now?: () => number; + durationRetryIntervalMs?: number; + minWatchSeconds?: number; + minWatchRatio?: number; + maxAttemptedUpdateKeys?: number; +} + +export interface AnilistRuntime { + notifyAnilistSetup: (message: string) => void; + consumeAnilistSetupTokenFromUrl: (rawUrl: string) => boolean; + handleAnilistSetupProtocolUrl: (rawUrl: string) => boolean; + registerSubminerProtocolClient: () => void; + openAnilistSetupWindow: () => void; + refreshAnilistClientSecretState: (options?: { + force?: boolean; + allowSetupPrompt?: boolean; + }) => Promise; + refreshAnilistClientSecretStateIfEnabled: (options?: { + force?: boolean; + allowSetupPrompt?: boolean; + }) => Promise; + getCurrentAnilistMediaKey: () => string | null; + resetAnilistMediaTracking: (mediaKey: string | null) => void; + getAnilistMediaGuessRuntimeState: () => AnilistMediaGuessRuntimeState; + setAnilistMediaGuessRuntimeState: (state: AnilistMediaGuessRuntimeState) => void; + resetAnilistMediaGuessState: () => void; + maybeProbeAnilistDuration: (mediaKey: string) => Promise; + ensureAnilistMediaGuess: (mediaKey: string) => Promise; + processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>; + maybeRunAnilistPostWatchUpdate: () => Promise; + setClientSecretState: (partial: Partial) => void; + refreshRetryQueueState: () => void; + getStatusSnapshot: () => { + tokenStatus: AnilistSecretResolutionState['status']; + tokenSource: AnilistSecretResolutionState['source']; + tokenMessage: string | null; + tokenResolvedAt: number | null; + tokenErrorAt: number | null; + queuePending: number; + queueReady: number; + queueDeadLetter: number; + queueLastAttemptAt: number | null; + queueLastError: string | null; + }; + getQueueStatusSnapshot: () => AnilistRetryQueueState; + clearTokenState: () => void; + getSetupWindow: () => AnilistSetupWindowLike | null; +} + +const DEFAULT_DURATION_RETRY_INTERVAL_MS = 15_000; +const DEFAULT_MIN_WATCH_SECONDS = 10 * 60; +const DEFAULT_MIN_WATCH_RATIO = 0.85; +const DEFAULT_MAX_ATTEMPTED_UPDATE_KEYS = 1000; + +export function createAnilistRuntime< + TConfig extends { anilist: { accessToken: string; enabled?: boolean } }, + TWindow extends AnilistSetupWindowLike, +>(input: AnilistRuntimeInput): AnilistRuntime { + const now = input.now ?? Date.now; + + let setupWindow: TWindow | null = null; + let setupPageOpened = false; + let cachedAccessToken: string | null = null; + let clientSecretState = createInitialAnilistSecretResolutionState(); + let retryQueueState = createInitialAnilistRetryQueueState(); + let mediaGuessRuntimeState = createInitialAnilistMediaGuessRuntimeState(); + let updateInFlightState = createInitialAnilistUpdateInFlightState(); + const attemptedUpdateKeys = new Set(); + + const stateRuntime = createAnilistStateRuntime({ + getClientSecretState: () => clientSecretState, + setClientSecretState: (next) => { + clientSecretState = next; + }, + getRetryQueueState: () => retryQueueState, + setRetryQueueState: (next) => { + retryQueueState = next; + }, + getUpdateQueueSnapshot: () => input.updateQueue.getSnapshot(), + clearStoredToken: () => input.tokenStore.clearToken(), + clearCachedAccessToken: () => { + cachedAccessToken = null; + }, + }); + + const rememberAttemptedUpdate = (key: string): void => { + rememberAnilistAttemptedUpdateKey( + attemptedUpdateKeys, + key, + input.maxAttemptedUpdateKeys ?? DEFAULT_MAX_ATTEMPTED_UPDATE_KEYS, + ); + }; + + const maybeFocusExistingSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({ + getSetupWindow: () => setupWindow, + }); + const createSetupWindow = createCreateAnilistSetupWindowHandler({ + createBrowserWindow: (options) => input.createBrowserWindow(options), + }); + + const { + notifyAnilistSetup, + consumeAnilistSetupTokenFromUrl, + handleAnilistSetupProtocolUrl, + registerSubminerProtocolClient, + } = composeAnilistSetupHandlers({ + notifyDeps: { + hasMpvClient: () => input.hasMpvClient(), + showMpvOsd: (message) => input.showMpvOsd(message), + showDesktopNotification: (title, options) => input.showDesktopNotification(title, options), + logInfo: (message) => input.logInfo(message), + }, + consumeTokenDeps: { + consumeAnilistSetupCallbackUrl, + saveToken: (token) => input.tokenStore.saveToken(token), + setCachedToken: (token) => { + cachedAccessToken = token; + }, + setResolvedState: (resolvedAt) => { + stateRuntime.setClientSecretState({ + status: 'resolved', + source: 'stored', + message: 'saved token from AniList login', + resolvedAt, + errorAt: null, + }); + }, + setSetupPageOpened: (opened) => { + setupPageOpened = opened; + }, + onSuccess: () => { + notifyAnilistSetup('AniList login success'); + }, + closeWindow: () => { + if (setupWindow && !setupWindow.isDestroyed()) { + setupWindow.close(); + } + }, + }, + handleProtocolDeps: { + consumeAnilistSetupTokenFromUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl), + logWarn: (message, details) => input.logWarn(message, details), + }, + registerProtocolClientDeps: { + isDefaultApp: () => input.isDefaultApp(), + getArgv: () => input.getArgv(), + execPath: input.execPath, + resolvePath: (value) => input.resolvePath(value), + setAsDefaultProtocolClient: (scheme, targetPath, args) => + input.setAsDefaultProtocolClient(scheme, targetPath, args), + logDebug: (message, details) => input.logDebug(message, details), + }, + }); + + const openAnilistSetupWindow = createOpenAnilistSetupWindowHandler({ + maybeFocusExistingSetupWindow: () => maybeFocusExistingSetupWindow(), + createSetupWindow: () => createSetupWindow(), + buildAuthorizeUrl: () => + buildAnilistSetupUrl({ + authorizeUrl: input.authorizeUrl, + clientId: input.clientId, + responseType: input.responseType, + redirectUri: input.redirectUri, + }), + consumeCallbackUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl), + openSetupInBrowser: (authorizeUrl) => + openAnilistSetupInBrowser({ + authorizeUrl, + openExternal: async (url) => { + await input.openExternal(url); + }, + logError: (message, error) => input.logError(message, error), + }), + loadManualTokenEntry: (window, authorizeUrl) => + loadAnilistManualTokenEntry({ + setupWindow: window as never, + authorizeUrl, + developerSettingsUrl: input.developerSettingsUrl, + logWarn: (message, details) => input.logWarn(message, details), + }), + redirectUri: input.redirectUri, + developerSettingsUrl: input.developerSettingsUrl, + isAllowedExternalUrl: (url) => input.isAllowedExternalUrl(url), + isAllowedNavigationUrl: (url) => input.isAllowedNavigationUrl(url), + logWarn: (message, details) => input.logWarn(message, details), + logError: (message, details) => input.logError(message, details), + clearSetupWindow: () => { + setupWindow = null; + }, + setSetupPageOpened: (opened) => { + setupPageOpened = opened; + }, + setSetupWindow: (window) => { + setupWindow = window; + }, + openExternal: (url) => { + void input.openExternal(url); + }, + }); + + const trackingRuntime = composeAnilistTrackingHandlers({ + refreshClientSecretMainDeps: { + getResolvedConfig: () => input.getResolvedConfig(), + isAnilistTrackingEnabled: (config) => input.isTrackingEnabled(config as TConfig), + getCachedAccessToken: () => cachedAccessToken, + setCachedAccessToken: (token) => { + cachedAccessToken = token; + }, + saveStoredToken: (token) => { + input.tokenStore.saveToken(token); + }, + loadStoredToken: () => input.tokenStore.loadToken(), + setClientSecretState: (state) => { + clientSecretState = state; + }, + getAnilistSetupPageOpened: () => setupPageOpened, + setAnilistSetupPageOpened: (opened) => { + setupPageOpened = opened; + }, + openAnilistSetupWindow: () => { + openAnilistSetupWindow(); + }, + now, + }, + getCurrentMediaKeyMainDeps: { + getCurrentMediaPath: () => input.getCurrentMediaPath(), + }, + resetMediaTrackingMainDeps: { + setMediaKey: (value) => { + mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaKey: value }; + }, + setMediaDurationSec: (value) => { + mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaDurationSec: value }; + }, + setMediaGuess: (value) => { + mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value }; + }, + setMediaGuessPromise: (value) => { + mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value }; + }, + setLastDurationProbeAtMs: (value) => { + mediaGuessRuntimeState = { ...mediaGuessRuntimeState, lastDurationProbeAtMs: value }; + }, + }, + getMediaGuessRuntimeStateMainDeps: { + getMediaKey: () => mediaGuessRuntimeState.mediaKey, + getMediaDurationSec: () => mediaGuessRuntimeState.mediaDurationSec, + getMediaGuess: () => mediaGuessRuntimeState.mediaGuess, + getMediaGuessPromise: () => mediaGuessRuntimeState.mediaGuessPromise, + getLastDurationProbeAtMs: () => mediaGuessRuntimeState.lastDurationProbeAtMs, + }, + setMediaGuessRuntimeStateMainDeps: { + setMediaKey: (value) => { + mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaKey: value }; + }, + setMediaDurationSec: (value) => { + mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaDurationSec: value }; + }, + setMediaGuess: (value) => { + mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value }; + }, + setMediaGuessPromise: (value) => { + mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value }; + }, + setLastDurationProbeAtMs: (value) => { + mediaGuessRuntimeState = { ...mediaGuessRuntimeState, lastDurationProbeAtMs: value }; + }, + }, + resetMediaGuessStateMainDeps: { + setMediaGuess: (value) => { + mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value }; + }, + setMediaGuessPromise: (value) => { + mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value }; + }, + }, + maybeProbeDurationMainDeps: { + getState: () => mediaGuessRuntimeState, + setState: (state) => { + mediaGuessRuntimeState = state; + }, + durationRetryIntervalMs: input.durationRetryIntervalMs ?? DEFAULT_DURATION_RETRY_INTERVAL_MS, + now, + requestMpvDuration: () => input.requestMpvDuration(), + logWarn: (message, error) => input.logWarn(message, error), + }, + ensureMediaGuessMainDeps: { + getState: () => mediaGuessRuntimeState, + setState: (state) => { + mediaGuessRuntimeState = state; + }, + resolveMediaPathForJimaku: (currentMediaPath) => + input.resolveMediaPathForJimaku(currentMediaPath), + getCurrentMediaPath: () => input.getCurrentMediaPath(), + getCurrentMediaTitle: () => input.getCurrentMediaTitle(), + guessAnilistMediaInfo: (mediaPath, mediaTitle) => + input.guessAnilistMediaInfo(mediaPath, mediaTitle), + }, + processNextRetryUpdateMainDeps: { + nextReady: () => input.updateQueue.nextReady(), + refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(), + setLastAttemptAt: (value) => { + retryQueueState = { ...retryQueueState, lastAttemptAt: value }; + }, + setLastError: (value) => { + retryQueueState = { ...retryQueueState, lastError: value }; + }, + refreshAnilistClientSecretState: () => trackingRuntime.refreshAnilistClientSecretState(), + updateAnilistPostWatchProgress: (accessToken, title, episode) => + input.updateAnilistPostWatchProgress(accessToken, title, episode), + markSuccess: (key) => { + input.updateQueue.markSuccess(key); + }, + rememberAttemptedUpdateKey: (key) => { + rememberAttemptedUpdate(key); + }, + markFailure: (key, message) => { + input.updateQueue.markFailure(key, message); + }, + logInfo: (message) => input.logInfo(message), + now, + }, + maybeRunPostWatchUpdateMainDeps: { + getInFlight: () => updateInFlightState.inFlight, + setInFlight: (value) => { + updateInFlightState = { ...updateInFlightState, inFlight: value }; + }, + getResolvedConfig: () => input.getResolvedConfig(), + isAnilistTrackingEnabled: (config) => input.isTrackingEnabled(config as TConfig), + getCurrentMediaKey: () => trackingRuntime.getCurrentAnilistMediaKey(), + hasMpvClient: () => input.hasMpvClient(), + getTrackedMediaKey: () => mediaGuessRuntimeState.mediaKey, + resetTrackedMedia: (mediaKey) => { + trackingRuntime.resetAnilistMediaTracking(mediaKey); + }, + getWatchedSeconds: () => input.getWatchedSeconds(), + maybeProbeAnilistDuration: (mediaKey) => trackingRuntime.maybeProbeAnilistDuration(mediaKey), + ensureAnilistMediaGuess: (mediaKey) => trackingRuntime.ensureAnilistMediaGuess(mediaKey), + hasAttemptedUpdateKey: (key) => attemptedUpdateKeys.has(key), + processNextAnilistRetryUpdate: () => trackingRuntime.processNextAnilistRetryUpdate(), + refreshAnilistClientSecretState: () => trackingRuntime.refreshAnilistClientSecretState(), + enqueueRetry: (key, title, episode) => { + input.updateQueue.enqueue(key, title, episode); + }, + markRetryFailure: (key, message) => { + input.updateQueue.markFailure(key, message); + }, + markRetrySuccess: (key) => { + input.updateQueue.markSuccess(key); + }, + refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(), + updateAnilistPostWatchProgress: (accessToken, title, episode) => + input.updateAnilistPostWatchProgress(accessToken, title, episode), + rememberAttemptedUpdateKey: (key) => { + rememberAttemptedUpdate(key); + }, + showMpvOsd: (message) => input.showMpvOsd(message), + logInfo: (message) => input.logInfo(message), + logWarn: (message) => input.logWarn(message), + minWatchSeconds: input.minWatchSeconds ?? DEFAULT_MIN_WATCH_SECONDS, + minWatchRatio: input.minWatchRatio ?? DEFAULT_MIN_WATCH_RATIO, + }, + }); + + return { + notifyAnilistSetup, + consumeAnilistSetupTokenFromUrl, + handleAnilistSetupProtocolUrl, + registerSubminerProtocolClient, + openAnilistSetupWindow, + refreshAnilistClientSecretState: (options) => + trackingRuntime.refreshAnilistClientSecretState(options), + refreshAnilistClientSecretStateIfEnabled: (options) => { + if (!input.isTrackingEnabled(input.getResolvedConfig())) { + return Promise.resolve(null); + } + return trackingRuntime.refreshAnilistClientSecretState(options); + }, + getCurrentAnilistMediaKey: () => trackingRuntime.getCurrentAnilistMediaKey(), + resetAnilistMediaTracking: (mediaKey) => trackingRuntime.resetAnilistMediaTracking(mediaKey), + getAnilistMediaGuessRuntimeState: () => trackingRuntime.getAnilistMediaGuessRuntimeState(), + setAnilistMediaGuessRuntimeState: (state) => + trackingRuntime.setAnilistMediaGuessRuntimeState(state), + resetAnilistMediaGuessState: () => trackingRuntime.resetAnilistMediaGuessState(), + maybeProbeAnilistDuration: (mediaKey) => trackingRuntime.maybeProbeAnilistDuration(mediaKey), + ensureAnilistMediaGuess: (mediaKey) => trackingRuntime.ensureAnilistMediaGuess(mediaKey), + processNextAnilistRetryUpdate: () => trackingRuntime.processNextAnilistRetryUpdate(), + maybeRunAnilistPostWatchUpdate: () => trackingRuntime.maybeRunAnilistPostWatchUpdate(), + setClientSecretState: (partial) => stateRuntime.setClientSecretState(partial), + refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(), + getStatusSnapshot: () => stateRuntime.getStatusSnapshot(), + getQueueStatusSnapshot: () => stateRuntime.getQueueStatusSnapshot(), + clearTokenState: () => stateRuntime.clearTokenState(), + getSetupWindow: () => setupWindow, + }; +} + +export { buildAnilistAttemptKey }; diff --git a/src/main/app-ready-runtime.test.ts b/src/main/app-ready-runtime.test.ts new file mode 100644 index 00000000..d2b91345 --- /dev/null +++ b/src/main/app-ready-runtime.test.ts @@ -0,0 +1,270 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createAppReadyRuntime } from './app-ready-runtime'; + +test('app ready runtime shares overlay startup prereqs with youtube runtime init path', async () => { + let subtitlePosition: unknown | null = null; + let keybindingsCount = 0; + let hasMpvClient = false; + let runtimeOptionsManager: unknown | null = null; + let subtitleTimingTracker: unknown | null = null; + let overlayRuntimeInitialized = false; + const calls: string[] = []; + + const runtime = createAppReadyRuntime({ + reload: { + reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }), + logInfo: () => {}, + logWarning: () => {}, + showDesktopNotification: () => {}, + startConfigHotReload: () => {}, + refreshAnilistClientSecretState: async () => undefined, + failHandlers: { + logError: () => {}, + showErrorBox: () => {}, + quit: () => {}, + }, + }, + criticalConfig: { + getConfigPath: () => '/tmp/config.jsonc', + failHandlers: { + logError: () => {}, + showErrorBox: () => {}, + quit: () => { + throw new Error('quit'); + }, + }, + }, + immersion: { + getResolvedConfig: () => + ({ + immersionTracking: { + enabled: true, + batchSize: 1, + flushIntervalMs: 1, + queueCap: 1, + payloadCapBytes: 1, + maintenanceIntervalMs: 1, + retention: { + eventsDays: 1, + telemetryDays: 1, + sessionsDays: 1, + dailyRollupsDays: 1, + monthlyRollupsDays: 1, + vacuumIntervalDays: 1, + }, + }, + }) as never, + getConfiguredDbPath: () => '/tmp/immersion.sqlite', + createTrackerService: () => ({}) as never, + setTracker: () => {}, + getMpvClient: () => null, + seedTrackerFromCurrentMedia: () => {}, + logInfo: () => {}, + logDebug: () => {}, + logWarn: () => {}, + }, + runner: { + ensureDefaultConfigBootstrap: () => {}, + getSubtitlePosition: () => subtitlePosition, + loadSubtitlePosition: () => { + subtitlePosition = { mode: 'bottom' }; + calls.push('loadSubtitlePosition'); + }, + getKeybindingsCount: () => keybindingsCount, + resolveKeybindings: () => { + keybindingsCount = 3; + calls.push('resolveKeybindings'); + }, + hasMpvClient: () => hasMpvClient, + createMpvClient: () => { + hasMpvClient = true; + calls.push('createMpvClient'); + }, + getRuntimeOptionsManager: () => runtimeOptionsManager, + initRuntimeOptionsManager: () => { + runtimeOptionsManager = {}; + calls.push('initRuntimeOptionsManager'); + }, + getSubtitleTimingTracker: () => subtitleTimingTracker, + createSubtitleTimingTracker: () => { + subtitleTimingTracker = {}; + calls.push('createSubtitleTimingTracker'); + }, + getResolvedConfig: () => + ({ + ankiConnect: { + enabled: false, + }, + }) as never, + getConfigWarnings: () => [], + logConfigWarning: () => {}, + setLogLevel: () => {}, + setSecondarySubMode: () => {}, + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: 5174, + defaultAnnotationWebsocketPort: 6678, + defaultTexthookerPort: 5174, + hasMpvWebsocketPlugin: () => false, + startSubtitleWebsocket: () => {}, + startAnnotationWebsocket: () => {}, + startTexthooker: () => {}, + log: () => {}, + createMecabTokenizerAndCheck: async () => {}, + loadYomitanExtension: async () => {}, + ensureYomitanExtensionLoaded: async () => { + calls.push('ensureYomitanExtensionLoaded'); + }, + handleFirstRunSetup: async () => {}, + startBackgroundWarmups: () => {}, + texthookerOnlyMode: false, + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + setVisibleOverlayVisible: () => {}, + initializeOverlayRuntime: () => { + overlayRuntimeInitialized = true; + calls.push('initializeOverlayRuntime'); + }, + ensureOverlayWindowsReadyForVisibilityActions: () => { + calls.push('ensureOverlayWindowsReadyForVisibilityActions'); + }, + handleInitialArgs: () => {}, + }, + isOverlayRuntimeInitialized: () => overlayRuntimeInitialized, + }); + + runtime.ensureOverlayStartupPrereqs(); + runtime.ensureOverlayStartupPrereqs(); + await runtime.ensureYoutubePlaybackRuntimeReady(); + + assert.deepEqual(calls, [ + 'loadSubtitlePosition', + 'resolveKeybindings', + 'createMpvClient', + 'initRuntimeOptionsManager', + 'createSubtitleTimingTracker', + 'ensureYomitanExtensionLoaded', + 'initializeOverlayRuntime', + ]); +}); + +test('app ready runtime reuses existing overlay runtime during youtube readiness', async () => { + const calls: string[] = []; + + const runtime = createAppReadyRuntime({ + reload: { + reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }), + logInfo: () => {}, + logWarning: () => {}, + showDesktopNotification: () => {}, + startConfigHotReload: () => {}, + refreshAnilistClientSecretState: async () => undefined, + failHandlers: { + logError: () => {}, + showErrorBox: () => {}, + quit: () => {}, + }, + }, + criticalConfig: { + getConfigPath: () => '/tmp/config.jsonc', + failHandlers: { + logError: () => {}, + showErrorBox: () => {}, + quit: () => { + throw new Error('quit'); + }, + }, + }, + immersion: { + getResolvedConfig: () => + ({ + immersionTracking: { + enabled: true, + batchSize: 1, + flushIntervalMs: 1, + queueCap: 1, + payloadCapBytes: 1, + maintenanceIntervalMs: 1, + retention: { + eventsDays: 1, + telemetryDays: 1, + sessionsDays: 1, + dailyRollupsDays: 1, + monthlyRollupsDays: 1, + vacuumIntervalDays: 1, + }, + }, + }) as never, + getConfiguredDbPath: () => '/tmp/immersion.sqlite', + createTrackerService: () => ({}) as never, + setTracker: () => {}, + getMpvClient: () => null, + seedTrackerFromCurrentMedia: () => {}, + logInfo: () => {}, + logDebug: () => {}, + logWarn: () => {}, + }, + runner: { + ensureDefaultConfigBootstrap: () => {}, + getSubtitlePosition: () => ({}) as never, + loadSubtitlePosition: () => { + throw new Error('should not load subtitle position'); + }, + getKeybindingsCount: () => 1, + resolveKeybindings: () => { + throw new Error('should not resolve keybindings'); + }, + hasMpvClient: () => true, + createMpvClient: () => { + throw new Error('should not create mpv client'); + }, + getRuntimeOptionsManager: () => ({}), + initRuntimeOptionsManager: () => { + throw new Error('should not init runtime options'); + }, + getSubtitleTimingTracker: () => ({}), + createSubtitleTimingTracker: () => { + throw new Error('should not create subtitle timing tracker'); + }, + getResolvedConfig: () => ({}) as never, + getConfigWarnings: () => [], + logConfigWarning: () => {}, + setLogLevel: () => {}, + setSecondarySubMode: () => {}, + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: 5174, + defaultAnnotationWebsocketPort: 6678, + defaultTexthookerPort: 5174, + hasMpvWebsocketPlugin: () => false, + startSubtitleWebsocket: () => {}, + startAnnotationWebsocket: () => {}, + startTexthooker: () => {}, + log: () => {}, + createMecabTokenizerAndCheck: async () => {}, + loadYomitanExtension: async () => {}, + ensureYomitanExtensionLoaded: async () => { + calls.push('ensureYomitanExtensionLoaded'); + }, + handleFirstRunSetup: async () => {}, + startBackgroundWarmups: () => {}, + texthookerOnlyMode: false, + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + setVisibleOverlayVisible: () => {}, + initializeOverlayRuntime: () => { + calls.push('initializeOverlayRuntime'); + }, + ensureOverlayWindowsReadyForVisibilityActions: () => { + calls.push('ensureOverlayWindowsReadyForVisibilityActions'); + }, + handleInitialArgs: () => {}, + }, + isOverlayRuntimeInitialized: () => true, + }); + + await runtime.ensureYoutubePlaybackRuntimeReady(); + + assert.deepEqual(calls, [ + 'ensureYomitanExtensionLoaded', + 'ensureOverlayWindowsReadyForVisibilityActions', + ]); +}); diff --git a/src/main/app-ready-runtime.ts b/src/main/app-ready-runtime.ts new file mode 100644 index 00000000..33328260 --- /dev/null +++ b/src/main/app-ready-runtime.ts @@ -0,0 +1,264 @@ +import type { LogLevelSource } from '../logger'; +import type { ConfigValidationWarning, SecondarySubMode } from '../types'; +import { composeAppReadyRuntime } from './runtime/composers/app-ready-composer'; + +type AppReadyConfigLike = { + logging?: { + level?: string; + }; +}; + +type SubtitlePositionLike = unknown; +type RuntimeOptionsManagerLike = unknown; +type SubtitleTimingTrackerLike = unknown; +type ImmersionTrackingConfigLike = { + immersionTracking?: { + enabled?: boolean; + }; +}; +type MpvClientLike = { + connected: boolean; + connect: () => void; +}; + +export interface AppReadyReloadConfigInput { + reloadConfigStrict: () => + | { ok: true; path: string; warnings: ConfigValidationWarning[] } + | { ok: false; path: string; error: string }; + logInfo: (message: string) => void; + logWarning: (message: string) => void; + showDesktopNotification: (title: string, options: { body: string }) => void; + startConfigHotReload: () => void; + refreshAnilistClientSecretState: (options: { force: boolean }) => Promise; + failHandlers: { + logError: (details: string) => void; + showErrorBox: (title: string, details: string) => void; + setExitCode?: (code: number) => void; + quit: () => void; + }; +} + +export interface AppReadyCriticalConfigInput { + getConfigPath: () => string; + failHandlers: { + logError: (details: string) => void; + showErrorBox: (title: string, details: string) => void; + setExitCode?: (code: number) => void; + quit: () => void; + }; +} + +export interface AppReadyImmersionInput { + getResolvedConfig: () => ImmersionTrackingConfigLike; + getConfiguredDbPath: () => string; + createTrackerService: (params: { + dbPath: string; + policy: { + batchSize: number; + flushIntervalMs: number; + queueCap: number; + payloadCapBytes: number; + maintenanceIntervalMs: number; + retention: { + eventsDays: number; + telemetryDays: number; + sessionsDays: number; + dailyRollupsDays: number; + monthlyRollupsDays: number; + vacuumIntervalDays: number; + }; + }; + }) => unknown; + setTracker: (tracker: unknown | null) => void; + getMpvClient: () => MpvClientLike | null; + shouldAutoConnectMpv?: () => boolean; + seedTrackerFromCurrentMedia: () => void; + logInfo: (message: string) => void; + logDebug: (message: string) => void; + logWarn: (message: string, details: unknown) => void; +} + +export interface AppReadyRunnerInput { + ensureDefaultConfigBootstrap: () => void; + getSubtitlePosition: () => SubtitlePositionLike | null; + loadSubtitlePosition: () => void; + getKeybindingsCount: () => number; + resolveKeybindings: () => void; + hasMpvClient: () => boolean; + createMpvClient: () => void; + getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; + initRuntimeOptionsManager: () => void; + getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null; + createSubtitleTimingTracker: () => void; + getResolvedConfig: () => TConfig; + getConfigWarnings: () => ConfigValidationWarning[]; + logConfigWarning: (warning: ConfigValidationWarning) => void; + setLogLevel: (level: string, source: LogLevelSource) => void; + setSecondarySubMode: (mode: SecondarySubMode) => void; + defaultSecondarySubMode: SecondarySubMode; + defaultWebsocketPort: number; + defaultAnnotationWebsocketPort: number; + defaultTexthookerPort: number; + hasMpvWebsocketPlugin: () => boolean; + startSubtitleWebsocket: (port: number) => void; + startAnnotationWebsocket: (port: number) => void; + startTexthooker: (port: number, websocketUrl?: string) => void; + log: (message: string) => void; + createMecabTokenizerAndCheck: () => Promise; + createImmersionTracker?: () => void; + startJellyfinRemoteSession?: () => Promise; + loadYomitanExtension: () => Promise; + ensureYomitanExtensionLoaded: () => Promise; + handleFirstRunSetup: () => Promise; + prewarmSubtitleDictionaries?: () => Promise; + startBackgroundWarmups: () => void; + texthookerOnlyMode: boolean; + shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + initializeOverlayRuntime: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + runHeadlessInitialCommand?: () => Promise; + handleInitialArgs: () => void; + onCriticalConfigErrors?: (errors: string[]) => void; + logDebug?: (message: string) => void; + now?: () => number; + shouldRunHeadlessInitialCommand?: () => boolean; + shouldUseMinimalStartup?: () => boolean; + shouldSkipHeavyStartup?: () => boolean; +} + +export interface AppReadyRuntimeInput { + reload: AppReadyReloadConfigInput; + criticalConfig: AppReadyCriticalConfigInput; + immersion: AppReadyImmersionInput; + runner: AppReadyRunnerInput; + isOverlayRuntimeInitialized: () => boolean; +} + +export interface AppReadyRuntime { + reloadConfig: () => void; + criticalConfigError: (errors: string[]) => never; + ensureOverlayStartupPrereqs: () => void; + ensureYoutubePlaybackRuntimeReady: () => Promise; + runAppReady: () => Promise; +} + +export function createAppReadyRuntime( + input: AppReadyRuntimeInput, +): AppReadyRuntime { + const ensureSubtitlePositionLoaded = (): void => { + if (input.runner.getSubtitlePosition() === null) { + input.runner.loadSubtitlePosition(); + } + }; + + const ensureKeybindingsResolved = (): void => { + if (input.runner.getKeybindingsCount() === 0) { + input.runner.resolveKeybindings(); + } + }; + + const ensureMpvClientCreated = (): void => { + if (!input.runner.hasMpvClient()) { + input.runner.createMpvClient(); + } + }; + + const ensureRuntimeOptionsManagerInitialized = (): void => { + if (!input.runner.getRuntimeOptionsManager()) { + input.runner.initRuntimeOptionsManager(); + } + }; + + const ensureSubtitleTimingTrackerCreated = (): void => { + if (!input.runner.getSubtitleTimingTracker()) { + input.runner.createSubtitleTimingTracker(); + } + }; + + const ensureOverlayStartupPrereqs = (): void => { + ensureSubtitlePositionLoaded(); + ensureKeybindingsResolved(); + ensureMpvClientCreated(); + ensureRuntimeOptionsManagerInitialized(); + ensureSubtitleTimingTrackerCreated(); + }; + + const ensureYoutubePlaybackRuntimeReady = async (): Promise => { + ensureOverlayStartupPrereqs(); + await input.runner.ensureYomitanExtensionLoaded(); + if (!input.isOverlayRuntimeInitialized()) { + input.runner.initializeOverlayRuntime(); + return; + } + input.runner.ensureOverlayWindowsReadyForVisibilityActions(); + }; + + const createImmersionTracker = input.runner.createImmersionTracker; + const startJellyfinRemoteSession = input.runner.startJellyfinRemoteSession; + const prewarmSubtitleDictionaries = input.runner.prewarmSubtitleDictionaries; + const runHeadlessInitialCommand = input.runner.runHeadlessInitialCommand; + + const { reloadConfig, criticalConfigError, appReadyRuntimeRunner } = composeAppReadyRuntime({ + reloadConfigMainDeps: input.reload, + criticalConfigErrorMainDeps: input.criticalConfig, + immersionTrackerStartupMainDeps: input.immersion as never, + appReadyRuntimeMainDeps: { + ensureDefaultConfigBootstrap: () => input.runner.ensureDefaultConfigBootstrap(), + loadSubtitlePosition: () => ensureSubtitlePositionLoaded(), + resolveKeybindings: () => ensureKeybindingsResolved(), + createMpvClient: () => ensureMpvClientCreated(), + initRuntimeOptionsManager: () => ensureRuntimeOptionsManagerInitialized(), + createSubtitleTimingTracker: () => ensureSubtitleTimingTrackerCreated(), + getResolvedConfig: () => input.runner.getResolvedConfig() as never, + getConfigWarnings: () => input.runner.getConfigWarnings(), + logConfigWarning: (warning) => input.runner.logConfigWarning(warning), + setLogLevel: (level, source) => input.runner.setLogLevel(level, source), + setSecondarySubMode: (mode) => input.runner.setSecondarySubMode(mode), + defaultSecondarySubMode: input.runner.defaultSecondarySubMode, + defaultWebsocketPort: input.runner.defaultWebsocketPort, + defaultAnnotationWebsocketPort: input.runner.defaultAnnotationWebsocketPort, + defaultTexthookerPort: input.runner.defaultTexthookerPort, + hasMpvWebsocketPlugin: () => input.runner.hasMpvWebsocketPlugin(), + startSubtitleWebsocket: (port) => input.runner.startSubtitleWebsocket(port), + startAnnotationWebsocket: (port) => input.runner.startAnnotationWebsocket(port), + startTexthooker: (port, websocketUrl) => input.runner.startTexthooker(port, websocketUrl), + log: (message) => input.runner.log(message), + createMecabTokenizerAndCheck: () => input.runner.createMecabTokenizerAndCheck(), + createImmersionTracker: createImmersionTracker ? () => createImmersionTracker() : undefined, + startJellyfinRemoteSession: startJellyfinRemoteSession + ? () => startJellyfinRemoteSession() + : undefined, + loadYomitanExtension: () => input.runner.loadYomitanExtension(), + handleFirstRunSetup: () => input.runner.handleFirstRunSetup(), + prewarmSubtitleDictionaries: prewarmSubtitleDictionaries + ? () => prewarmSubtitleDictionaries() + : undefined, + startBackgroundWarmups: () => input.runner.startBackgroundWarmups(), + texthookerOnlyMode: input.runner.texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: () => + input.runner.shouldAutoInitializeOverlayRuntimeFromConfig(), + setVisibleOverlayVisible: (visible) => input.runner.setVisibleOverlayVisible(visible), + initializeOverlayRuntime: () => input.runner.initializeOverlayRuntime(), + runHeadlessInitialCommand: runHeadlessInitialCommand + ? () => runHeadlessInitialCommand() + : undefined, + handleInitialArgs: () => input.runner.handleInitialArgs(), + logDebug: input.runner.logDebug, + now: input.runner.now, + shouldRunHeadlessInitialCommand: input.runner.shouldRunHeadlessInitialCommand, + shouldUseMinimalStartup: input.runner.shouldUseMinimalStartup, + shouldSkipHeavyStartup: input.runner.shouldSkipHeavyStartup, + }, + }); + + return { + reloadConfig, + criticalConfigError, + ensureOverlayStartupPrereqs, + ensureYoutubePlaybackRuntimeReady, + runAppReady: async () => { + await appReadyRuntimeRunner(); + }, + }; +} diff --git a/src/main/cli-startup-runtime.test.ts b/src/main/cli-startup-runtime.test.ts new file mode 100644 index 00000000..0cc3d051 --- /dev/null +++ b/src/main/cli-startup-runtime.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { CliArgs } from '../cli/args'; + +import { createCliStartupRuntime } from './cli-startup-runtime'; + +test('cli startup runtime returns callable CLI handlers', () => { + const calls: string[] = []; + + const runtime = createCliStartupRuntime({ + appState: { + appState: {} as never, + getInitialArgs: () => null, + isBackgroundMode: () => false, + isTexthookerOnlyMode: () => false, + setTexthookerOnlyMode: () => {}, + hasImmersionTracker: () => false, + getMpvClient: () => null, + isOverlayRuntimeInitialized: () => false, + }, + config: { + defaultConfig: { websocket: { port: 6677 }, annotationWebsocket: { port: 6678 } } as never, + getResolvedConfig: () => ({}) as never, + setCliLogLevel: () => {}, + hasMpvWebsocketPlugin: () => false, + }, + io: { + texthookerService: {} as never, + openExternal: async () => {}, + logBrowserOpenError: () => {}, + showMpvOsd: () => {}, + schedule: () => 0 as never, + logInfo: () => {}, + logWarn: () => {}, + logError: () => {}, + }, + commands: { + initializeOverlayRuntime: () => {}, + toggleVisibleOverlay: () => {}, + openFirstRunSetupWindow: () => {}, + setVisibleOverlayVisible: () => {}, + copyCurrentSubtitle: () => {}, + startPendingMultiCopy: () => {}, + mineSentenceCard: async () => {}, + startPendingMineSentenceMultiple: () => {}, + updateLastCardFromClipboard: async () => {}, + refreshKnownWordCache: async () => {}, + triggerFieldGrouping: async () => {}, + triggerSubsyncFromConfig: async () => {}, + markLastCardAsAudioCard: async () => {}, + getAnilistStatus: () => ({}) as never, + clearAnilistToken: () => {}, + openAnilistSetupWindow: () => {}, + openJellyfinSetupWindow: () => {}, + getAnilistQueueStatus: () => ({}) as never, + processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }), + generateCharacterDictionary: async () => ({ + zipPath: '/tmp/test.zip', + fromCache: false, + mediaId: 1, + mediaTitle: 'Test', + entryCount: 1, + }), + runJellyfinCommand: async () => {}, + runStatsCommand: async () => {}, + runYoutubePlaybackFlow: async () => {}, + openYomitanSettings: () => {}, + cycleSecondarySubMode: () => {}, + openRuntimeOptionsPalette: () => {}, + printHelp: () => {}, + stopApp: () => {}, + hasMainWindow: () => false, + getMultiCopyTimeoutMs: () => 0, + }, + startup: { + shouldEnsureTrayOnStartup: () => false, + shouldRunHeadlessInitialCommand: () => false, + ensureTray: () => {}, + commandNeedsOverlayStartupPrereqs: () => false, + commandNeedsOverlayRuntime: () => false, + ensureOverlayStartupPrereqs: () => {}, + startBackgroundWarmups: () => {}, + }, + handleCliCommandRuntimeServiceWithContext: (args) => { + calls.push(`handle:${(args as { command?: string }).command ?? 'unknown'}`); + }, + }); + + assert.equal(typeof runtime.handleCliCommand, 'function'); + assert.equal(typeof runtime.handleInitialArgs, 'function'); + + runtime.handleCliCommand({ command: 'start' } as unknown as CliArgs); + assert.deepEqual(calls, ['handle:start']); +}); diff --git a/src/main/cli-startup-runtime.ts b/src/main/cli-startup-runtime.ts new file mode 100644 index 00000000..1a5d1f36 --- /dev/null +++ b/src/main/cli-startup-runtime.ts @@ -0,0 +1,220 @@ +import type { CliArgs, CliCommandSource } from '../cli/args'; +import type { + CliCommandRuntimeServiceContext, + CliCommandRuntimeServiceContextHandlers, +} from './cli-runtime'; +import { composeCliStartupHandlers } from './runtime/composers/cli-startup-composer'; + +/** Mpv client shape required by the CLI command context (appState.mpvClient). */ +type AppStateMpvClientLike = { + setSocketPath: (socketPath: string) => void; + connect: () => void; +} | null; + +/** Mpv client shape required by the initial-args handler (getMpvClient). */ +type InitialArgsMpvClientLike = { connected: boolean; connect: () => void } | null; + +/** Resolved config shape consumed by the CLI command context builder. */ +type ResolvedConfigLike = { + texthooker?: { openBrowser?: boolean }; + websocket?: { enabled?: boolean | 'auto'; port?: number }; + annotationWebsocket?: { enabled?: boolean; port?: number }; +}; + +/** Mutable app state consumed by the CLI command context builder. */ +type CliCommandContextMainStateLike = { + mpvSocketPath: string; + mpvClient: AppStateMpvClientLike; + texthookerPort: number; + overlayRuntimeInitialized: boolean; +}; + +export interface CliStartupAppStateInput { + appState: CliCommandContextMainStateLike; + getInitialArgs: () => CliArgs | null | undefined; + isBackgroundMode: () => boolean; + isTexthookerOnlyMode: () => boolean; + setTexthookerOnlyMode: (enabled: boolean) => void; + hasImmersionTracker: () => boolean; + getMpvClient: () => InitialArgsMpvClientLike; + isOverlayRuntimeInitialized: () => boolean; +} + +export interface CliStartupConfigInput { + defaultConfig: { + websocket: { port: number }; + annotationWebsocket: { port: number }; + }; + getResolvedConfig: () => ResolvedConfigLike; + setCliLogLevel: (level: NonNullable) => void; + hasMpvWebsocketPlugin: () => boolean; +} + +export interface CliStartupIoInput { + texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService']; + openExternal: (url: string) => Promise; + logBrowserOpenError: (url: string, error: unknown) => void; + showMpvOsd: (text: string) => void; + schedule: (fn: () => void, delayMs: number) => ReturnType; + logInfo: (message: string) => void; + logWarn: (message: string) => void; + logError: (message: string, err: unknown) => void; +} + +export interface CliStartupCommandsInput { + initializeOverlayRuntime: () => void; + toggleVisibleOverlay: () => void; + openFirstRunSetupWindow: () => void; + setVisibleOverlayVisible: (visible: boolean) => void; + copyCurrentSubtitle: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + mineSentenceCard: () => Promise; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + refreshKnownWordCache: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; + getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus']; + clearAnilistToken: () => void; + openAnilistSetupWindow: () => void; + openJellyfinSetupWindow: () => void; + getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus']; + processNextAnilistRetryUpdate: CliCommandRuntimeServiceContext['retryAnilistQueueNow']; + generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary']; + runJellyfinCommand: (argsFromCommand: CliArgs) => Promise; + runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand']; + runYoutubePlaybackFlow: (request: { + url: string; + mode: NonNullable; + source: CliCommandSource; + }) => Promise; + openYomitanSettings: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; + stopApp: () => void; + hasMainWindow: () => boolean; + getMultiCopyTimeoutMs: () => number; +} + +export interface CliStartupStartupInput { + shouldEnsureTrayOnStartup: () => boolean; + shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean; + ensureTray: () => void; + commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean; + commandNeedsOverlayRuntime: (args: CliArgs) => boolean; + ensureOverlayStartupPrereqs: () => void; + startBackgroundWarmups: () => void; +} + +export interface CliStartupRuntimeInput { + appState: CliStartupAppStateInput; + config: CliStartupConfigInput; + io: CliStartupIoInput; + commands: CliStartupCommandsInput; + startup: CliStartupStartupInput; + handleCliCommandRuntimeServiceWithContext: ( + args: CliArgs, + source: CliCommandSource, + context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers, + ) => void; +} + +export interface CliStartupRuntime { + handleCliCommand: (args: CliArgs, source?: CliCommandSource) => void; + handleInitialArgs: () => void; +} + +export function createCliStartupRuntime(input: CliStartupRuntimeInput): CliStartupRuntime { + const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ + cliCommandContextMainDeps: { + appState: input.appState.appState, + setLogLevel: (level) => input.config.setCliLogLevel(level), + texthookerService: input.io.texthookerService, + getResolvedConfig: () => input.config.getResolvedConfig(), + defaultWebsocketPort: input.config.defaultConfig.websocket.port, + defaultAnnotationWebsocketPort: input.config.defaultConfig.annotationWebsocket.port, + hasMpvWebsocketPlugin: () => input.config.hasMpvWebsocketPlugin(), + openExternal: (url: string) => input.io.openExternal(url), + logBrowserOpenError: (url: string, error: unknown) => + input.io.logBrowserOpenError(url, error), + showMpvOsd: (text: string) => input.io.showMpvOsd(text), + initializeOverlayRuntime: () => input.commands.initializeOverlayRuntime(), + toggleVisibleOverlay: () => input.commands.toggleVisibleOverlay(), + openFirstRunSetupWindow: () => input.commands.openFirstRunSetupWindow(), + setVisibleOverlayVisible: (visible: boolean) => + input.commands.setVisibleOverlayVisible(visible), + copyCurrentSubtitle: () => input.commands.copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs: number) => input.commands.startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => input.commands.mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs: number) => + input.commands.startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => input.commands.updateLastCardFromClipboard(), + refreshKnownWordCache: () => input.commands.refreshKnownWordCache(), + triggerFieldGrouping: () => input.commands.triggerFieldGrouping(), + triggerSubsyncFromConfig: () => input.commands.triggerSubsyncFromConfig(), + markLastCardAsAudioCard: () => input.commands.markLastCardAsAudioCard(), + getAnilistStatus: () => input.commands.getAnilistStatus(), + clearAnilistToken: () => input.commands.clearAnilistToken(), + openAnilistSetupWindow: () => input.commands.openAnilistSetupWindow(), + openJellyfinSetupWindow: () => input.commands.openJellyfinSetupWindow(), + getAnilistQueueStatus: () => input.commands.getAnilistQueueStatus(), + processNextAnilistRetryUpdate: () => input.commands.processNextAnilistRetryUpdate(), + generateCharacterDictionary: (targetPath?: string) => + input.commands.generateCharacterDictionary(targetPath), + runJellyfinCommand: (argsFromCommand: CliArgs) => + input.commands.runJellyfinCommand(argsFromCommand), + runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) => + input.commands.runStatsCommand(argsFromCommand, source), + runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request), + openYomitanSettings: () => input.commands.openYomitanSettings(), + cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(), + openRuntimeOptionsPalette: () => input.commands.openRuntimeOptionsPalette(), + printHelp: () => input.commands.printHelp(), + stopApp: () => input.commands.stopApp(), + hasMainWindow: () => input.commands.hasMainWindow(), + getMultiCopyTimeoutMs: () => input.commands.getMultiCopyTimeoutMs(), + schedule: (fn: () => void, delayMs: number) => input.io.schedule(fn, delayMs), + logInfo: (message: string) => input.io.logInfo(message), + logWarn: (message: string) => input.io.logWarn(message), + logError: (message: string, err: unknown) => input.io.logError(message, err), + }, + cliCommandRuntimeHandlerMainDeps: { + handleTexthookerOnlyModeTransitionMainDeps: { + isTexthookerOnlyMode: () => input.appState.isTexthookerOnlyMode(), + ensureOverlayStartupPrereqs: () => input.startup.ensureOverlayStartupPrereqs(), + setTexthookerOnlyMode: (enabled) => input.appState.setTexthookerOnlyMode(enabled), + commandNeedsOverlayStartupPrereqs: (args) => + input.startup.commandNeedsOverlayStartupPrereqs(args), + startBackgroundWarmups: () => input.startup.startBackgroundWarmups(), + logInfo: (message: string) => input.io.logInfo(message), + }, + handleCliCommandRuntimeServiceWithContext: (args, source, context) => + input.handleCliCommandRuntimeServiceWithContext(args, source, context), + }, + initialArgsRuntimeHandlerMainDeps: { + getInitialArgs: () => input.appState.getInitialArgs() ?? null, + isBackgroundMode: () => input.appState.isBackgroundMode(), + shouldEnsureTrayOnStartup: () => input.startup.shouldEnsureTrayOnStartup(), + shouldRunHeadlessInitialCommand: (args) => + input.startup.shouldRunHeadlessInitialCommand(args), + ensureTray: () => input.startup.ensureTray(), + isTexthookerOnlyMode: () => input.appState.isTexthookerOnlyMode(), + hasImmersionTracker: () => input.appState.hasImmersionTracker(), + getMpvClient: () => input.appState.getMpvClient(), + commandNeedsOverlayStartupPrereqs: (args) => + input.startup.commandNeedsOverlayStartupPrereqs(args), + commandNeedsOverlayRuntime: (args) => input.startup.commandNeedsOverlayRuntime(args), + ensureOverlayStartupPrereqs: () => input.startup.ensureOverlayStartupPrereqs(), + isOverlayRuntimeInitialized: () => input.appState.isOverlayRuntimeInitialized(), + initializeOverlayRuntime: () => input.commands.initializeOverlayRuntime(), + logInfo: (message) => input.io.logInfo(message), + }, + }); + + return { + handleCliCommand, + handleInitialArgs, + }; +} diff --git a/src/main/default-socket-path.ts b/src/main/default-socket-path.ts new file mode 100644 index 00000000..b87d6d40 --- /dev/null +++ b/src/main/default-socket-path.ts @@ -0,0 +1,12 @@ +import { + createBuildGetDefaultSocketPathMainDepsHandler, + createGetDefaultSocketPathHandler, +} from './runtime/domains/jellyfin'; + +export function createDefaultSocketPathResolver(platform: NodeJS.Platform) { + return createGetDefaultSocketPathHandler( + createBuildGetDefaultSocketPathMainDepsHandler({ + platform, + })(), + ); +} diff --git a/src/main/dictionary-support-runtime-input.ts b/src/main/dictionary-support-runtime-input.ts new file mode 100644 index 00000000..f4d8fa4b --- /dev/null +++ b/src/main/dictionary-support-runtime-input.ts @@ -0,0 +1,205 @@ +import type { OverlayHostedModal } from '../shared/ipc/contracts'; +import type { FrequencyDictionaryLookup, ResolvedConfig } from '../types'; +import type { JlptLookup } from './jlpt-runtime'; +import type { DictionarySupportRuntimeInput } from './dictionary-support-runtime'; +import { notifyCharacterDictionaryAutoSyncStatus } from './runtime/character-dictionary-auto-sync-notifications'; +import type { StartupOsdSequencerCharacterDictionaryEvent } from './runtime/startup-osd-sequencer'; + +type BrowserWindowLike = { + isDestroyed: () => boolean; + webContents: { + send: (channel: string, payload?: unknown) => void; + }; +}; + +type ImmersionTrackerLike = { + handleMediaChange: (path: string, title: string | null) => void; +}; + +type MpvClientLike = { + currentVideoPath?: string | null; + connected?: boolean; + requestProperty?: (name: string) => Promise; +}; + +type StartupOsdSequencerLike = { + notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void; +}; + +export interface DictionarySupportRuntimeInputBuilderInput { + env: { + platform: NodeJS.Platform; + dirname: string; + appPath: string; + resourcesPath: string; + userDataPath: string; + appUserDataPath: string; + homeDir: string; + appDataDir?: string; + cwd: string; + subtitlePositionsDir: string; + defaultImmersionDbPath: string; + }; + config: { + getResolvedConfig: () => ResolvedConfig; + }; + dictionaryState: { + setJlptLevelLookup: (lookup: JlptLookup) => void; + setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void; + }; + logger: { + info: (message: string) => void; + debug: (message: string) => void; + warn: (message: string) => void; + error: (message: string, ...args: unknown[]) => void; + }; + media: { + isRemoteMediaPath: (mediaPath: string) => boolean; + getCurrentMediaPath: () => string | null; + setCurrentMediaPath: (mediaPath: string | null) => void; + getCurrentMediaTitle: () => string | null; + setCurrentMediaTitle: (title: string | null) => void; + getPendingSubtitlePosition: () => DictionarySupportRuntimeInput['getPendingSubtitlePosition'] extends () => infer T + ? T + : never; + clearPendingSubtitlePosition: () => void; + setSubtitlePosition: DictionarySupportRuntimeInput['setSubtitlePosition']; + }; + subtitle: { + loadSubtitlePosition: DictionarySupportRuntimeInput['loadSubtitlePosition']; + invalidateTokenizationCache: () => void; + refreshSubtitlePrefetchFromActiveTrack: () => void; + refreshCurrentSubtitle: (text: string) => void; + getCurrentSubtitleText: () => string; + }; + overlay: { + broadcastSubtitlePosition: DictionarySupportRuntimeInput['broadcastSubtitlePosition']; + broadcastToOverlayWindows: DictionarySupportRuntimeInput['broadcastToOverlayWindows']; + getMainWindow: () => BrowserWindowLike | null; + getVisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + getRestoreVisibleOverlayOnModalClose: () => Set; + sendToActiveOverlayWindow: DictionarySupportRuntimeInput['sendToActiveOverlayWindow']; + }; + tracker: { + getTracker: () => ImmersionTrackerLike | null; + getMpvClient: () => MpvClientLike | null; + }; + anilist: { + guessAnilistMediaInfo: DictionarySupportRuntimeInput['guessAnilistMediaInfo']; + }; + yomitan: { + isCharacterDictionaryEnabled: () => boolean; + getYomitanDictionaryInfo: () => Promise>; + importYomitanDictionary: (zipPath: string) => Promise; + deleteYomitanDictionary: (dictionaryTitle: string) => Promise; + upsertYomitanDictionarySettings: ( + dictionaryTitle: string, + profileScope: ResolvedConfig['anilist']['characterDictionary']['profileScope'], + ) => Promise; + hasParserWindow: () => boolean; + clearParserCaches: () => void; + }; + startup: { + getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined; + showMpvOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body?: string }) => void; + startupOsdSequencer?: StartupOsdSequencerLike; + }; + playback: { + isYoutubePlaybackActiveNow: () => boolean; + waitForYomitanMutationReady: () => Promise; + }; +} + +export function createDictionarySupportRuntimeInput( + input: DictionarySupportRuntimeInputBuilderInput, +): DictionarySupportRuntimeInput { + return { + platform: input.env.platform, + dirname: input.env.dirname, + appPath: input.env.appPath, + resourcesPath: input.env.resourcesPath, + userDataPath: input.env.userDataPath, + appUserDataPath: input.env.appUserDataPath, + homeDir: input.env.homeDir, + appDataDir: input.env.appDataDir, + cwd: input.env.cwd, + subtitlePositionsDir: input.env.subtitlePositionsDir, + getResolvedConfig: () => input.config.getResolvedConfig(), + isJlptEnabled: () => input.config.getResolvedConfig().subtitleStyle.enableJlpt, + isFrequencyDictionaryEnabled: () => + input.config.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + getFrequencyDictionarySourcePath: () => + input.config.getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath, + setJlptLevelLookup: (lookup) => input.dictionaryState.setJlptLevelLookup(lookup), + setFrequencyRankLookup: (lookup) => input.dictionaryState.setFrequencyRankLookup(lookup), + logInfo: (message) => input.logger.info(message), + logDebug: (message) => input.logger.debug(message), + logWarn: (message) => input.logger.warn(message), + isRemoteMediaPath: (mediaPath) => input.media.isRemoteMediaPath(mediaPath), + getCurrentMediaPath: () => input.media.getCurrentMediaPath(), + setCurrentMediaPath: (mediaPath) => input.media.setCurrentMediaPath(mediaPath), + getCurrentMediaTitle: () => input.media.getCurrentMediaTitle(), + setCurrentMediaTitle: (title) => input.media.setCurrentMediaTitle(title), + getPendingSubtitlePosition: () => input.media.getPendingSubtitlePosition(), + loadSubtitlePosition: () => input.subtitle.loadSubtitlePosition(), + clearPendingSubtitlePosition: () => input.media.clearPendingSubtitlePosition(), + setSubtitlePosition: (position) => input.media.setSubtitlePosition(position), + broadcastSubtitlePosition: (position) => input.overlay.broadcastSubtitlePosition(position), + broadcastToOverlayWindows: (channel, payload) => + input.overlay.broadcastToOverlayWindows(channel, payload), + getTracker: () => input.tracker.getTracker(), + getMpvClient: () => input.tracker.getMpvClient(), + defaultImmersionDbPath: input.env.defaultImmersionDbPath, + guessAnilistMediaInfo: (mediaPath, mediaTitle) => + input.anilist.guessAnilistMediaInfo(mediaPath, mediaTitle), + getCollapsibleSectionOpenState: (section) => + input.config.getResolvedConfig().anilist.characterDictionary.collapsibleSections[section], + isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(), + isYoutubePlaybackActiveNow: () => input.playback.isYoutubePlaybackActiveNow(), + waitForYomitanMutationReady: () => input.playback.waitForYomitanMutationReady(), + getYomitanDictionaryInfo: () => input.yomitan.getYomitanDictionaryInfo(), + importYomitanDictionary: (zipPath) => input.yomitan.importYomitanDictionary(zipPath), + deleteYomitanDictionary: (dictionaryTitle) => + input.yomitan.deleteYomitanDictionary(dictionaryTitle), + upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) => + input.yomitan.upsertYomitanDictionarySettings(dictionaryTitle, profileScope), + getCharacterDictionaryConfig: () => { + const config = input.config.getResolvedConfig().anilist.characterDictionary; + return { + enabled: + config.enabled && + input.yomitan.isCharacterDictionaryEnabled() && + !input.playback.isYoutubePlaybackActiveNow(), + maxLoaded: config.maxLoaded, + profileScope: config.profileScope, + }; + }, + notifyCharacterDictionaryAutoSyncStatus: (event) => { + notifyCharacterDictionaryAutoSyncStatus(event, { + getNotificationType: () => input.startup.getNotificationType(), + showOsd: (message) => input.startup.showMpvOsd(message), + showDesktopNotification: (title, options) => + input.startup.showDesktopNotification(title, options), + startupOsdSequencer: input.startup.startupOsdSequencer, + }); + }, + characterDictionaryAutoSyncCompleteDeps: { + hasParserWindow: () => input.yomitan.hasParserWindow(), + clearParserCaches: () => input.yomitan.clearParserCaches(), + invalidateTokenizationCache: () => input.subtitle.invalidateTokenizationCache(), + refreshSubtitlePrefetch: () => input.subtitle.refreshSubtitlePrefetchFromActiveTrack(), + refreshCurrentSubtitle: () => + input.subtitle.refreshCurrentSubtitle(input.subtitle.getCurrentSubtitleText()), + logInfo: (message) => input.logger.info(message), + }, + getMainWindow: () => input.overlay.getMainWindow(), + getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(), + setVisibleOverlayVisible: (visible) => input.overlay.setVisibleOverlayVisible(visible), + getRestoreVisibleOverlayOnModalClose: () => + input.overlay.getRestoreVisibleOverlayOnModalClose(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => + input.overlay.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + }; +} diff --git a/src/main/dictionary-support-runtime.test.ts b/src/main/dictionary-support-runtime.test.ts new file mode 100644 index 00000000..840ca794 --- /dev/null +++ b/src/main/dictionary-support-runtime.test.ts @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createDictionarySupportRuntime } from './dictionary-support-runtime'; + +function createRuntime() { + const state = { + currentMediaPath: null as string | null, + currentMediaTitle: null as string | null, + jlptLookupSet: 0, + frequencyLookupSet: 0, + trackerCalls: [] as Array<{ path: string; title: string | null }>, + characterDictionaryConfig: { + enabled: false, + maxLoaded: 1, + profileScope: 'global', + }, + youtubePlaybackActive: false, + }; + + const runtime = createDictionarySupportRuntime({ + platform: 'darwin', + dirname: '/repo/dist/main', + appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar', + resourcesPath: '/Applications/SubMiner.app/Contents/Resources', + userDataPath: '/Users/a/Library/Application Support/SubMiner', + appUserDataPath: '/Users/a/Library/Application Support/SubMiner', + homeDir: '/Users/a', + cwd: '/repo', + subtitlePositionsDir: '/Users/a/Library/Application Support/SubMiner/subtitle-positions', + getResolvedConfig: () => + ({ + subtitleStyle: { + enableJlpt: false, + frequencyDictionary: { + enabled: false, + sourcePath: '', + }, + }, + anilist: { + characterDictionary: { + enabled: false, + maxLoaded: 1, + profileScope: 'global', + collapsibleSections: { + description: false, + glossary: false, + termEntry: false, + nameReading: false, + }, + }, + }, + ankiConnect: { + behavior: { + notificationType: 'none', + }, + }, + }) as never, + isJlptEnabled: () => false, + isFrequencyDictionaryEnabled: () => false, + getFrequencyDictionarySourcePath: () => undefined, + setJlptLevelLookup: () => { + state.jlptLookupSet += 1; + }, + setFrequencyRankLookup: () => { + state.frequencyLookupSet += 1; + }, + logInfo: () => {}, + logDebug: () => {}, + logWarn: () => {}, + isRemoteMediaPath: (mediaPath) => mediaPath.startsWith('remote:'), + getCurrentMediaPath: () => state.currentMediaPath, + setCurrentMediaPath: (mediaPath) => { + state.currentMediaPath = mediaPath; + }, + getCurrentMediaTitle: () => state.currentMediaTitle, + setCurrentMediaTitle: (title) => { + state.currentMediaTitle = title; + }, + getPendingSubtitlePosition: () => null, + loadSubtitlePosition: () => null, + clearPendingSubtitlePosition: () => {}, + setSubtitlePosition: () => {}, + broadcastSubtitlePosition: () => {}, + broadcastToOverlayWindows: () => {}, + getTracker: () => + ({ + handleMediaChange: (path: string, title: string | null) => { + state.trackerCalls.push({ path, title }); + }, + }) as never, + getMpvClient: () => null, + defaultImmersionDbPath: '/tmp/immersion.db', + guessAnilistMediaInfo: async () => null, + getCollapsibleSectionOpenState: () => false, + isCharacterDictionaryEnabled: () => state.characterDictionaryConfig.enabled, + isYoutubePlaybackActiveNow: () => state.youtubePlaybackActive, + waitForYomitanMutationReady: async () => {}, + getYomitanDictionaryInfo: async () => [], + importYomitanDictionary: async () => false, + deleteYomitanDictionary: async () => false, + upsertYomitanDictionarySettings: async () => false, + getCharacterDictionaryConfig: () => state.characterDictionaryConfig as never, + notifyCharacterDictionaryAutoSyncStatus: () => {}, + characterDictionaryAutoSyncCompleteDeps: { + hasParserWindow: () => false, + clearParserCaches: () => {}, + invalidateTokenizationCache: () => {}, + refreshSubtitlePrefetch: () => {}, + refreshCurrentSubtitle: () => {}, + logInfo: () => {}, + }, + getMainWindow: () => null, + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getRestoreVisibleOverlayOnModalClose: () => new Set(), + sendToActiveOverlayWindow: () => true, + }); + + return { runtime, state }; +} + +test('dictionary support runtime wires field grouping resolver and callback', async () => { + const { runtime } = createRuntime(); + const choice = { + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }; + + const callback = runtime.createFieldGroupingCallback(); + const pending = callback({} as never); + const resolver = runtime.getFieldGroupingResolver(); + assert.ok(resolver); + resolver(choice as never); + assert.deepEqual(await pending, choice); + assert.equal(typeof runtime.getFieldGroupingResolver(), 'function'); + + runtime.setFieldGroupingResolver(null); + assert.equal(runtime.getFieldGroupingResolver(), null); +}); + +test('dictionary support runtime resolves media paths and keeps title in sync', () => { + const { runtime, state } = createRuntime(); + + runtime.updateCurrentMediaTitle(' Example Title '); + runtime.updateCurrentMediaPath('remote://media' as never); + assert.equal(state.currentMediaTitle, 'Example Title'); + assert.equal(runtime.resolveMediaPathForJimaku('remote://media'), 'Example Title'); + + runtime.updateCurrentMediaPath('local.mp4' as never); + assert.equal(state.currentMediaTitle, null); + assert.equal(runtime.resolveMediaPathForJimaku('remote://media'), 'remote://media'); +}); + +test('dictionary support runtime skips disabled lookup and sync paths', async () => { + const { runtime, state } = createRuntime(); + + await runtime.ensureJlptDictionaryLookup(); + await runtime.ensureFrequencyDictionaryLookup(); + runtime.scheduleCharacterDictionarySync(); + + assert.equal(state.jlptLookupSet, 0); + assert.equal(state.frequencyLookupSet, 0); +}); + +test('dictionary support runtime syncs immersion media from current state', async () => { + const { runtime, state } = createRuntime(); + + runtime.updateCurrentMediaTitle(' Example Title '); + runtime.updateCurrentMediaPath('remote://media' as never); + await runtime.seedImmersionMediaFromCurrentMedia(); + runtime.syncImmersionMediaState(); + + assert.deepEqual(state.trackerCalls, [ + { path: 'remote://media', title: 'Example Title' }, + { path: 'remote://media', title: 'Example Title' }, + ]); +}); + +test('dictionary support runtime gates character dictionary auto-sync scheduling', () => { + const { runtime, state } = createRuntime(); + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + let timeoutCalls = 0; + + try { + globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: never[]) => { + timeoutCalls += 1; + return originalSetTimeout(handler, timeout ?? 0, ...args); + }) as typeof globalThis.setTimeout; + globalThis.clearTimeout = ((handle: number | NodeJS.Timeout | undefined) => { + originalClearTimeout(handle); + }) as typeof globalThis.clearTimeout; + + runtime.scheduleCharacterDictionarySync(); + assert.equal(timeoutCalls, 0); + + state.characterDictionaryConfig = { + enabled: true, + maxLoaded: 1, + profileScope: 'global', + }; + runtime.scheduleCharacterDictionarySync(); + assert.equal(timeoutCalls, 1); + + state.youtubePlaybackActive = true; + runtime.scheduleCharacterDictionarySync(); + assert.equal(timeoutCalls, 1); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } +}); diff --git a/src/main/dictionary-support-runtime.ts b/src/main/dictionary-support-runtime.ts new file mode 100644 index 00000000..92edc5fc --- /dev/null +++ b/src/main/dictionary-support-runtime.ts @@ -0,0 +1,306 @@ +import * as path from 'path'; + +import type { + AnilistCharacterDictionaryProfileScope, + FrequencyDictionaryLookup, + KikuFieldGroupingChoice, + KikuFieldGroupingRequestData, + SubtitlePosition, + ResolvedConfig, +} from '../types'; +import { + createBuildDictionaryRootsMainHandler, + createBuildFrequencyDictionaryRuntimeMainDepsHandler, + createBuildJlptDictionaryRuntimeMainDepsHandler, +} from './runtime/dictionary-runtime-main-deps'; +import { createImmersionMediaRuntime } from './runtime/immersion-media'; +import { + createFrequencyDictionaryRuntimeService, + getFrequencyDictionarySearchPaths, +} from './frequency-dictionary-runtime'; +import { + createJlptDictionaryRuntimeService, + getJlptDictionarySearchPaths, + type JlptLookup, +} from './jlpt-runtime'; +import { createMediaRuntimeService } from './media-runtime'; +import { + createCharacterDictionaryRuntimeService, + type CharacterDictionaryBuildResult, +} from './character-dictionary-runtime'; +import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater'; +import { + createCharacterDictionaryAutoSyncRuntimeService, + type CharacterDictionaryAutoSyncConfig, + type CharacterDictionaryAutoSyncStatusEvent, +} from './runtime/character-dictionary-auto-sync'; +import { handleCharacterDictionaryAutoSyncComplete } from './runtime/character-dictionary-auto-sync-completion'; +import { notifyCharacterDictionaryAutoSyncStatus } from './runtime/character-dictionary-auto-sync-notifications'; +import { createFieldGroupingOverlayRuntime } from '../core/services/field-grouping-overlay'; + +type FieldGroupingResolver = ((choice: KikuFieldGroupingChoice) => void) | null; + +type BrowserWindowLike = { + isDestroyed: () => boolean; + webContents: { + send: (channel: string, payload?: unknown) => void; + }; +}; + +type ImmersionTrackerLike = { + handleMediaChange: (path: string, title: string | null) => void; +}; + +type MpvClientLike = { + currentVideoPath?: string | null; + connected?: boolean; + requestProperty?: (name: string) => Promise; +}; + +type CharacterDictionaryAutoSyncCompleteDeps = { + hasParserWindow: () => boolean; + clearParserCaches: () => void; + invalidateTokenizationCache: () => void; + refreshSubtitlePrefetch: () => void; + refreshCurrentSubtitle: () => void; + logInfo: (message: string) => void; +}; + +export interface DictionarySupportRuntimeInput { + platform: NodeJS.Platform; + dirname: string; + appPath: string; + resourcesPath: string; + userDataPath: string; + appUserDataPath: string; + homeDir: string; + appDataDir?: string; + cwd: string; + subtitlePositionsDir: string; + getResolvedConfig: () => ResolvedConfig; + isJlptEnabled: () => boolean; + isFrequencyDictionaryEnabled: () => boolean; + getFrequencyDictionarySourcePath: () => string | undefined; + setJlptLevelLookup: (lookup: JlptLookup) => void; + setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void; + logInfo: (message: string) => void; + logDebug?: (message: string) => void; + logWarn: (message: string) => void; + isRemoteMediaPath: (mediaPath: string) => boolean; + getCurrentMediaPath: () => string | null; + setCurrentMediaPath: (mediaPath: string | null) => void; + getCurrentMediaTitle: () => string | null; + setCurrentMediaTitle: (title: string | null) => void; + getPendingSubtitlePosition: () => SubtitlePosition | null; + loadSubtitlePosition: () => SubtitlePosition | null; + clearPendingSubtitlePosition: () => void; + setSubtitlePosition: (position: SubtitlePosition | null) => void; + broadcastSubtitlePosition: (position: SubtitlePosition | null) => void; + broadcastToOverlayWindows: (channel: string, payload?: unknown) => void; + getTracker: () => ImmersionTrackerLike | null; + getMpvClient: () => MpvClientLike | null; + defaultImmersionDbPath: string; + guessAnilistMediaInfo: ( + mediaPath: string | null, + mediaTitle: string | null, + ) => Promise; + getCollapsibleSectionOpenState: ( + section: keyof ResolvedConfig['anilist']['characterDictionary']['collapsibleSections'], + ) => boolean; + isCharacterDictionaryEnabled: () => boolean; + isYoutubePlaybackActiveNow: () => boolean; + waitForYomitanMutationReady: () => Promise; + getYomitanDictionaryInfo: () => Promise>; + importYomitanDictionary: (zipPath: string) => Promise; + deleteYomitanDictionary: (dictionaryTitle: string) => Promise; + upsertYomitanDictionarySettings: ( + dictionaryTitle: string, + profileScope: AnilistCharacterDictionaryProfileScope, + ) => Promise; + getCharacterDictionaryConfig: () => CharacterDictionaryAutoSyncConfig; + notifyCharacterDictionaryAutoSyncStatus: (event: CharacterDictionaryAutoSyncStatusEvent) => void; + characterDictionaryAutoSyncCompleteDeps: CharacterDictionaryAutoSyncCompleteDeps; + getMainWindow: () => BrowserWindowLike | null; + getVisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + getRestoreVisibleOverlayOnModalClose: () => Set; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: TModal }, + ) => boolean; +} + +export interface DictionarySupportRuntime { + ensureJlptDictionaryLookup: () => Promise; + ensureFrequencyDictionaryLookup: () => Promise; + getFieldGroupingResolver: () => FieldGroupingResolver; + setFieldGroupingResolver: (resolver: FieldGroupingResolver) => void; + createFieldGroupingCallback: () => ( + data: KikuFieldGroupingRequestData, + ) => Promise; + getConfiguredDbPath: () => string; + seedImmersionMediaFromCurrentMedia: () => Promise; + syncImmersionMediaState: () => void; + resolveMediaPathForJimaku: (mediaPath: string | null) => string | null; + updateCurrentMediaPath: (mediaPath: unknown) => void; + updateCurrentMediaTitle: (mediaTitle: unknown) => void; + scheduleCharacterDictionarySync: () => void; + generateCharacterDictionaryForCurrentMedia: ( + targetPath?: string, + ) => Promise; +} + +export function createDictionarySupportRuntime( + input: DictionarySupportRuntimeInput, +): DictionarySupportRuntime { + const dictionaryRoots = createBuildDictionaryRootsMainHandler({ + platform: input.platform, + dirname: input.dirname, + appPath: input.appPath, + resourcesPath: input.resourcesPath, + userDataPath: input.userDataPath, + appUserDataPath: input.appUserDataPath, + homeDir: input.homeDir, + appDataDir: input.appDataDir, + cwd: input.cwd, + joinPath: (...parts: string[]) => path.join(...parts), + }); + + const jlptRuntime = createJlptDictionaryRuntimeService( + createBuildJlptDictionaryRuntimeMainDepsHandler({ + isJlptEnabled: () => input.isJlptEnabled(), + getDictionaryRoots: () => dictionaryRoots(), + getJlptDictionarySearchPaths, + setJlptLevelLookup: (lookup) => input.setJlptLevelLookup(lookup), + logInfo: (message) => input.logInfo(message), + })(), + ); + + const frequencyRuntime = createFrequencyDictionaryRuntimeService( + createBuildFrequencyDictionaryRuntimeMainDepsHandler({ + isFrequencyDictionaryEnabled: () => input.isFrequencyDictionaryEnabled(), + getDictionaryRoots: () => dictionaryRoots(), + getFrequencyDictionarySearchPaths, + getSourcePath: () => input.getFrequencyDictionarySourcePath(), + setFrequencyRankLookup: (lookup) => input.setFrequencyRankLookup(lookup), + logInfo: (message) => input.logInfo(message), + })(), + ); + + let fieldGroupingResolver: FieldGroupingResolver = null; + let fieldGroupingResolverSequence = 0; + + const getFieldGroupingResolver = (): FieldGroupingResolver => fieldGroupingResolver; + const setFieldGroupingResolver = (resolver: FieldGroupingResolver): void => { + if (!resolver) { + fieldGroupingResolver = null; + return; + } + const sequence = ++fieldGroupingResolverSequence; + fieldGroupingResolver = (choice) => { + if (sequence !== fieldGroupingResolverSequence) { + return; + } + resolver(choice); + }; + }; + + const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime({ + getMainWindow: () => input.getMainWindow(), + getVisibleOverlayVisible: () => input.getVisibleOverlayVisible(), + setVisibleOverlayVisible: (visible) => input.setVisibleOverlayVisible(visible), + getResolver: () => getFieldGroupingResolver(), + setResolver: (resolver) => setFieldGroupingResolver(resolver), + getRestoreVisibleOverlayOnModalClose: () => input.getRestoreVisibleOverlayOnModalClose(), + sendToVisibleOverlay: (channel, payload, runtimeOptions) => + input.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + }); + + const immersionMediaRuntime = createImmersionMediaRuntime({ + getResolvedConfig: () => input.getResolvedConfig(), + defaultImmersionDbPath: input.defaultImmersionDbPath, + getTracker: () => input.getTracker(), + getMpvClient: () => input.getMpvClient(), + getCurrentMediaPath: () => input.getCurrentMediaPath(), + getCurrentMediaTitle: () => input.getCurrentMediaTitle(), + logDebug: (message) => (input.logDebug ?? input.logInfo)(message), + logInfo: (message) => input.logInfo(message), + }); + + const mediaRuntime = createMediaRuntimeService({ + isRemoteMediaPath: (mediaPath) => input.isRemoteMediaPath(mediaPath), + loadSubtitlePosition: () => input.loadSubtitlePosition(), + getCurrentMediaPath: () => input.getCurrentMediaPath(), + getPendingSubtitlePosition: () => input.getPendingSubtitlePosition(), + getSubtitlePositionsDir: () => input.subtitlePositionsDir, + setCurrentMediaPath: (mediaPath) => input.setCurrentMediaPath(mediaPath), + clearPendingSubtitlePosition: () => input.clearPendingSubtitlePosition(), + setSubtitlePosition: (position) => input.setSubtitlePosition(position), + broadcastSubtitlePosition: (position) => input.broadcastSubtitlePosition(position), + getCurrentMediaTitle: () => input.getCurrentMediaTitle(), + setCurrentMediaTitle: (title) => input.setCurrentMediaTitle(title), + }); + + const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({ + userDataPath: input.userDataPath, + getCurrentMediaPath: () => input.getCurrentMediaPath(), + getCurrentMediaTitle: () => input.getCurrentMediaTitle(), + resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath), + guessAnilistMediaInfo: (mediaPath, mediaTitle) => + input.guessAnilistMediaInfo(mediaPath, mediaTitle), + getCollapsibleSectionOpenState: (section) => input.getCollapsibleSectionOpenState(section), + now: () => Date.now(), + logInfo: (message) => input.logInfo(message), + logWarn: (message) => input.logWarn(message), + }); + + const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({ + userDataPath: input.userDataPath, + getConfig: () => input.getCharacterDictionaryConfig(), + getOrCreateCurrentSnapshot: (targetPath, progress) => + characterDictionaryRuntime.getOrCreateCurrentSnapshot(targetPath, progress), + buildMergedDictionary: (mediaIds) => characterDictionaryRuntime.buildMergedDictionary(mediaIds), + waitForYomitanMutationReady: () => input.waitForYomitanMutationReady(), + getYomitanDictionaryInfo: () => input.getYomitanDictionaryInfo(), + importYomitanDictionary: (zipPath) => input.importYomitanDictionary(zipPath), + deleteYomitanDictionary: (dictionaryTitle) => input.deleteYomitanDictionary(dictionaryTitle), + upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) => + input.upsertYomitanDictionarySettings(dictionaryTitle, profileScope), + now: () => Date.now(), + schedule: (fn, delayMs) => setTimeout(fn, delayMs), + clearSchedule: (timer) => clearTimeout(timer), + logInfo: (message) => input.logInfo(message), + logWarn: (message) => input.logWarn(message), + onSyncStatus: (event) => input.notifyCharacterDictionaryAutoSyncStatus(event), + onSyncComplete: (result) => + handleCharacterDictionaryAutoSyncComplete( + result, + input.characterDictionaryAutoSyncCompleteDeps, + ), + }); + + const scheduleCharacterDictionarySync = (): void => { + if (!input.isCharacterDictionaryEnabled() || input.isYoutubePlaybackActiveNow()) { + return; + } + characterDictionaryAutoSyncRuntime.scheduleSync(); + }; + + return { + ensureJlptDictionaryLookup: () => jlptRuntime.ensureJlptDictionaryLookup(), + ensureFrequencyDictionaryLookup: () => frequencyRuntime.ensureFrequencyDictionaryLookup(), + getFieldGroupingResolver, + setFieldGroupingResolver, + createFieldGroupingCallback: () => fieldGroupingOverlayRuntime.createFieldGroupingCallback(), + getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), + seedImmersionMediaFromCurrentMedia: () => immersionMediaRuntime.seedFromCurrentMedia(), + syncImmersionMediaState: () => immersionMediaRuntime.syncFromCurrentMediaState(), + resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath), + updateCurrentMediaPath: (mediaPath) => mediaRuntime.updateCurrentMediaPath(mediaPath), + updateCurrentMediaTitle: (mediaTitle) => mediaRuntime.updateCurrentMediaTitle(mediaTitle), + scheduleCharacterDictionarySync, + generateCharacterDictionaryForCurrentMedia: (targetPath?: string) => + characterDictionaryRuntime.generateForCurrentMedia(targetPath), + }; +} diff --git a/src/main/discord-presence-lifecycle-runtime.test.ts b/src/main/discord-presence-lifecycle-runtime.test.ts new file mode 100644 index 00000000..e5458b3e --- /dev/null +++ b/src/main/discord-presence-lifecycle-runtime.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createDiscordPresenceLifecycleRuntime } from './discord-presence-lifecycle-runtime'; + +test('discord presence lifecycle runtime starts service and publishes presence when enabled', async () => { + const calls: string[] = []; + let service: { start: () => Promise; stop: () => Promise } | null = null; + + const runtime = createDiscordPresenceLifecycleRuntime({ + getResolvedConfig: () => ({ discordPresence: { enabled: true } }), + getDiscordPresenceService: () => service as never, + setDiscordPresenceService: (next) => { + service = next as typeof service; + }, + getMpvClient: () => null, + getCurrentMediaTitle: () => 'Demo', + getCurrentMediaPath: () => '/tmp/demo.mkv', + getCurrentSubtitleText: () => 'subtitle', + getPlaybackPaused: () => false, + getFallbackMediaDurationSec: () => 12, + createDiscordPresenceService: () => ({ + start: async () => { + calls.push('start'); + }, + stop: async () => { + calls.push('stop'); + }, + publish: () => { + calls.push('publish'); + }, + }), + createDiscordRuntime: (input) => ({ + refreshDiscordPresenceMediaDuration: async () => {}, + publishDiscordPresence: () => { + calls.push(input.getCurrentMediaTitle() ?? 'unknown'); + input.getDiscordPresenceService()?.publish({ + mediaTitle: input.getCurrentMediaTitle(), + mediaPath: input.getCurrentMediaPath(), + subtitleText: input.getCurrentSubtitleText(), + currentTimeSec: null, + mediaDurationSec: input.getFallbackMediaDurationSec(), + paused: input.getPlaybackPaused(), + connected: false, + sessionStartedAtMs: input.getSessionStartedAtMs(), + }); + }, + }), + now: () => 123, + }); + + await runtime.initializeDiscordPresenceService(); + + assert.deepEqual(calls, ['start', 'Demo', 'publish']); +}); diff --git a/src/main/discord-presence-lifecycle-runtime.ts b/src/main/discord-presence-lifecycle-runtime.ts new file mode 100644 index 00000000..dc3c44c7 --- /dev/null +++ b/src/main/discord-presence-lifecycle-runtime.ts @@ -0,0 +1,90 @@ +import { createDiscordPresenceRuntime } from './runtime/discord-presence-runtime'; + +type DiscordPresenceConfigLike = { + enabled?: boolean; +}; + +type DiscordPresenceServiceLike = { + start: () => Promise; + stop?: () => Promise; + publish: (snapshot: { + mediaTitle: string | null; + mediaPath: string | null; + subtitleText: string; + currentTimeSec: number | null; + mediaDurationSec: number | null; + paused: boolean | null; + connected: boolean; + sessionStartedAtMs: number; + }) => void; +}; + +export interface DiscordPresenceLifecycleRuntimeInput { + getResolvedConfig: () => { discordPresence: DiscordPresenceConfigLike }; + getDiscordPresenceService: () => DiscordPresenceServiceLike | null; + setDiscordPresenceService: (service: DiscordPresenceServiceLike | null) => void; + getMpvClient: () => { + connected?: boolean; + currentTimePos?: number | null; + requestProperty: (name: string) => Promise; + } | null; + getCurrentMediaTitle: () => string | null; + getCurrentMediaPath: () => string | null; + getCurrentSubtitleText: () => string; + getPlaybackPaused: () => boolean | null; + getFallbackMediaDurationSec: () => number | null; + createDiscordPresenceService: (config: unknown) => DiscordPresenceServiceLike; + createDiscordRuntime?: typeof createDiscordPresenceRuntime; + now?: () => number; +} + +export interface DiscordPresenceLifecycleRuntime { + publishDiscordPresence: () => void; + initializeDiscordPresenceService: () => Promise; + stopDiscordPresenceService: () => Promise; +} + +export function createDiscordPresenceLifecycleRuntime( + input: DiscordPresenceLifecycleRuntimeInput, +): DiscordPresenceLifecycleRuntime { + let discordPresenceMediaDurationSec: number | null = null; + const discordPresenceSessionStartedAtMs = input.now ? input.now() : Date.now(); + + const discordPresenceRuntime = (input.createDiscordRuntime ?? createDiscordPresenceRuntime)({ + getDiscordPresenceService: () => input.getDiscordPresenceService(), + isDiscordPresenceEnabled: () => input.getResolvedConfig().discordPresence.enabled === true, + getMpvClient: () => input.getMpvClient(), + getCurrentMediaTitle: () => input.getCurrentMediaTitle(), + getCurrentMediaPath: () => input.getCurrentMediaPath(), + getCurrentSubtitleText: () => input.getCurrentSubtitleText(), + getPlaybackPaused: () => input.getPlaybackPaused(), + getFallbackMediaDurationSec: () => input.getFallbackMediaDurationSec(), + getSessionStartedAtMs: () => discordPresenceSessionStartedAtMs, + getMediaDurationSec: () => discordPresenceMediaDurationSec, + setMediaDurationSec: (next) => { + discordPresenceMediaDurationSec = next; + }, + }); + + return { + publishDiscordPresence: () => { + discordPresenceRuntime.publishDiscordPresence(); + }, + initializeDiscordPresenceService: async () => { + if (input.getResolvedConfig().discordPresence.enabled !== true) { + input.setDiscordPresenceService(null); + return; + } + + input.setDiscordPresenceService( + input.createDiscordPresenceService(input.getResolvedConfig().discordPresence), + ); + await input.getDiscordPresenceService()?.start(); + discordPresenceRuntime.publishDiscordPresence(); + }, + stopDiscordPresenceService: async () => { + await input.getDiscordPresenceService()?.stop?.(); + input.setDiscordPresenceService(null); + }, + }; +} diff --git a/src/main/first-run-runtime-coordinator.ts b/src/main/first-run-runtime-coordinator.ts new file mode 100644 index 00000000..1baf622f --- /dev/null +++ b/src/main/first-run-runtime-coordinator.ts @@ -0,0 +1,92 @@ +import { BrowserWindow } from 'electron'; + +import { getYomitanDictionaryInfo } from '../core/services'; +import type { ResolvedConfig } from '../types'; +import { createFirstRunRuntime } from './first-run-runtime'; +import type { AppState } from './state'; + +export interface FirstRunRuntimeCoordinatorInput { + platform: NodeJS.Platform; + configDir: string; + homeDir: string; + xdgConfigHome?: string; + binaryPath: string; + appPath: string; + resourcesPath: string; + appDataDir: string; + desktopDir: string; + appState: Pick; + getResolvedConfig: () => ResolvedConfig; + yomitan: { + ensureYomitanExtensionLoaded: () => Promise; + getParserRuntimeDeps: () => Parameters[0]; + openYomitanSettings: () => boolean; + }; + overlay: { + ensureTray: () => void; + hasTray: () => boolean; + }; + actions: { + writeShortcutLink: ( + shortcutPath: string, + operation: 'create' | 'update' | 'replace', + details: { + target: string; + args?: string; + cwd?: string; + description?: string; + icon?: string; + iconIndex?: number; + }, + ) => boolean; + requestAppQuit: () => void; + }; + logger: { + error: (message: string, error: unknown) => void; + info: (message: string, ...args: unknown[]) => void; + }; +} + +export function createFirstRunRuntimeCoordinator(input: FirstRunRuntimeCoordinatorInput) { + return createFirstRunRuntime({ + platform: input.platform, + configDir: input.configDir, + homeDir: input.homeDir, + xdgConfigHome: input.xdgConfigHome, + binaryPath: input.binaryPath, + appPath: input.appPath, + resourcesPath: input.resourcesPath, + appDataDir: input.appDataDir, + desktopDir: input.desktopDir, + getYomitanDictionaryCount: async () => { + await input.yomitan.ensureYomitanExtensionLoaded(); + const dictionaries = await getYomitanDictionaryInfo(input.yomitan.getParserRuntimeDeps(), { + error: (message, ...args) => input.logger.error(message, args[0]), + info: (message, ...args) => input.logger.info(message, ...args), + }); + return dictionaries.length; + }, + isExternalYomitanConfigured: () => + input.getResolvedConfig().yomitan.externalProfilePath.trim().length > 0, + createBrowserWindow: (options) => { + const window = new BrowserWindow(options); + input.appState.firstRunSetupWindow = window; + window.on('closed', () => { + input.appState.firstRunSetupWindow = null; + }); + return window; + }, + writeShortcutLink: (shortcutPath, operation, details) => + input.actions.writeShortcutLink(shortcutPath, operation, details), + openYomitanSettings: () => input.yomitan.openYomitanSettings(), + shouldQuitWhenClosedIncomplete: () => !input.appState.backgroundMode, + quitApp: () => input.actions.requestAppQuit(), + logError: (message, error) => input.logger.error(message, error), + onStateChanged: (state) => { + input.appState.firstRunSetupCompleted = state.status === 'completed'; + if (input.overlay.hasTray()) { + input.overlay.ensureTray(); + } + }, + }); +} diff --git a/src/main/first-run-runtime.test.ts b/src/main/first-run-runtime.test.ts new file mode 100644 index 00000000..5a8b3866 --- /dev/null +++ b/src/main/first-run-runtime.test.ts @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createFirstRunRuntime } from './first-run-runtime'; + +function withTempDir(fn: (dir: string) => Promise | void): Promise | void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-runtime-test-')); + const result = fn(dir); + if (result instanceof Promise) { + return result.finally(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + } + fs.rmSync(dir, { recursive: true, force: true }); +} + +function createMockSetupWindow() { + const calls: string[] = []; + let closedHandler: (() => void) | null = null; + let navigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null; + + return { + calls, + window: { + webContents: { + on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => { + if (event === 'will-navigate') { + navigateHandler = handler; + } + }, + }, + loadURL: async (url: string) => { + calls.push(`load:${url.slice(0, 24)}`); + }, + on: (event: 'closed', handler: () => void) => { + if (event === 'closed') { + closedHandler = handler; + } + }, + isDestroyed: () => false, + close: () => { + calls.push('close'); + closedHandler?.(); + }, + focus: () => { + calls.push('focus'); + }, + triggerNavigate: (url: string) => { + navigateHandler?.( + { + preventDefault: () => { + calls.push('prevent-default'); + }, + }, + url, + ); + }, + }, + }; +} + +test('first-run runtime focuses an existing window instead of creating a new one', async () => { + await withTempDir(async (root) => { + const configDir = path.join(root, 'SubMiner'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + + let createCount = 0; + const mock = createMockSetupWindow(); + const runtime = createFirstRunRuntime({ + platform: 'darwin', + configDir, + homeDir: os.homedir(), + binaryPath: process.execPath, + appPath: '/app', + resourcesPath: '/resources', + appDataDir: path.join(root, 'appData'), + desktopDir: path.join(root, 'desktop'), + getYomitanDictionaryCount: async () => 1, + isExternalYomitanConfigured: () => false, + createBrowserWindow: () => { + createCount += 1; + return mock.window; + }, + writeShortcutLink: () => true, + openYomitanSettings: () => false, + shouldQuitWhenClosedIncomplete: () => true, + quitApp: () => { + throw new Error('quit should not be called'); + }, + logError: () => { + throw new Error('logError should not be called'); + }, + }); + + runtime.openFirstRunSetupWindow(); + runtime.openFirstRunSetupWindow(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(createCount, 1); + assert.equal(mock.calls.filter((call) => call === 'focus').length, 1); + }); +}); + +test('first-run runtime closes the setup window after completion', async () => { + await withTempDir(async (root) => { + const configDir = path.join(root, 'SubMiner'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + + const events: string[] = []; + const mock = createMockSetupWindow(); + const runtime = createFirstRunRuntime({ + platform: 'linux', + configDir, + homeDir: os.homedir(), + binaryPath: process.execPath, + appPath: '/app', + resourcesPath: '/resources', + appDataDir: path.join(root, 'appData'), + desktopDir: path.join(root, 'desktop'), + getYomitanDictionaryCount: async () => 1, + isExternalYomitanConfigured: () => false, + createBrowserWindow: () => mock.window, + writeShortcutLink: () => true, + openYomitanSettings: () => false, + shouldQuitWhenClosedIncomplete: () => true, + quitApp: () => { + events.push('quit'); + }, + logError: (message, error) => { + events.push(`${message}:${String(error)}`); + }, + onStateChanged: (state) => { + events.push(state.status); + }, + }); + + runtime.openFirstRunSetupWindow(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + mock.window.triggerNavigate('subminer://first-run-setup?action=finish'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(runtime.isSetupCompleted(), true); + assert.equal(events[0], 'in_progress'); + assert.equal(events.at(-1), 'completed'); + assert.equal(mock.calls.includes('close'), true); + assert.equal(events.includes('quit'), false); + }); +}); diff --git a/src/main/first-run-runtime.ts b/src/main/first-run-runtime.ts new file mode 100644 index 00000000..e8b8afbc --- /dev/null +++ b/src/main/first-run-runtime.ts @@ -0,0 +1,235 @@ +import { + createFirstRunSetupService, + shouldAutoOpenFirstRunSetup, + type FirstRunSetupService, + type PluginInstallResult, + type SetupStatusSnapshot, +} from './runtime/first-run-setup-service'; +import type { SetupState } from '../shared/setup-state'; +import { + buildFirstRunSetupHtml, + createMaybeFocusExistingFirstRunSetupWindowHandler, + createOpenFirstRunSetupWindowHandler, + parseFirstRunSetupSubmissionUrl, +} from './runtime/first-run-setup-window'; +import { createCreateFirstRunSetupWindowHandler } from './runtime/setup-window-factory'; +import { + detectInstalledFirstRunPlugin, + installFirstRunPluginToDefaultLocation, + syncInstalledFirstRunPluginBinaryPath, +} from './runtime/first-run-setup-plugin'; +import { + applyWindowsMpvShortcuts, + detectWindowsMpvShortcuts, + resolveWindowsMpvShortcutPaths, +} from './runtime/windows-mpv-shortcuts'; +import { resolveDefaultMpvInstallPaths } from '../shared/setup-state'; + +export interface FirstRunSetupWindowLike { + webContents: { + on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void; + }; + loadURL: (url: string) => Promise | void; + on: (event: 'closed', handler: () => void) => void; + isDestroyed: () => boolean; + close: () => void; + focus: () => void; +} + +export interface FirstRunRuntimeInput< + TWindow extends FirstRunSetupWindowLike = FirstRunSetupWindowLike, +> { + platform: NodeJS.Platform; + configDir: string; + homeDir: string; + xdgConfigHome?: string; + binaryPath: string; + appPath: string; + resourcesPath: string; + appDataDir: string; + desktopDir: string; + getYomitanDictionaryCount: () => Promise; + isExternalYomitanConfigured: () => boolean; + createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; + writeShortcutLink: ( + shortcutPath: string, + operation: 'create' | 'update' | 'replace', + details: { + target: string; + args?: string; + cwd?: string; + description?: string; + icon?: string; + iconIndex?: number; + }, + ) => boolean; + openYomitanSettings: () => boolean; + shouldQuitWhenClosedIncomplete: () => boolean; + quitApp: () => void; + logError: (message: string, error: unknown) => void; + onStateChanged?: (state: SetupState) => void; +} + +export interface FirstRunRuntime { + ensureSetupStateInitialized: () => Promise; + isSetupCompleted: () => boolean; + openFirstRunSetupWindow: () => void; +} + +export function createFirstRunRuntime( + input: FirstRunRuntimeInput, +): FirstRunRuntime { + syncInstalledFirstRunPluginBinaryPath({ + platform: input.platform, + homeDir: input.homeDir, + xdgConfigHome: input.xdgConfigHome, + binaryPath: input.binaryPath, + }); + + const firstRunSetupService = createFirstRunSetupService({ + platform: input.platform, + configDir: input.configDir, + getYomitanDictionaryCount: input.getYomitanDictionaryCount, + isExternalYomitanConfigured: input.isExternalYomitanConfigured, + detectPluginInstalled: () => + detectInstalledFirstRunPlugin( + resolveDefaultMpvInstallPaths(input.platform, input.homeDir, input.xdgConfigHome), + ), + installPlugin: async (): Promise => + installFirstRunPluginToDefaultLocation({ + platform: input.platform, + homeDir: input.homeDir, + xdgConfigHome: input.xdgConfigHome, + dirname: __dirname, + appPath: input.appPath, + resourcesPath: input.resourcesPath, + binaryPath: input.binaryPath, + }), + detectWindowsMpvShortcuts: async () => + detectWindowsMpvShortcuts( + resolveWindowsMpvShortcutPaths({ + appDataDir: input.appDataDir, + desktopDir: input.desktopDir, + }), + ), + applyWindowsMpvShortcuts: async (preferences) => + applyWindowsMpvShortcuts({ + preferences, + paths: resolveWindowsMpvShortcutPaths({ + appDataDir: input.appDataDir, + desktopDir: input.desktopDir, + }), + exePath: input.binaryPath, + writeShortcutLink: (shortcutPath, operation, details) => + input.writeShortcutLink(shortcutPath, operation, details), + }), + onStateChanged: (state) => { + input.onStateChanged?.(state); + }, + }); + + let firstRunSetupWindow: TWindow | null = null; + let firstRunSetupMessage: string | null = null; + + const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({ + getSetupWindow: () => firstRunSetupWindow, + }); + + const createSetupWindow = createCreateFirstRunSetupWindowHandler({ + createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => + input.createBrowserWindow(options), + }); + + const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ + maybeFocusExistingSetupWindow: () => maybeFocusExistingFirstRunSetupWindow(), + createSetupWindow: () => { + const window = createSetupWindow(); + firstRunSetupWindow = window; + return window; + }, + getSetupSnapshot: async () => { + const snapshot = await firstRunSetupService.getSetupStatus(); + return { + ...snapshot, + message: firstRunSetupMessage, + }; + }, + buildSetupHtml: (model) => buildFirstRunSetupHtml(model), + parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl), + handleAction: async (submission) => { + if (submission.action === 'install-plugin') { + const snapshot = await firstRunSetupService.installMpvPlugin(); + firstRunSetupMessage = snapshot.message; + return; + } + + if (submission.action === 'configure-windows-mpv-shortcuts') { + const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({ + startMenuEnabled: submission.startMenuEnabled === true, + desktopEnabled: submission.desktopEnabled === true, + }); + firstRunSetupMessage = snapshot.message; + return; + } + + if (submission.action === 'open-yomitan-settings') { + firstRunSetupMessage = input.openYomitanSettings() + ? 'Opened Yomitan settings. Install dictionaries, then refresh status.' + : 'Yomitan settings are unavailable while external read-only profile mode is enabled.'; + return; + } + + if (submission.action === 'refresh') { + const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.'); + firstRunSetupMessage = snapshot.message; + return; + } + + if (submission.action === 'skip-plugin') { + await firstRunSetupService.skipPluginInstall(); + firstRunSetupMessage = 'mpv plugin installation skipped.'; + return; + } + + const snapshot = await firstRunSetupService.markSetupCompleted(); + if (snapshot.state.status === 'completed') { + firstRunSetupMessage = null; + return { closeWindow: true }; + } + firstRunSetupMessage = 'Install at least one Yomitan dictionary before finishing setup.'; + return undefined; + }, + markSetupInProgress: async () => { + firstRunSetupMessage = null; + await firstRunSetupService.markSetupInProgress(); + }, + markSetupCancelled: async () => { + firstRunSetupMessage = null; + await firstRunSetupService.markSetupCancelled(); + }, + isSetupCompleted: () => firstRunSetupService.isSetupCompleted(), + shouldQuitWhenClosedIncomplete: () => input.shouldQuitWhenClosedIncomplete(), + quitApp: () => input.quitApp(), + clearSetupWindow: () => { + firstRunSetupWindow = null; + }, + setSetupWindow: (window) => { + firstRunSetupWindow = window; + }, + encodeURIComponent: (value) => encodeURIComponent(value), + logError: (message, error) => input.logError(message, error), + }); + + return { + ensureSetupStateInitialized: () => firstRunSetupService.ensureSetupStateInitialized(), + isSetupCompleted: () => firstRunSetupService.isSetupCompleted(), + openFirstRunSetupWindow: () => { + if (firstRunSetupService.isSetupCompleted()) { + return; + } + openFirstRunSetupWindowHandler(); + }, + }; +} + +export { shouldAutoOpenFirstRunSetup }; diff --git a/src/main/frequency-dictionary-runtime.ts b/src/main/frequency-dictionary-runtime.ts index a9edcd37..2e6e4446 100644 --- a/src/main/frequency-dictionary-runtime.ts +++ b/src/main/frequency-dictionary-runtime.ts @@ -1,6 +1,6 @@ import * as path from 'path'; import type { FrequencyDictionaryLookup } from '../types'; -import { createFrequencyDictionaryLookup } from '../core/services'; +import { createFrequencyDictionaryLookup } from '../core/services/frequency-dictionary'; export interface FrequencyDictionarySearchPathDeps { getDictionaryRoots: () => string[]; diff --git a/src/main/headless-known-word-refresh.ts b/src/main/headless-known-word-refresh.ts new file mode 100644 index 00000000..5beddc4d --- /dev/null +++ b/src/main/headless-known-word-refresh.ts @@ -0,0 +1,55 @@ +import path from 'node:path'; + +import { mergeAiConfig } from '../ai/config'; +import { AnkiIntegration } from '../anki-integration'; +import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; +import type { ResolvedConfig } from '../types'; +import type { AnkiConnectConfig } from '../types/anki'; + +export async function runHeadlessKnownWordRefresh(input: { + resolvedConfig: ResolvedConfig; + runtimeOptionsManager: { + getEffectiveAnkiConnectConfig: (config: AnkiConnectConfig) => AnkiConnectConfig; + } | null; + userDataPath: string; + logger: { + error: (message: string, error?: unknown) => void; + }; + requestAppQuit: () => void; +}): Promise { + if (input.resolvedConfig.ankiConnect.enabled !== true) { + input.logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled'); + process.exitCode = 1; + input.requestAppQuit(); + return; + } + + const effectiveAnkiConfig = + input.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(input.resolvedConfig.ankiConnect) ?? + input.resolvedConfig.ankiConnect; + const integration = new AnkiIntegration( + effectiveAnkiConfig, + new SubtitleTimingTracker(), + { send: () => undefined } as never, + undefined, + undefined, + async () => ({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: false, + cancelled: true, + }), + path.join(input.userDataPath, 'known-words-cache.json'), + mergeAiConfig(input.resolvedConfig.ai, input.resolvedConfig.ankiConnect?.ai), + ); + + try { + await integration.refreshKnownWordCache(); + } catch (error) { + input.logger.error('Headless known-word refresh failed:', error); + process.exitCode = 1; + } finally { + integration.stop(); + input.requestAppQuit(); + } +} diff --git a/src/main/headless-startup-runtime.test.ts b/src/main/headless-startup-runtime.test.ts new file mode 100644 index 00000000..26af135a --- /dev/null +++ b/src/main/headless-startup-runtime.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { CliArgs } from '../cli/args'; +import type { LogLevelSource } from '../logger'; +import type { StartupBootstrapRuntimeFactoryDeps } from './startup'; + +import { createHeadlessStartupRuntime } from './headless-startup-runtime'; + +test('headless startup runtime returns callable handlers and applies startup state', () => { + const calls: string[] = []; + + const runtime = createHeadlessStartupRuntime< + { mode: string }, + { startAppLifecycle: (args: CliArgs) => void } + >({ + appLifecycleRuntimeRunnerMainDeps: { + app: { on: () => {} } as never, + platform: 'darwin', + shouldStartApp: () => true, + parseArgs: () => ({}) as never, + handleCliCommand: () => {}, + printHelp: () => {}, + logNoRunningInstance: () => {}, + onReady: async () => {}, + onWillQuitCleanup: () => {}, + shouldRestoreWindowsOnActivate: () => false, + restoreWindowsOnActivate: () => {}, + shouldQuitOnWindowAllClosed: () => false, + }, + bootstrap: { + argv: ['node', 'main.js'], + parseArgs: () => ({ command: 'start' }) as never, + setLogLevel: (_level: string, _source: LogLevelSource) => {}, + forceX11Backend: () => {}, + enforceUnsupportedWaylandMode: () => {}, + shouldStartApp: () => true, + getDefaultSocketPath: () => '/tmp/mpv.sock', + defaultTexthookerPort: 5174, + configDir: '/tmp/config', + defaultConfig: {} as never, + generateConfigTemplate: () => 'template', + generateDefaultConfigFile: async () => 0, + setExitCode: () => {}, + quitApp: () => {}, + logGenerateConfigError: () => {}, + startAppLifecycle: (args: CliArgs) => { + calls.push(`bootstrap:${(args as { command?: string }).command ?? 'unknown'}`); + }, + }, + createAppLifecycleRuntimeRunner: () => (args: CliArgs) => { + calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`); + }, + createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => ({ + startAppLifecycle: deps.startAppLifecycle, + }), + runStartupBootstrapRuntime: (deps) => { + deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs); + return { mode: 'started' }; + }, + applyStartupState: (state: { mode: string }) => { + calls.push(`apply:${state.mode}`); + }, + }); + + assert.equal(typeof runtime.appLifecycleRuntimeRunner, 'function'); + assert.equal(typeof runtime.runAndApplyStartupState, 'function'); + assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' }); + assert.deepEqual(calls, ['lifecycle:start', 'apply:started']); +}); + +test('headless startup runtime accepts grouped app lifecycle input', () => { + const calls: string[] = []; + + const runtime = createHeadlessStartupRuntime< + { mode: string }, + { startAppLifecycle: (args: CliArgs) => void } + >({ + appLifecycle: { + app: { on: () => {} } as never, + platform: 'darwin', + shouldStartApp: () => true, + parseArgs: () => ({}) as never, + handleCliCommand: () => {}, + printHelp: () => {}, + logNoRunningInstance: () => {}, + onReady: async () => {}, + onWillQuitCleanup: () => {}, + shouldRestoreWindowsOnActivate: () => false, + restoreWindowsOnActivate: () => {}, + shouldQuitOnWindowAllClosed: () => false, + }, + bootstrap: { + argv: ['node', 'main.js'], + parseArgs: () => ({ command: 'start' }) as never, + setLogLevel: (_level: string, _source: LogLevelSource) => {}, + forceX11Backend: () => {}, + enforceUnsupportedWaylandMode: () => {}, + shouldStartApp: () => true, + getDefaultSocketPath: () => '/tmp/mpv.sock', + defaultTexthookerPort: 5174, + configDir: '/tmp/config', + defaultConfig: {} as never, + generateConfigTemplate: () => 'template', + generateDefaultConfigFile: async () => 0, + setExitCode: () => {}, + quitApp: () => {}, + logGenerateConfigError: () => {}, + startAppLifecycle: (args: CliArgs) => { + calls.push(`bootstrap:${(args as { command?: string }).command ?? 'unknown'}`); + }, + }, + createAppLifecycleRuntimeRunner: () => (args: CliArgs) => { + calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`); + }, + createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => ({ + startAppLifecycle: deps.startAppLifecycle, + }), + runStartupBootstrapRuntime: (deps) => { + deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs); + return { mode: 'started' }; + }, + applyStartupState: (state: { mode: string }) => { + calls.push(`apply:${state.mode}`); + }, + }); + + runtime.appLifecycleRuntimeRunner({ command: 'start' } as unknown as CliArgs); + + assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' }); + assert.deepEqual(calls, ['lifecycle:start', 'lifecycle:start', 'apply:started']); +}); diff --git a/src/main/headless-startup-runtime.ts b/src/main/headless-startup-runtime.ts new file mode 100644 index 00000000..1037f315 --- /dev/null +++ b/src/main/headless-startup-runtime.ts @@ -0,0 +1,106 @@ +import type { CliArgs } from '../cli/args'; +import type { LogLevelSource } from '../logger'; +import type { ResolvedConfig } from '../types'; +import type { StartupBootstrapRuntimeDeps } from '../core/services/startup'; +import { createAppLifecycleDepsRuntime, startAppLifecycle } from '../core/services/app-lifecycle'; +import type { AppLifecycleDepsRuntimeOptions } from '../core/services/app-lifecycle'; +import type { AppLifecycleRuntimeRunnerParams } from './startup-lifecycle'; +import type { StartupBootstrapRuntimeFactoryDeps } from './startup'; +import { createStartupBootstrapRuntimeDeps } from './startup'; +import { composeHeadlessStartupHandlers } from './runtime/composers/headless-startup-composer'; +import { createAppLifecycleRuntimeDeps } from './app-lifecycle'; +import { createBuildAppLifecycleRuntimeRunnerMainDepsHandler } from './runtime/startup-lifecycle-main-deps'; + +export interface HeadlessStartupBootstrapInput { + argv: string[]; + parseArgs: (argv: string[]) => CliArgs; + setLogLevel: (level: string, source: LogLevelSource) => void; + forceX11Backend: (args: CliArgs) => void; + enforceUnsupportedWaylandMode: (args: CliArgs) => void; + shouldStartApp: (args: CliArgs) => boolean; + getDefaultSocketPath: () => string; + defaultTexthookerPort: number; + configDir: string; + defaultConfig: ResolvedConfig; + generateConfigTemplate: (config: ResolvedConfig) => string; + generateDefaultConfigFile: ( + args: CliArgs, + options: { + configDir: string; + defaultConfig: unknown; + generateTemplate: (config: unknown) => string; + }, + ) => Promise; + setExitCode: (code: number) => void; + quitApp: () => void; + logGenerateConfigError: (message: string) => void; + startAppLifecycle: (args: CliArgs) => void; +} + +export type HeadlessStartupAppLifecycleInput = AppLifecycleRuntimeRunnerParams; + +export interface HeadlessStartupRuntimeInput< + TStartupState, + TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps, +> { + appLifecycleRuntimeRunnerMainDeps?: AppLifecycleDepsRuntimeOptions; + appLifecycle?: HeadlessStartupAppLifecycleInput; + bootstrap: HeadlessStartupBootstrapInput; + createAppLifecycleRuntimeRunner?: ( + params: AppLifecycleDepsRuntimeOptions, + ) => (args: CliArgs) => void; + createStartupBootstrapRuntimeDeps?: ( + deps: StartupBootstrapRuntimeFactoryDeps, + ) => TStartupBootstrapRuntimeDeps; + runStartupBootstrapRuntime: (deps: TStartupBootstrapRuntimeDeps) => TStartupState; + applyStartupState: (startupState: TStartupState) => void; +} + +export interface HeadlessStartupRuntime { + appLifecycleRuntimeRunner: (args: CliArgs) => void; + runAndApplyStartupState: () => TStartupState; +} + +export function createHeadlessStartupRuntime< + TStartupState, + TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps, +>( + input: HeadlessStartupRuntimeInput, +): HeadlessStartupRuntime { + const appLifecycleRuntimeRunnerMainDeps = + input.appLifecycleRuntimeRunnerMainDeps ?? input.appLifecycle; + + if (!appLifecycleRuntimeRunnerMainDeps) { + throw new Error('Headless startup runtime needs app lifecycle runtime runner deps'); + } + + const { appLifecycleRuntimeRunner, runAndApplyStartupState } = composeHeadlessStartupHandlers({ + startupRuntimeHandlersDeps: { + appLifecycleRuntimeRunnerMainDeps: createBuildAppLifecycleRuntimeRunnerMainDepsHandler( + appLifecycleRuntimeRunnerMainDeps, + )(), + createAppLifecycleRuntimeRunner: + input.createAppLifecycleRuntimeRunner ?? + ((params: AppLifecycleDepsRuntimeOptions) => (args: CliArgs) => + startAppLifecycle( + args, + createAppLifecycleDepsRuntime(createAppLifecycleRuntimeDeps(params)), + )), + buildStartupBootstrapMainDeps: (startAppLifecycle: (args: CliArgs) => void) => ({ + ...input.bootstrap, + startAppLifecycle, + }), + createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => + input.createStartupBootstrapRuntimeDeps + ? input.createStartupBootstrapRuntimeDeps(deps) + : (createStartupBootstrapRuntimeDeps(deps) as unknown as TStartupBootstrapRuntimeDeps), + runStartupBootstrapRuntime: input.runStartupBootstrapRuntime, + applyStartupState: input.applyStartupState, + }, + }); + + return { + appLifecycleRuntimeRunner, + runAndApplyStartupState, + }; +} diff --git a/src/main/ipc-runtime-bootstrap.ts b/src/main/ipc-runtime-bootstrap.ts new file mode 100644 index 00000000..29d8a07f --- /dev/null +++ b/src/main/ipc-runtime-bootstrap.ts @@ -0,0 +1,253 @@ +import * as path from 'node:path'; + +import type { BrowserWindow } from 'electron'; + +import type { AnkiIntegration } from '../anki-integration'; +import type { + JimakuApiResponse, + JimakuLanguagePreference, + KikuFieldGroupingChoice, + ResolvedConfig, + SubsyncManualRunRequest, + SubsyncResult, +} from '../types'; +import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from '../jimaku/utils'; +import { applyRuntimeOptionResultRuntime } from '../core/services/runtime-options-ipc'; +import { + playNextSubtitleRuntime, + replayCurrentSubtitleRuntime, + sendMpvCommandRuntime, +} from '../core/services'; +import type { ConfigService } from '../config'; +import { applyControllerConfigUpdate } from './controller-config-update.js'; +import type { AnilistRuntime } from './anilist-runtime'; +import type { DictionarySupportRuntime } from './dictionary-support-runtime'; +import { createIpcRuntimeFromMainState, type IpcRuntime } from './ipc-runtime'; +import type { MiningRuntime } from './mining-runtime'; +import type { MpvRuntime } from './mpv-runtime'; +import type { OverlayModalRuntime } from './overlay-runtime'; +import type { OverlayUiRuntime } from './overlay-ui-runtime'; +import type { AppState } from './state'; +import type { SubtitleRuntime } from './subtitle-runtime'; +import type { YoutubeRuntime } from './youtube-runtime'; +import { resolveSubtitleStyleForRenderer } from './runtime/domains/overlay'; +import type { ShortcutsRuntime } from './shortcuts-runtime'; + +type OverlayManagerLike = { + getMainWindow: () => BrowserWindow | null; + getVisibleOverlayVisible: () => boolean; +}; + +type OverlayUiLike = Pick< + OverlayUiRuntime, + | 'broadcastRuntimeOptionsChanged' + | 'handleOverlayModalClosed' + | 'openRuntimeOptionsPalette' + | 'toggleVisibleOverlay' +>; + +type OverlayContentMeasurementStoreLike = { + report: (payload: unknown) => void; +}; + +type ConfigDerivedRuntimeLike = { + jimakuFetchJson: ( + endpoint: string, + query?: Record, + ) => Promise>; + getJimakuMaxEntryResults: () => number; + getJimakuLanguagePreference: () => JimakuLanguagePreference; + resolveJimakuApiKey: () => Promise; +}; + +type SubsyncRuntimeLike = { + triggerFromConfig: () => Promise; + runManualFromIpc: (request: SubsyncManualRunRequest) => Promise; +}; + +export interface IpcRuntimeBootstrapInput { + appState: AppState; + userDataPath: string; + getResolvedConfig: () => ResolvedConfig; + configService: Pick; + overlay: { + manager: OverlayManagerLike; + getOverlayUi: () => OverlayUiLike | undefined; + modalRuntime: Pick; + contentMeasurementStore: OverlayContentMeasurementStoreLike; + }; + subtitle: SubtitleRuntime; + mpvRuntime: Pick; + shortcuts: Pick; + actions: { + requestAppQuit: () => void; + openYomitanSettings: () => boolean; + showDesktopNotification: (title: string, options: { body?: string }) => void; + setAnkiIntegration: (integration: AnkiIntegration | null) => void; + }; + runtimes: { + youtube: Pick; + anilist: Pick< + AnilistRuntime, + | 'getStatusSnapshot' + | 'clearTokenState' + | 'openAnilistSetupWindow' + | 'getQueueStatusSnapshot' + | 'processNextAnilistRetryUpdate' + >; + mining: Pick; + dictionarySupport: Pick< + DictionarySupportRuntime, + | 'createFieldGroupingCallback' + | 'getFieldGroupingResolver' + | 'setFieldGroupingResolver' + | 'resolveMediaPathForJimaku' + >; + configDerived: ConfigDerivedRuntimeLike; + subsync: SubsyncRuntimeLike; + }; +} + +export function createIpcRuntimeBootstrap(input: IpcRuntimeBootstrapInput): IpcRuntime { + return createIpcRuntimeFromMainState({ + mpv: { + mainDeps: { + triggerSubsyncFromConfig: () => input.runtimes.subsync.triggerFromConfig(), + openRuntimeOptionsPalette: () => input.overlay.getOverlayUi()?.openRuntimeOptionsPalette(), + openYoutubeTrackPicker: () => input.runtimes.youtube.openYoutubeTrackPickerFromPlayback(), + cycleRuntimeOption: (id, direction) => { + if (!input.appState.runtimeOptionsManager) { + return { ok: false, error: 'Runtime options manager unavailable' }; + } + return applyRuntimeOptionResultRuntime( + input.appState.runtimeOptionsManager.cycleOption(id, direction), + (text) => input.mpvRuntime.showMpvOsd(text), + ); + }, + showMpvOsd: (text) => input.mpvRuntime.showMpvOsd(text), + replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(input.appState.mpvClient), + playNextSubtitle: () => playNextSubtitleRuntime(input.appState.mpvClient), + shiftSubDelayToAdjacentSubtitle: (direction) => + input.mpvRuntime.shiftSubtitleDelayToAdjacentCue(direction), + sendMpvCommand: (rawCommand) => sendMpvCommandRuntime(input.appState.mpvClient, rawCommand), + getMpvClient: () => input.appState.mpvClient, + isMpvConnected: () => + Boolean(input.appState.mpvClient && input.appState.mpvClient.connected), + hasRuntimeOptionsManager: () => input.appState.runtimeOptionsManager !== null, + }, + runSubsyncManualFromIpc: (request) => input.runtimes.subsync.runManualFromIpc(request), + }, + runtimeOptions: { + getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager, + showMpvOsd: (text) => input.mpvRuntime.showMpvOsd(text), + }, + main: { + window: { + getMainWindow: () => input.overlay.manager.getMainWindow(), + getVisibleOverlayVisibility: () => input.overlay.manager.getVisibleOverlayVisible(), + focusMainWindow: () => { + const mainWindow = input.overlay.manager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + if (!mainWindow.isFocused()) { + mainWindow.focus(); + } + }, + onOverlayModalClosed: (modal) => + input.overlay.getOverlayUi()?.handleOverlayModalClosed(modal), + onOverlayModalOpened: (modal) => { + input.overlay.modalRuntime.notifyOverlayModalOpened(modal); + }, + onYoutubePickerResolve: (request) => input.runtimes.youtube.resolveActivePicker(request), + openYomitanSettings: () => input.actions.openYomitanSettings(), + quitApp: () => input.actions.requestAppQuit(), + toggleVisibleOverlay: () => input.overlay.getOverlayUi()?.toggleVisibleOverlay(), + }, + subtitle: { + tokenizeCurrentSubtitle: async () => await input.subtitle.tokenizeCurrentSubtitle(), + getCurrentSubtitleRaw: () => input.appState.currentSubText, + getCurrentSubtitleAss: () => input.appState.currentSubAssText, + getSubtitleSidebarSnapshot: async () => await input.subtitle.getSubtitleSidebarSnapshot(), + getPlaybackPaused: () => input.appState.playbackPaused, + getSubtitlePosition: () => input.subtitle.loadSubtitlePosition(), + getSubtitleStyle: () => resolveSubtitleStyleForRenderer(input.getResolvedConfig()), + saveSubtitlePosition: (position) => input.subtitle.saveSubtitlePosition(position), + getMecabTokenizer: () => input.appState.mecabTokenizer, + getKeybindings: () => input.appState.keybindings, + getConfiguredShortcuts: () => input.shortcuts.getConfiguredShortcuts(), + getStatsToggleKey: () => input.getResolvedConfig().stats.toggleKey, + getMarkWatchedKey: () => input.getResolvedConfig().stats.markWatchedKey, + getSecondarySubMode: () => input.appState.secondarySubMode, + }, + controller: { + getControllerConfig: () => input.getResolvedConfig().controller, + saveControllerConfig: (update) => { + const currentRawConfig = input.configService.getRawConfig(); + input.configService.patchRawConfig({ + controller: applyControllerConfigUpdate(currentRawConfig.controller, update), + }); + }, + saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => { + input.configService.patchRawConfig({ + controller: { + preferredGamepadId, + preferredGamepadLabel, + }, + }); + }, + }, + runtime: { + getMpvClient: () => input.appState.mpvClient, + getAnkiConnectStatus: () => input.appState.ankiIntegration !== null, + getRuntimeOptions: () => input.appState.runtimeOptionsManager?.listOptions() ?? [], + reportOverlayContentBounds: (payload) => { + input.overlay.contentMeasurementStore.report(payload); + }, + getImmersionTracker: () => input.appState.immersionTracker, + }, + anilist: { + getStatus: () => input.runtimes.anilist.getStatusSnapshot(), + clearToken: () => input.runtimes.anilist.clearTokenState(), + openSetup: () => input.runtimes.anilist.openAnilistSetupWindow(), + getQueueStatus: () => input.runtimes.anilist.getQueueStatusSnapshot(), + retryQueueNow: () => input.runtimes.anilist.processNextAnilistRetryUpdate(), + }, + mining: { + appendClipboardVideoToQueue: () => input.runtimes.mining.appendClipboardVideoToQueue(), + }, + }, + ankiJimaku: { + patchAnkiConnectEnabled: (enabled) => { + input.configService.patchRawConfig({ ankiConnect: { enabled } }); + }, + getResolvedConfig: () => input.getResolvedConfig(), + getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager, + getSubtitleTimingTracker: () => input.appState.subtitleTimingTracker, + getMpvClient: () => input.appState.mpvClient, + getAnkiIntegration: () => input.appState.ankiIntegration, + setAnkiIntegration: (integration) => input.actions.setAnkiIntegration(integration), + getKnownWordCacheStatePath: () => path.join(input.userDataPath, 'known-words-cache.json'), + showDesktopNotification: input.actions.showDesktopNotification, + createFieldGroupingCallback: () => + input.runtimes.dictionarySupport.createFieldGroupingCallback(), + broadcastRuntimeOptionsChanged: () => + input.overlay.getOverlayUi()?.broadcastRuntimeOptionsChanged(), + getFieldGroupingResolver: () => input.runtimes.dictionarySupport.getFieldGroupingResolver(), + setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => + input.runtimes.dictionarySupport.setFieldGroupingResolver(resolver), + parseMediaInfo: (mediaPath: string | null) => + parseMediaInfo(input.runtimes.dictionarySupport.resolveMediaPathForJimaku(mediaPath)), + getCurrentMediaPath: () => input.appState.currentMediaPath, + jimakuFetchJson: ( + endpoint: string, + query?: Record, + ): Promise> => + input.runtimes.configDerived.jimakuFetchJson(endpoint, query), + getJimakuMaxEntryResults: () => input.runtimes.configDerived.getJimakuMaxEntryResults(), + getJimakuLanguagePreference: () => input.runtimes.configDerived.getJimakuLanguagePreference(), + resolveJimakuApiKey: () => input.runtimes.configDerived.resolveJimakuApiKey(), + isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath), + downloadToFile: (url: string, destPath: string, headers: Record) => + downloadToFile(url, destPath, headers), + }, + }); +} diff --git a/src/main/ipc-runtime-services.ts b/src/main/ipc-runtime-services.ts new file mode 100644 index 00000000..d8ddecc4 --- /dev/null +++ b/src/main/ipc-runtime-services.ts @@ -0,0 +1,43 @@ +import { + createIpcDepsRuntime, + registerAnkiJimakuIpcRuntime, + registerIpcHandlers, +} from '../core/services'; +import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc'; +import { + createAnkiJimakuIpcRuntimeServiceDeps, + createMainIpcRuntimeServiceDeps, + createRuntimeOptionsIpcDeps, +} from './dependencies'; +import type { + AnkiJimakuIpcRuntimeServiceDepsParams, + MainIpcRuntimeServiceDepsParams, +} from './dependencies'; +import type { RegisterIpcRuntimeServicesParams } from './ipc-runtime'; + +export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void { + registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params))); +} + +export function registerAnkiJimakuIpcRuntimeServices( + params: AnkiJimakuIpcRuntimeServiceDepsParams, +): void { + registerAnkiJimakuIpcRuntime( + createAnkiJimakuIpcRuntimeServiceDeps(params), + registerAnkiJimakuIpcHandlers, + ); +} + +export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void { + const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({ + getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager, + showMpvOsd: params.runtimeOptions.showMpvOsd, + }); + + registerMainIpcRuntimeServices({ + ...params.mainDeps, + setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption, + cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption, + }); + registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps); +} diff --git a/src/main/ipc-runtime.test.ts b/src/main/ipc-runtime.test.ts new file mode 100644 index 00000000..fc6069c8 --- /dev/null +++ b/src/main/ipc-runtime.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createIpcRuntime, createIpcRuntimeFromMainState } from './ipc-runtime'; + +function createBaseRuntimeInput(capturedRegistration: { value: unknown | null }) { + const manualResult = { ok: true, summary: 'done' }; + const main = { + window: { + getMainWindow: () => null, + getVisibleOverlayVisibility: () => false, + focusMainWindow: () => {}, + onOverlayModalClosed: () => {}, + onOverlayModalOpened: () => {}, + onYoutubePickerResolve: async () => ({ ok: true }) as never, + openYomitanSettings: () => {}, + quitApp: () => {}, + toggleVisibleOverlay: () => {}, + }, + subtitle: { + tokenizeCurrentSubtitle: async () => null, + getCurrentSubtitleRaw: () => '', + getCurrentSubtitleAss: () => '', + getSubtitleSidebarSnapshot: async () => null as never, + getPlaybackPaused: () => false, + getSubtitlePosition: () => null, + getSubtitleStyle: () => null, + saveSubtitlePosition: () => {}, + getMecabTokenizer: () => null, + getKeybindings: () => [], + getConfiguredShortcuts: () => null, + getStatsToggleKey: () => '', + getMarkWatchedKey: () => '', + getSecondarySubMode: () => 'hover', + }, + controller: { + getControllerConfig: () => ({}) as never, + saveControllerConfig: () => {}, + saveControllerPreference: () => {}, + }, + runtime: { + getMpvClient: () => null, + getAnkiConnectStatus: () => false, + getRuntimeOptions: () => [], + reportOverlayContentBounds: () => {}, + getImmersionTracker: () => null, + }, + anilist: { + getStatus: () => null, + clearToken: () => {}, + openSetup: () => {}, + getQueueStatus: () => null, + retryQueueNow: async () => ({ ok: true, message: 'ok' }) as never, + }, + mining: { + appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + }, + }; + const ankiJimaku = { + patchAnkiConnectEnabled: () => {}, + getResolvedConfig: () => ({}), + getRuntimeOptionsManager: () => null, + getSubtitleTimingTracker: () => null, + getMpvClient: () => null, + getAnkiIntegration: () => null, + setAnkiIntegration: () => {}, + getKnownWordCacheStatePath: () => '/tmp/known-words.json', + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => ({}) as never, + broadcastRuntimeOptionsChanged: () => {}, + getFieldGroupingResolver: () => null, + setFieldGroupingResolver: () => {}, + parseMediaInfo: () => ({}) as never, + getCurrentMediaPath: () => null, + jimakuFetchJson: async () => ({ data: null, error: null }) as never, + getJimakuMaxEntryResults: () => 5, + getJimakuLanguagePreference: () => 'ja' as const, + resolveJimakuApiKey: async () => null, + isRemoteMediaPath: () => false, + downloadToFile: async () => ({ ok: true, path: '/tmp/file' }) as never, + }; + + return { + mpv: { + mainDeps: { + triggerSubsyncFromConfig: () => {}, + openRuntimeOptionsPalette: () => {}, + openYoutubeTrackPicker: () => {}, + cycleRuntimeOption: () => ({ ok: true }), + showMpvOsd: () => {}, + replayCurrentSubtitle: () => {}, + playNextSubtitle: () => {}, + shiftSubDelayToAdjacentSubtitle: async () => {}, + sendMpvCommand: () => {}, + getMpvClient: () => null, + isMpvConnected: () => true, + hasRuntimeOptionsManager: () => true, + }, + handleMpvCommandFromIpcRuntime: () => {}, + runSubsyncManualFromIpc: async () => manualResult as never, + }, + main, + ankiJimaku, + registration: { + runtimeOptions: { + getRuntimeOptionsManager: () => null, + showMpvOsd: () => {}, + }, + main, + ankiJimaku, + registerIpcRuntimeServices: (params: unknown) => { + capturedRegistration.value = params; + }, + }, + registerIpcRuntimeServices: (params: unknown) => { + capturedRegistration.value = params; + }, + manualResult, + }; +} + +test('ipc runtime registers composed IPC handlers from explicit registration input', async () => { + const capturedRegistration = { value: null as unknown | null }; + const input = createBaseRuntimeInput(capturedRegistration); + + const runtime = createIpcRuntime({ + mpv: input.mpv, + registration: input.registration, + }); + + runtime.registerIpcRuntimeHandlers(); + + assert.ok(capturedRegistration.value); + const registration = capturedRegistration.value as { + runtimeOptions: { showMpvOsd: unknown }; + mainDeps: { + handleMpvCommand: unknown; + runSubsyncManual: (payload: unknown) => Promise; + }; + }; + assert.equal(registration.runtimeOptions.showMpvOsd !== undefined, true); + assert.equal(registration.mainDeps.handleMpvCommand instanceof Function, true); + assert.deepEqual( + await registration.mainDeps.runSubsyncManual({ payload: null } as never), + input.manualResult, + ); +}); + +test('ipc runtime builds grouped registration input from main state', async () => { + const capturedRegistration = { value: null as unknown | null }; + const input = createBaseRuntimeInput(capturedRegistration); + + const runtime = createIpcRuntimeFromMainState({ + mpv: input.mpv, + runtimeOptions: input.registration.runtimeOptions, + main: input.main, + ankiJimaku: input.ankiJimaku, + }); + + runtime.registerIpcRuntimeHandlers(); + + assert.ok(capturedRegistration.value); + const registration = capturedRegistration.value as { + runtimeOptions: { showMpvOsd: unknown }; + mainDeps: { + handleMpvCommand: unknown; + runSubsyncManual: (payload: unknown) => Promise; + }; + }; + assert.equal(registration.runtimeOptions.showMpvOsd !== undefined, true); + assert.equal(registration.mainDeps.handleMpvCommand instanceof Function, true); + assert.deepEqual( + await registration.mainDeps.runSubsyncManual({ payload: null } as never), + input.manualResult, + ); +}); diff --git a/src/main/ipc-runtime.ts b/src/main/ipc-runtime.ts index 4e5f7f6a..4a0bddb5 100644 --- a/src/main/ipc-runtime.ts +++ b/src/main/ipc-runtime.ts @@ -1,17 +1,15 @@ -import { - createIpcDepsRuntime, - registerAnkiJimakuIpcRuntime, - registerIpcHandlers, -} from '../core/services'; -import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc'; -import { - createAnkiJimakuIpcRuntimeServiceDeps, +import type { AnkiJimakuIpcRuntimeServiceDepsParams, - createMainIpcRuntimeServiceDeps, MainIpcRuntimeServiceDepsParams, - createRuntimeOptionsIpcDeps, RuntimeOptionsIpcDepsParams, } from './dependencies'; +import { createAnkiJimakuIpcRuntimeServiceDeps } from './dependencies'; +import { + handleMpvCommandFromIpcRuntime, + type MpvCommandFromIpcRuntimeDeps, +} from './ipc-mpv-command'; +import { registerIpcRuntimeServices } from './ipc-runtime-services'; +import { composeIpcRuntimeHandlers } from './runtime/composers/ipc-runtime-composer'; export interface RegisterIpcRuntimeServicesParams { runtimeOptions: RuntimeOptionsIpcDepsParams; @@ -19,28 +17,131 @@ export interface RegisterIpcRuntimeServicesParams { ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams; } -export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void { - registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params))); +export interface IpcRuntimeMainInput { + window: Pick< + RegisterIpcRuntimeServicesParams['mainDeps'], + | 'getMainWindow' + | 'getVisibleOverlayVisibility' + | 'focusMainWindow' + | 'onOverlayModalClosed' + | 'onOverlayModalOpened' + | 'onYoutubePickerResolve' + | 'openYomitanSettings' + | 'quitApp' + | 'toggleVisibleOverlay' + >; + subtitle: Pick< + RegisterIpcRuntimeServicesParams['mainDeps'], + | 'tokenizeCurrentSubtitle' + | 'getCurrentSubtitleRaw' + | 'getCurrentSubtitleAss' + | 'getSubtitleSidebarSnapshot' + | 'getPlaybackPaused' + | 'getSubtitlePosition' + | 'getSubtitleStyle' + | 'saveSubtitlePosition' + | 'getMecabTokenizer' + | 'getKeybindings' + | 'getConfiguredShortcuts' + | 'getStatsToggleKey' + | 'getMarkWatchedKey' + | 'getSecondarySubMode' + >; + controller: Pick< + RegisterIpcRuntimeServicesParams['mainDeps'], + 'getControllerConfig' | 'saveControllerConfig' | 'saveControllerPreference' + >; + runtime: Pick< + RegisterIpcRuntimeServicesParams['mainDeps'], + 'getMpvClient' | 'getAnkiConnectStatus' | 'getRuntimeOptions' | 'reportOverlayContentBounds' + > & + Partial>; + anilist: { + getStatus: RegisterIpcRuntimeServicesParams['mainDeps']['getAnilistStatus']; + clearToken: RegisterIpcRuntimeServicesParams['mainDeps']['clearAnilistToken']; + openSetup: RegisterIpcRuntimeServicesParams['mainDeps']['openAnilistSetup']; + getQueueStatus: RegisterIpcRuntimeServicesParams['mainDeps']['getAnilistQueueStatus']; + retryQueueNow: RegisterIpcRuntimeServicesParams['mainDeps']['retryAnilistQueueNow']; + }; + mining: { + appendClipboardVideoToQueue: RegisterIpcRuntimeServicesParams['mainDeps']['appendClipboardVideoToQueue']; + }; } -export function registerAnkiJimakuIpcRuntimeServices( - params: AnkiJimakuIpcRuntimeServiceDepsParams, -): void { - registerAnkiJimakuIpcRuntime( - createAnkiJimakuIpcRuntimeServiceDeps(params), - registerAnkiJimakuIpcHandlers, - ); +export interface IpcRuntimeRegistrationInput { + runtimeOptions: RuntimeOptionsIpcDepsParams; + main: IpcRuntimeMainInput; + ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams; + registerIpcRuntimeServices: (params: RegisterIpcRuntimeServicesParams) => void; } -export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void { - const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({ - getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager, - showMpvOsd: params.runtimeOptions.showMpvOsd, - }); - registerMainIpcRuntimeServices({ - ...params.mainDeps, - setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption, - cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption, - }); - registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps); +export interface IpcRuntimeInput { + mpv: { + mainDeps: MpvCommandFromIpcRuntimeDeps; + handleMpvCommandFromIpcRuntime: ( + command: (string | number)[], + deps: MpvCommandFromIpcRuntimeDeps, + ) => void; + runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual']; + }; + registration: IpcRuntimeRegistrationInput; +} + +export interface IpcRuntime { + registerIpcRuntimeHandlers: () => void; +} + +export interface IpcRuntimeFromMainStateInput { + mpv: { + mainDeps: MpvCommandFromIpcRuntimeDeps; + runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual']; + }; + runtimeOptions: RuntimeOptionsIpcDepsParams; + main: IpcRuntimeMainInput; + ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams; +} + +export function createIpcRuntime(input: IpcRuntimeInput): IpcRuntime { + const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ + mpvCommandMainDeps: input.mpv.mainDeps, + handleMpvCommandFromIpcRuntime: input.mpv.handleMpvCommandFromIpcRuntime, + runSubsyncManualFromIpc: input.mpv.runSubsyncManualFromIpc, + registration: { + runtimeOptions: input.registration.runtimeOptions, + mainDeps: { + ...input.registration.main.window, + ...input.registration.main.subtitle, + ...input.registration.main.controller, + ...input.registration.main.runtime, + getAnilistStatus: input.registration.main.anilist.getStatus, + clearAnilistToken: input.registration.main.anilist.clearToken, + openAnilistSetup: input.registration.main.anilist.openSetup, + getAnilistQueueStatus: input.registration.main.anilist.getQueueStatus, + retryAnilistQueueNow: input.registration.main.anilist.retryQueueNow, + appendClipboardVideoToQueue: input.registration.main.mining.appendClipboardVideoToQueue, + }, + ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps(input.registration.ankiJimaku), + registerIpcRuntimeServices: (params) => input.registration.registerIpcRuntimeServices(params), + }, + }); + + return { + registerIpcRuntimeHandlers, + }; +} + +export function createIpcRuntimeFromMainState(input: IpcRuntimeFromMainStateInput): IpcRuntime { + return createIpcRuntime({ + mpv: { + mainDeps: input.mpv.mainDeps, + handleMpvCommandFromIpcRuntime, + runSubsyncManualFromIpc: input.mpv.runSubsyncManualFromIpc, + }, + registration: { + runtimeOptions: input.runtimeOptions, + main: input.main, + ankiJimaku: input.ankiJimaku, + registerIpcRuntimeServices, + }, + }); } diff --git a/src/main/jellyfin-runtime-coordinator.ts b/src/main/jellyfin-runtime-coordinator.ts new file mode 100644 index 00000000..44dc98af --- /dev/null +++ b/src/main/jellyfin-runtime-coordinator.ts @@ -0,0 +1,160 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +import { BrowserWindow } from 'electron'; + +import { DEFAULT_CONFIG } from '../config'; +import { + JellyfinRemoteSessionService, + authenticateWithPasswordRuntime, + jellyfinTicksToSecondsRuntime, + listJellyfinItemsRuntime, + listJellyfinLibrariesRuntime, + listJellyfinSubtitleTracksRuntime, + resolveJellyfinPlaybackPlanRuntime, + sendMpvCommandRuntime, +} from '../core/services'; +import type { MpvIpcClient } from '../core/services/mpv'; +import type { JellyfinSetupWindowLike } from './jellyfin-runtime'; +import { createJellyfinRuntime } from './jellyfin-runtime'; + +export interface JellyfinRuntimeCoordinatorInput { + getResolvedConfig: Parameters[0]['getResolvedConfig']; + configService: { + patchRawConfig: Parameters[0]['patchRawConfig']; + }; + tokenStore: Parameters[0]['tokenStore']; + platform: NodeJS.Platform; + execPath: string; + defaultMpvLogPath: string; + defaultMpvArgs: readonly string[]; + connectTimeoutMs: number; + autoLaunchTimeoutMs: number; + langPref: string; + progressIntervalMs: number; + ticksPerSecond: number; + appState: { + mpvSocketPath: string; + mpvClient: MpvIpcClient | null; + jellyfinSetupWindow: BrowserWindow | null; + }; + actions: { + createMpvClient: () => MpvIpcClient; + applyJellyfinMpvDefaults: (client: MpvIpcClient) => void; + showMpvOsd: (message: string) => void; + }; + logger: { + info: (message: string) => void; + warn: (message: string, details?: unknown) => void; + debug: (message: string, details?: unknown) => void; + error: (message: string, error?: unknown) => void; + }; +} + +export function createJellyfinRuntimeCoordinator(input: JellyfinRuntimeCoordinatorInput) { + return createJellyfinRuntime({ + getResolvedConfig: () => input.getResolvedConfig(), + getEnv: (name) => process.env[name], + patchRawConfig: (patch) => { + input.configService.patchRawConfig(patch); + }, + defaultJellyfinConfig: DEFAULT_CONFIG.jellyfin, + tokenStore: input.tokenStore, + platform: input.platform, + execPath: input.execPath, + defaultMpvLogPath: input.defaultMpvLogPath, + defaultMpvArgs: [...input.defaultMpvArgs], + connectTimeoutMs: input.connectTimeoutMs, + autoLaunchTimeoutMs: input.autoLaunchTimeoutMs, + langPref: input.langPref, + progressIntervalMs: input.progressIntervalMs, + ticksPerSecond: input.ticksPerSecond, + getMpvSocketPath: () => input.appState.mpvSocketPath, + getMpvClient: () => input.appState.mpvClient, + setMpvClient: (client) => { + input.appState.mpvClient = client as MpvIpcClient | null; + }, + createMpvClient: () => input.actions.createMpvClient(), + sendMpvCommand: (client, command) => sendMpvCommandRuntime(client as MpvIpcClient, command), + applyJellyfinMpvDefaults: (client) => + input.actions.applyJellyfinMpvDefaults(client as MpvIpcClient), + showMpvOsd: (message) => input.actions.showMpvOsd(message), + removeSocketPath: (socketPath) => { + fs.rmSync(socketPath, { force: true }); + }, + spawnMpv: (args) => + spawn('mpv', args, { + detached: true, + stdio: 'ignore', + }), + wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), + authenticateWithPassword: (serverUrl, username, password, clientInfo) => + authenticateWithPasswordRuntime( + serverUrl, + username, + password, + clientInfo as Parameters[3], + ), + listJellyfinLibraries: (session, clientInfo) => + listJellyfinLibrariesRuntime( + session as Parameters[0], + clientInfo as Parameters[1], + ), + listJellyfinItems: (session, clientInfo, params) => + listJellyfinItemsRuntime( + session as Parameters[0], + clientInfo as Parameters[1], + params as Parameters[2], + ), + listJellyfinSubtitleTracks: (session, clientInfo, itemId) => + listJellyfinSubtitleTracksRuntime( + session as Parameters[0], + clientInfo as Parameters[1], + itemId, + ), + writeJellyfinPreviewAuth: (responsePath, payload) => { + fs.mkdirSync(path.dirname(responsePath), { recursive: true }); + fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf-8'); + }, + resolvePlaybackPlan: (params) => + resolveJellyfinPlaybackPlanRuntime( + (params as { session: Parameters[0] }).session, + (params as { clientInfo: Parameters[1] }) + .clientInfo, + ( + params as { + jellyfinConfig: ReturnType< + JellyfinRuntimeCoordinatorInput['getResolvedConfig'] + >['jellyfin']; + } + ).jellyfinConfig, + { + itemId: (params as { itemId: string }).itemId, + audioStreamIndex: + (params as { audioStreamIndex?: number | null }).audioStreamIndex ?? undefined, + subtitleStreamIndex: + (params as { subtitleStreamIndex?: number | null }).subtitleStreamIndex ?? undefined, + }, + ), + convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks), + createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options as never), + defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId, + defaultClientName: DEFAULT_CONFIG.jellyfin.clientName, + defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion, + createBrowserWindow: (options) => { + const window = new BrowserWindow(options); + input.appState.jellyfinSetupWindow = window; + window.on('closed', () => { + input.appState.jellyfinSetupWindow = null; + }); + return window as unknown as JellyfinSetupWindowLike; + }, + encodeURIComponent: (value) => encodeURIComponent(value), + logInfo: (message) => input.logger.info(message), + logWarn: (message, details) => input.logger.warn(message, details), + logDebug: (message, details) => input.logger.debug(message, details), + logError: (message, error) => input.logger.error(message, error), + now: () => Date.now(), + }); +} diff --git a/src/main/jellyfin-runtime.test.ts b/src/main/jellyfin-runtime.test.ts new file mode 100644 index 00000000..eb2bd41a --- /dev/null +++ b/src/main/jellyfin-runtime.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { JellyfinRuntimeInput } from './jellyfin-runtime'; +import { createJellyfinRuntime } from './jellyfin-runtime'; + +test('jellyfin runtime reuses existing setup window', () => { + const calls: string[] = []; + let windowCount = 0; + + const runtime = createJellyfinRuntime({ + getResolvedConfig: () => + ({ + jellyfin: { + enabled: true, + serverUrl: 'https://media.example', + username: 'demo', + }, + }) as ReturnType, + getEnv: () => undefined, + patchRawConfig: () => {}, + defaultJellyfinConfig: { + enabled: false, + serverUrl: '', + username: '', + } as JellyfinRuntimeInput['defaultJellyfinConfig'], + tokenStore: { + loadSession: () => null, + saveSession: () => {}, + clearSession: () => {}, + }, + platform: 'linux', + execPath: '/usr/bin/electron', + defaultMpvLogPath: '/tmp/mpv.log', + defaultMpvArgs: ['--idle=yes'], + connectTimeoutMs: 1000, + autoLaunchTimeoutMs: 1000, + langPref: 'ja,en', + progressIntervalMs: 3000, + ticksPerSecond: 10_000_000, + getMpvSocketPath: () => '/tmp/mpv.sock', + getMpvClient: () => null, + setMpvClient: () => {}, + createMpvClient: () => ({}), + sendMpvCommand: () => {}, + applyJellyfinMpvDefaults: () => {}, + showMpvOsd: () => {}, + removeSocketPath: () => {}, + spawnMpv: () => ({}), + wait: async () => {}, + authenticateWithPassword: async () => { + throw new Error('not used'); + }, + listJellyfinLibraries: async () => [], + listJellyfinItems: async () => [], + listJellyfinSubtitleTracks: async () => [], + writeJellyfinPreviewAuth: () => {}, + resolvePlaybackPlan: async () => ({}), + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + createRemoteSessionService: () => ({}), + defaultDeviceId: 'device', + defaultClientName: 'SubMiner', + defaultClientVersion: '1.0.0', + createBrowserWindow: () => { + windowCount += 1; + return { + webContents: { + on: () => {}, + }, + loadURL: () => { + calls.push('loadURL'); + }, + on: () => {}, + focus: () => { + calls.push('focus'); + }, + close: () => {}, + isDestroyed: () => false, + }; + }, + encodeURIComponent: (value) => encodeURIComponent(value), + logInfo: () => {}, + logWarn: () => {}, + logDebug: () => {}, + logError: () => {}, + }); + + runtime.openJellyfinSetupWindow(); + runtime.openJellyfinSetupWindow(); + + assert.equal(windowCount, 1); + assert.deepEqual(calls, ['loadURL', 'focus']); + assert.equal(runtime.getQuitOnDisconnectArmed(), false); + assert.ok(runtime.getSetupWindow()); +}); diff --git a/src/main/jellyfin-runtime.ts b/src/main/jellyfin-runtime.ts new file mode 100644 index 00000000..a8fc672f --- /dev/null +++ b/src/main/jellyfin-runtime.ts @@ -0,0 +1,423 @@ +import type { CliArgs } from '../cli/args'; +import { + buildJellyfinSetupFormHtml, + getConfiguredJellyfinSession, + parseJellyfinSetupSubmissionUrl, +} from './runtime/domains/jellyfin'; +import { + composeJellyfinRuntimeHandlers, + type JellyfinRuntimeComposerOptions, +} from './runtime/composers/jellyfin-runtime-composer'; +import { createCreateJellyfinSetupWindowHandler } from './runtime/setup-window-factory'; + +// --------------------------------------------------------------------------- +// Helper: extract each dep-block's type from the composer options. +// --------------------------------------------------------------------------- + +type Deps = JellyfinRuntimeComposerOptions[K]; + +// --------------------------------------------------------------------------- +// Resolved-config shape (extracted from composer). +// --------------------------------------------------------------------------- + +type ResolvedConfigShape = + Deps<'getResolvedJellyfinConfigMainDeps'> extends { + getResolvedConfig: () => infer R; + } + ? R + : never; +type JellyfinConfigShape = ResolvedConfigShape extends { jellyfin: infer J } ? J : never; + +/** Stored-session shape (what the token store persists). */ +type StoredSessionShape = { accessToken: string; userId: string }; + +// --------------------------------------------------------------------------- +// Public interfaces +// --------------------------------------------------------------------------- + +export interface JellyfinSessionStoreLike { + loadSession: () => StoredSessionShape | null | undefined; + saveSession: (session: StoredSessionShape) => void; + clearSession: () => void; +} + +export interface JellyfinSetupWindowLike { + webContents: { + on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void; + }; + loadURL: (url: string) => Promise | void; + on: (event: 'closed', handler: () => void) => void; + focus: () => void; + close: () => void; + isDestroyed: () => boolean; +} + +/** + * Input for createJellyfinRuntime. + * + * Fields whose types vary across handler files (MpvClient, Session, ClientInfo, + * RemoteSessionService, etc.) are typed as `unknown`. The factory body bridges + * these to the handler-specific structural types via per-dep-block type + * annotations (`Deps`) with targeted `as` casts on the individual + * function references. This keeps the public-facing input surface simple and + * avoids 7+ generic type parameters that previously required `as never` casts. + */ +export interface JellyfinRuntimeInput< + TSetupWindow extends JellyfinSetupWindowLike = JellyfinSetupWindowLike, +> { + getResolvedConfig: () => ResolvedConfigShape; + getEnv: (name: string) => string | undefined; + patchRawConfig: (patch: unknown) => void; + defaultJellyfinConfig: JellyfinConfigShape; + tokenStore: JellyfinSessionStoreLike; + platform: NodeJS.Platform; + execPath: string; + defaultMpvLogPath: string; + defaultMpvArgs: readonly string[]; + connectTimeoutMs: number; + autoLaunchTimeoutMs: number; + langPref: string; + progressIntervalMs: number; + ticksPerSecond: number; + getMpvSocketPath: () => string; + getMpvClient: () => unknown; + setMpvClient: (client: unknown) => void; + createMpvClient: () => unknown; + sendMpvCommand: (client: unknown, command: Array) => void; + applyJellyfinMpvDefaults: (client: unknown) => void; + showMpvOsd: (message: string) => void; + removeSocketPath: (socketPath: string) => void; + spawnMpv: (args: string[]) => unknown; + wait: (delayMs: number) => Promise; + authenticateWithPassword: ( + serverUrl: string, + username: string, + password: string, + clientInfo: unknown, + ) => Promise; + listJellyfinLibraries: (session: unknown, clientInfo: unknown) => Promise; + listJellyfinItems: (session: unknown, clientInfo: unknown, params: unknown) => Promise; + listJellyfinSubtitleTracks: ( + session: unknown, + clientInfo: unknown, + itemId: string, + ) => Promise; + writeJellyfinPreviewAuth: (responsePath: string, payload: unknown) => void; + resolvePlaybackPlan: (params: unknown) => Promise; + convertTicksToSeconds: (ticks: number) => number; + createRemoteSessionService: (options: unknown) => unknown; + defaultDeviceId: string; + defaultClientName: string; + defaultClientVersion: string; + createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TSetupWindow; + encodeURIComponent: (value: string) => string; + logInfo: (message: string) => void; + logWarn: (message: string, details?: unknown) => void; + logDebug: (message: string, details?: unknown) => void; + logError: (message: string, error: unknown) => void; + now?: () => number; + schedule?: (callback: () => void, delayMs: number) => void; +} + +export interface JellyfinRuntime< + TSetupWindow extends JellyfinSetupWindowLike = JellyfinSetupWindowLike, +> { + getResolvedJellyfinConfig: () => JellyfinConfigShape; + reportJellyfinRemoteProgress: (forceImmediate?: boolean) => Promise; + reportJellyfinRemoteStopped: () => Promise; + startJellyfinRemoteSession: () => Promise; + stopJellyfinRemoteSession: () => Promise; + runJellyfinCommand: (args: CliArgs) => Promise; + openJellyfinSetupWindow: () => void; + getQuitOnDisconnectArmed: () => boolean; + clearQuitOnDisconnectArm: () => void; + getRemoteSession: () => unknown; + getSetupWindow: () => TSetupWindow | null; +} + +export function createJellyfinRuntime( + input: JellyfinRuntimeInput, +): JellyfinRuntime { + const now = input.now ?? Date.now; + const schedule = + input.schedule ?? + ((callback: () => void, delayMs: number) => { + setTimeout(callback, delayMs); + }); + + let playQuitOnDisconnectArmed = false; + let activePlayback: unknown = null; + let lastProgressAtMs = 0; + let mpvAutoLaunchInFlight: Promise | null = null; + let remoteSession: unknown = null; + let setupWindow: TSetupWindow | null = null; + + // Each dep block is typed with Deps so TypeScript verifies structural + // compatibility with the composer. The `as Deps[field]` casts on + // function references bridge `unknown`-typed input methods to the + // handler-specific structural types. This replaces 23 `as never` casts + // with targeted, auditable type assertions. + + const getResolvedJellyfinConfigMainDeps: Deps<'getResolvedJellyfinConfigMainDeps'> = { + getResolvedConfig: () => input.getResolvedConfig(), + loadStoredSession: () => input.tokenStore.loadSession(), + getEnv: (name) => input.getEnv(name), + }; + + const getJellyfinClientInfoMainDeps: Deps<'getJellyfinClientInfoMainDeps'> = { + getResolvedJellyfinConfig: () => input.getResolvedConfig().jellyfin, + getDefaultJellyfinConfig: () => input.defaultJellyfinConfig, + }; + + const waitForMpvConnectedMainDeps: Deps<'waitForMpvConnectedMainDeps'> = { + getMpvClient: input.getMpvClient as Deps<'waitForMpvConnectedMainDeps'>['getMpvClient'], + now, + sleep: (delayMs) => input.wait(delayMs), + }; + + const launchMpvIdleForJellyfinPlaybackMainDeps: Deps<'launchMpvIdleForJellyfinPlaybackMainDeps'> = + { + getSocketPath: () => input.getMpvSocketPath(), + platform: input.platform, + execPath: input.execPath, + defaultMpvLogPath: input.defaultMpvLogPath, + defaultMpvArgs: input.defaultMpvArgs, + removeSocketPath: (socketPath) => input.removeSocketPath(socketPath), + spawnMpv: input.spawnMpv as Deps<'launchMpvIdleForJellyfinPlaybackMainDeps'>['spawnMpv'], + logWarn: (message, error) => input.logWarn(message, error), + logInfo: (message) => input.logInfo(message), + }; + + const ensureMpvConnectedForJellyfinPlaybackMainDeps: Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'> = + { + getMpvClient: + input.getMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['getMpvClient'], + setMpvClient: + input.setMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['setMpvClient'], + createMpvClient: + input.createMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['createMpvClient'], + getAutoLaunchInFlight: () => mpvAutoLaunchInFlight, + setAutoLaunchInFlight: (promise) => { + mpvAutoLaunchInFlight = promise; + }, + connectTimeoutMs: input.connectTimeoutMs, + autoLaunchTimeoutMs: input.autoLaunchTimeoutMs, + }; + + const preloadJellyfinExternalSubtitlesMainDeps: Deps<'preloadJellyfinExternalSubtitlesMainDeps'> = + { + listJellyfinSubtitleTracks: + input.listJellyfinSubtitleTracks as Deps<'preloadJellyfinExternalSubtitlesMainDeps'>['listJellyfinSubtitleTracks'], + getMpvClient: + input.getMpvClient as Deps<'preloadJellyfinExternalSubtitlesMainDeps'>['getMpvClient'], + sendMpvCommand: (command) => input.sendMpvCommand(input.getMpvClient(), command), + wait: (delayMs) => input.wait(delayMs), + logDebug: (message, error) => input.logDebug(message, error), + }; + + const playJellyfinItemInMpvMainDeps: Deps<'playJellyfinItemInMpvMainDeps'> = { + getMpvClient: input.getMpvClient as Deps<'playJellyfinItemInMpvMainDeps'>['getMpvClient'], + resolvePlaybackPlan: + input.resolvePlaybackPlan as Deps<'playJellyfinItemInMpvMainDeps'>['resolvePlaybackPlan'], + applyJellyfinMpvDefaults: + input.applyJellyfinMpvDefaults as Deps<'playJellyfinItemInMpvMainDeps'>['applyJellyfinMpvDefaults'], + sendMpvCommand: (command) => input.sendMpvCommand(input.getMpvClient(), command), + armQuitOnDisconnect: () => { + playQuitOnDisconnectArmed = false; + schedule(() => { + playQuitOnDisconnectArmed = true; + }, 3000); + }, + schedule: (callback, delayMs) => { + schedule(callback, delayMs); + }, + convertTicksToSeconds: (ticks) => input.convertTicksToSeconds(ticks), + setActivePlayback: (state) => { + activePlayback = state; + }, + setLastProgressAtMs: (value) => { + lastProgressAtMs = value; + }, + reportPlaying: (payload) => { + const session = remoteSession as { reportPlaying?: (payload: unknown) => unknown } | null; + if (typeof session?.reportPlaying === 'function') { + void session.reportPlaying(payload); + } + }, + showMpvOsd: (message) => input.showMpvOsd(message), + }; + + const remoteComposerBase: Omit, 'getConfiguredSession'> = { + logWarn: (message) => input.logWarn(message), + getMpvClient: input.getMpvClient as Deps<'remoteComposerOptions'>['getMpvClient'], + sendMpvCommand: input.sendMpvCommand as Deps<'remoteComposerOptions'>['sendMpvCommand'], + jellyfinTicksToSeconds: (ticks) => input.convertTicksToSeconds(ticks), + getActivePlayback: () => + activePlayback as ReturnType['getActivePlayback']>, + clearActivePlayback: () => { + activePlayback = null; + }, + getSession: () => remoteSession as ReturnType['getSession']>, + getNow: now, + getLastProgressAtMs: () => lastProgressAtMs, + setLastProgressAtMs: (value) => { + lastProgressAtMs = value; + }, + progressIntervalMs: input.progressIntervalMs, + ticksPerSecond: input.ticksPerSecond, + logDebug: (message, error) => input.logDebug(message, error), + }; + + const handleJellyfinAuthCommandsMainDeps: Deps<'handleJellyfinAuthCommandsMainDeps'> = { + patchRawConfig: (patch) => input.patchRawConfig(patch), + authenticateWithPassword: + input.authenticateWithPassword as Deps<'handleJellyfinAuthCommandsMainDeps'>['authenticateWithPassword'], + saveStoredSession: (session) => input.tokenStore.saveSession(session as StoredSessionShape), + clearStoredSession: () => input.tokenStore.clearSession(), + logInfo: (message) => input.logInfo(message), + }; + + const handleJellyfinListCommandsMainDeps: Deps<'handleJellyfinListCommandsMainDeps'> = { + listJellyfinLibraries: + input.listJellyfinLibraries as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinLibraries'], + listJellyfinItems: + input.listJellyfinItems as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinItems'], + listJellyfinSubtitleTracks: + input.listJellyfinSubtitleTracks as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinSubtitleTracks'], + writeJellyfinPreviewAuth: (responsePath, payload) => + input.writeJellyfinPreviewAuth(responsePath, payload), + logInfo: (message) => input.logInfo(message), + }; + + const handleJellyfinPlayCommandMainDeps: Deps<'handleJellyfinPlayCommandMainDeps'> = { + logWarn: (message) => input.logWarn(message), + }; + + const handleJellyfinRemoteAnnounceCommandMainDeps: Deps<'handleJellyfinRemoteAnnounceCommandMainDeps'> = + { + getRemoteSession: () => + remoteSession as ReturnType< + Deps<'handleJellyfinRemoteAnnounceCommandMainDeps'>['getRemoteSession'] + >, + logInfo: (message) => input.logInfo(message), + logWarn: (message) => input.logWarn(message), + }; + + const startJellyfinRemoteSessionMainDeps: Deps<'startJellyfinRemoteSessionMainDeps'> = { + getCurrentSession: () => + remoteSession as ReturnType['getCurrentSession']>, + setCurrentSession: (session) => { + remoteSession = session; + }, + createRemoteSessionService: + input.createRemoteSessionService as Deps<'startJellyfinRemoteSessionMainDeps'>['createRemoteSessionService'], + defaultDeviceId: input.defaultDeviceId, + defaultClientName: input.defaultClientName, + defaultClientVersion: input.defaultClientVersion, + logInfo: (message) => input.logInfo(message), + logWarn: (message, details) => input.logWarn(message, details), + }; + + const stopJellyfinRemoteSessionMainDeps: Deps<'stopJellyfinRemoteSessionMainDeps'> = { + getCurrentSession: () => + remoteSession as ReturnType['getCurrentSession']>, + setCurrentSession: (session) => { + remoteSession = session; + }, + clearActivePlayback: () => { + activePlayback = null; + }, + }; + + const runJellyfinCommandMainDeps: Deps<'runJellyfinCommandMainDeps'> = { + defaultServerUrl: input.defaultJellyfinConfig.serverUrl, + }; + + const maybeFocusExistingJellyfinSetupWindowMainDeps: Deps<'maybeFocusExistingJellyfinSetupWindowMainDeps'> = + { + getSetupWindow: () => setupWindow, + }; + + const openJellyfinSetupWindowMainDeps: Deps<'openJellyfinSetupWindowMainDeps'> = { + createSetupWindow: createCreateJellyfinSetupWindowHandler({ + createBrowserWindow: (options) => input.createBrowserWindow(options), + }), + buildSetupFormHtml: (defaultServer, defaultUser) => + buildJellyfinSetupFormHtml(defaultServer, defaultUser), + parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), + authenticateWithPassword: + input.authenticateWithPassword as Deps<'openJellyfinSetupWindowMainDeps'>['authenticateWithPassword'], + saveStoredSession: (session) => input.tokenStore.saveSession(session as StoredSessionShape), + patchJellyfinConfig: (session) => { + const jellyfinSession = session as { serverUrl?: string; username?: string }; + input.patchRawConfig({ + jellyfin: { + enabled: true, + serverUrl: jellyfinSession.serverUrl, + username: jellyfinSession.username, + }, + }); + }, + logInfo: (message) => input.logInfo(message), + logError: (message, error) => input.logError(message, error), + showMpvOsd: (message) => input.showMpvOsd(message), + clearSetupWindow: () => { + setupWindow = null; + }, + setSetupWindow: (window) => { + setupWindow = window as TSetupWindow | null; + }, + encodeURIComponent: (value) => input.encodeURIComponent(value), + }; + + const runtime = composeJellyfinRuntimeHandlers({ + getResolvedJellyfinConfigMainDeps, + getJellyfinClientInfoMainDeps, + waitForMpvConnectedMainDeps, + launchMpvIdleForJellyfinPlaybackMainDeps, + ensureMpvConnectedForJellyfinPlaybackMainDeps, + preloadJellyfinExternalSubtitlesMainDeps, + playJellyfinItemInMpvMainDeps, + remoteComposerOptions: { + ...remoteComposerBase, + getConfiguredSession: () => getConfiguredJellyfinSession(runtime.getResolvedJellyfinConfig()), + }, + handleJellyfinAuthCommandsMainDeps, + handleJellyfinListCommandsMainDeps, + handleJellyfinPlayCommandMainDeps, + handleJellyfinRemoteAnnounceCommandMainDeps, + startJellyfinRemoteSessionMainDeps, + stopJellyfinRemoteSessionMainDeps, + runJellyfinCommandMainDeps, + maybeFocusExistingJellyfinSetupWindowMainDeps, + openJellyfinSetupWindowMainDeps, + }); + + return { + getResolvedJellyfinConfig: () => runtime.getResolvedJellyfinConfig(), + reportJellyfinRemoteProgress: async (forceImmediate) => { + await runtime.reportJellyfinRemoteProgress(forceImmediate); + }, + reportJellyfinRemoteStopped: async () => { + await runtime.reportJellyfinRemoteStopped(); + }, + startJellyfinRemoteSession: async () => { + await runtime.startJellyfinRemoteSession(); + }, + stopJellyfinRemoteSession: async () => { + await runtime.stopJellyfinRemoteSession(); + }, + runJellyfinCommand: async (args) => { + await runtime.runJellyfinCommand(args); + }, + openJellyfinSetupWindow: () => { + runtime.openJellyfinSetupWindow(); + }, + getQuitOnDisconnectArmed: () => playQuitOnDisconnectArmed, + clearQuitOnDisconnectArm: () => { + playQuitOnDisconnectArmed = false; + }, + getRemoteSession: () => remoteSession, + getSetupWindow: () => setupWindow, + }; +} diff --git a/src/main/jlpt-runtime.ts b/src/main/jlpt-runtime.ts index f2af3556..965eb05a 100644 --- a/src/main/jlpt-runtime.ts +++ b/src/main/jlpt-runtime.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import type { JlptLevel } from '../types'; -import { createJlptVocabularyLookup } from '../core/services'; +import { createJlptVocabularyLookup } from '../core/services/jlpt-vocab'; export interface JlptDictionarySearchPathDeps { getDictionaryRoots: () => string[]; diff --git a/src/main/main-boot-runtime.ts b/src/main/main-boot-runtime.ts new file mode 100644 index 00000000..174bb8c8 --- /dev/null +++ b/src/main/main-boot-runtime.ts @@ -0,0 +1,169 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { BrowserWindow } from 'electron'; + +import { createAnilistTokenStore } from '../core/services/anilist/anilist-token-store'; +import { createJellyfinTokenStore } from '../core/services/jellyfin-token-store'; +import { createAnilistUpdateQueue } from '../core/services/anilist/anilist-update-queue'; +import { + SubtitleWebSocket, + createOverlayContentMeasurementStore, + createOverlayManager, +} from '../core/services'; +import { ConfigService } from '../config'; +import { resolveConfigDir } from '../config/path-resolution'; +import { createAppState } from './state'; +import { + createMainBootServices, + type AppLifecycleShape, + type MainBootServicesResult, +} from './boot/services'; +import { createLogger } from '../logger'; +import { createMainRuntimeRegistry } from './runtime/registry'; +import { createOverlayModalInputState } from './runtime/overlay-modal-input-state'; +import { createOverlayModalRuntimeService } from './overlay-runtime'; +import { buildConfigParseErrorDetails, failStartupFromConfig } from './config-validation'; +import { + registerSecondInstanceHandlerEarly, + requestSingleInstanceLockEarly, + shouldBypassSingleInstanceLockForArgv, +} from './early-single-instance'; +import { + createBuildOverlayContentMeasurementStoreMainDepsHandler, + createBuildOverlayModalRuntimeMainDepsHandler, +} from './runtime/domains/overlay'; +import type { WindowGeometry } from '../types'; + +export type MainBootRuntime = MainBootServicesResult< + ConfigService, + ReturnType, + ReturnType, + ReturnType, + SubtitleWebSocket, + ReturnType, + ReturnType, + ReturnType, + ReturnType, + ReturnType, + ReturnType, + ReturnType, + AppLifecycleShape +>; + +export interface MainBootRuntimeInput { + platform: NodeJS.Platform; + argv: string[]; + appDataDir: string | undefined; + xdgConfigHome: string | undefined; + homeDir: string; + defaultMpvLogFile: string; + envMpvLog: string | undefined; + defaultTexthookerPort: number; + getDefaultSocketPath: () => string; + app: { + setPath: (name: string, value: string) => void; + quit: () => void; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures + on: Function; + whenReady: () => Promise; + }; + dialog: { + showErrorBox: (title: string, details: string) => void; + }; + overlay: { + getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void; + getSyncOverlayVisibilityForModal: () => () => void; + createModalWindow: () => BrowserWindow; + getOverlayGeometry: () => WindowGeometry; + }; + notifications: { + notifyAnilistTokenStoreWarning: (message: string) => void; + requestAppQuit: () => void; + }; +} + +export function createMainBootRuntime(input: MainBootRuntimeInput): MainBootRuntime { + return createMainBootServices({ + platform: input.platform, + argv: input.argv, + appDataDir: input.appDataDir, + xdgConfigHome: input.xdgConfigHome, + homeDir: input.homeDir, + defaultMpvLogFile: input.defaultMpvLogFile, + envMpvLog: input.envMpvLog, + defaultTexthookerPort: input.defaultTexthookerPort, + getDefaultSocketPath: () => input.getDefaultSocketPath(), + resolveConfigDir, + existsSync: (targetPath) => fs.existsSync(targetPath), + mkdirSync: (targetPath, options) => { + fs.mkdirSync(targetPath, options); + }, + joinPath: (...parts) => path.join(...parts), + app: input.app, + shouldBypassSingleInstanceLock: () => shouldBypassSingleInstanceLockForArgv(input.argv), + requestSingleInstanceLockEarly: () => requestSingleInstanceLockEarly(input.app as never), + registerSecondInstanceHandlerEarly: (listener) => { + registerSecondInstanceHandlerEarly(input.app as never, listener); + }, + onConfigStartupParseError: (error) => { + failStartupFromConfig( + 'SubMiner config parse error', + buildConfigParseErrorDetails(error.path, error.parseError), + { + logError: (details) => console.error(details), + showErrorBox: (title, details) => input.dialog.showErrorBox(title, details), + quit: () => input.notifications.requestAppQuit(), + }, + ); + }, + createConfigService: (configDir) => new ConfigService(configDir), + createAnilistTokenStore: (targetPath) => + createAnilistTokenStore(targetPath, { + info: (message: string) => console.info(message), + warn: (message: string, details?: unknown) => console.warn(message, details), + error: (message: string, details?: unknown) => console.error(message, details), + warnUser: (message: string) => input.notifications.notifyAnilistTokenStoreWarning(message), + }), + createJellyfinTokenStore: (targetPath) => + createJellyfinTokenStore(targetPath, { + info: (message: string) => console.info(message), + warn: (message: string, details?: unknown) => console.warn(message, details), + error: (message: string, details?: unknown) => console.error(message, details), + }), + createAnilistUpdateQueue: (targetPath) => + createAnilistUpdateQueue(targetPath, { + info: (message: string) => console.info(message), + warn: (message: string, details?: unknown) => console.warn(message, details), + error: (message: string, details?: unknown) => console.error(message, details), + }), + createSubtitleWebSocket: () => new SubtitleWebSocket(), + createLogger, + createMainRuntimeRegistry, + createOverlayManager, + createOverlayModalInputState, + createOverlayContentMeasurementStore: ({ logger }) => + createOverlayContentMeasurementStore( + createBuildOverlayContentMeasurementStoreMainDepsHandler({ + now: () => Date.now(), + warn: (message: string) => logger.warn(message), + })(), + ), + getSyncOverlayShortcutsForModal: () => input.overlay.getSyncOverlayShortcutsForModal(), + getSyncOverlayVisibilityForModal: () => input.overlay.getSyncOverlayVisibilityForModal(), + createOverlayModalRuntime: ({ overlayManager, overlayModalInputState }) => + createOverlayModalRuntimeService( + createBuildOverlayModalRuntimeMainDepsHandler({ + getMainWindow: () => overlayManager.getMainWindow(), + getModalWindow: () => overlayManager.getModalWindow(), + createModalWindow: () => input.overlay.createModalWindow(), + getModalGeometry: () => input.overlay.getOverlayGeometry(), + setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry), + })(), + { + onModalStateChange: (isActive: boolean) => + overlayModalInputState.handleModalInputStateChange(isActive), + }, + ), + createAppState, + }) as MainBootRuntime; +} diff --git a/src/main/main-boot-services-bootstrap.test.ts b/src/main/main-boot-services-bootstrap.test.ts new file mode 100644 index 00000000..2eef689d --- /dev/null +++ b/src/main/main-boot-services-bootstrap.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; + +import { createMainBootServicesBootstrap } from './main-boot-services-bootstrap'; + +test('main boot services bootstrap composes grouped inputs into boot services', () => { + const calls: string[] = []; + const modalWindow = {} as never; + const overlayManager = { + getModalWindow: () => modalWindow, + }; + type AppStateStub = { + kind: 'app-state'; + input: { + mpvSocketPath: string; + texthookerPort: number; + }; + }; + type OverlayModalRuntimeStub = { + kind: 'overlay-modal-runtime'; + }; + + const overlayModalInputState = { + kind: 'overlay-modal-input-state', + handleModalInputStateChange: (isActive: boolean) => { + calls.push(`modal-state:${String(isActive)}`); + }, + }; + const overlayModalInputStateParams: { + getModalWindow: () => unknown; + syncOverlayShortcutsForModal: (isActive: boolean) => void; + syncOverlayVisibilityForModal: () => void; + }[] = []; + const createOverlayModalInputState = (params: (typeof overlayModalInputStateParams)[number]) => { + overlayModalInputStateParams.push(params); + return overlayModalInputState as never; + }; + + const createOverlayModalRuntime = (params: { + onModalStateChange: (isActive: boolean) => void; + }) => { + calls.push(`modal:${String(params.onModalStateChange(true))}`); + return { kind: 'overlay-modal-runtime' } as OverlayModalRuntimeStub; + }; + + const boot = createMainBootServicesBootstrap({ + system: { + platform: 'darwin', + argv: ['node', 'main.js'], + appDataDir: '/tmp/app-data', + xdgConfigHome: '/tmp/xdg', + homeDir: '/Users/test', + defaultMpvLogFile: '/tmp/mpv.log', + envMpvLog: '', + defaultTexthookerPort: 5174, + getDefaultSocketPath: () => '/tmp/mpv.sock', + resolveConfigDir: () => '/tmp/config', + existsSync: () => true, + mkdirSync: () => undefined, + joinPath: (...parts: string[]) => path.posix.join(...parts), + app: { + setPath: () => undefined, + quit: () => undefined, + on: () => undefined, + whenReady: async () => undefined, + }, + }, + singleInstance: { + shouldBypassSingleInstanceLock: () => false, + requestSingleInstanceLockEarly: () => true, + registerSecondInstanceHandlerEarly: () => undefined, + onConfigStartupParseError: () => undefined, + }, + factories: { + createConfigService: () => ({ kind: 'config-service' }) as never, + createAnilistTokenStore: () => ({ kind: 'anilist-token-store' }) as never, + createJellyfinTokenStore: () => ({ kind: 'jellyfin-token-store' }) as never, + createAnilistUpdateQueue: () => ({ kind: 'anilist-update-queue' }) as never, + createSubtitleWebSocket: () => ({ kind: 'subtitle-websocket' }) as never, + createLogger: () => + ({ + warn: () => undefined, + info: () => undefined, + error: () => undefined, + }) as never, + createMainRuntimeRegistry: () => ({ kind: 'runtime-registry' }) as never, + createOverlayManager: () => overlayManager as never, + createOverlayModalInputState, + createOverlayContentMeasurementStore: () => ({ kind: 'overlay-content-store' }) as never, + getSyncOverlayShortcutsForModal: () => (isActive: boolean) => { + calls.push(`shortcuts:${String(isActive)}`); + }, + getSyncOverlayVisibilityForModal: () => () => { + calls.push('visibility'); + }, + createOverlayModalRuntime, + createAppState: (input) => ({ kind: 'app-state', input }) satisfies AppStateStub, + }, + }); + + assert.equal(boot.configDir, '/tmp/config'); + assert.equal(boot.userDataPath, '/tmp/config'); + assert.equal(boot.defaultImmersionDbPath, '/tmp/config/immersion.sqlite'); + assert.equal(boot.appState.input.mpvSocketPath, '/tmp/mpv.sock'); + assert.equal(boot.appState.input.texthookerPort, 5174); + assert.equal(overlayModalInputStateParams.length, 1); + assert.equal(overlayModalInputStateParams[0]?.getModalWindow(), modalWindow); + overlayModalInputStateParams[0]?.syncOverlayShortcutsForModal(true); + overlayModalInputStateParams[0]?.syncOverlayVisibilityForModal(); + assert.deepEqual(calls, ['modal-state:true', 'modal:undefined', 'shortcuts:true', 'visibility']); + assert.equal(boot.overlayManager, overlayManager); + assert.equal(boot.overlayModalRuntime.kind, 'overlay-modal-runtime'); +}); diff --git a/src/main/main-boot-services-bootstrap.ts b/src/main/main-boot-services-bootstrap.ts new file mode 100644 index 00000000..a9d6c67e --- /dev/null +++ b/src/main/main-boot-services-bootstrap.ts @@ -0,0 +1,173 @@ +import type { BrowserWindow } from 'electron'; + +import type { ConfigStartupParseError } from '../config'; +import { + createMainBootServices, + type MainBootServicesResult, + type OverlayModalInputStateShape, + type AppLifecycleShape, +} from './boot/services'; + +export interface MainBootServicesBootstrapInput< + TConfigService, + TAnilistTokenStore, + TJellyfinTokenStore, + TAnilistUpdateQueue, + TSubtitleWebSocket, + TLogger, + TRuntimeRegistry, + TOverlayManager, + TOverlayModalInputState, + TOverlayContentMeasurementStore, + TOverlayModalRuntime, + TAppState, + TAppLifecycleApp, +> { + system: { + platform: NodeJS.Platform; + argv: string[]; + appDataDir: string | undefined; + xdgConfigHome: string | undefined; + homeDir: string; + defaultMpvLogFile: string; + envMpvLog: string | undefined; + defaultTexthookerPort: number; + getDefaultSocketPath: () => string; + resolveConfigDir: (input: { + platform: NodeJS.Platform; + appDataDir: string | undefined; + xdgConfigHome: string | undefined; + homeDir: string; + existsSync: (targetPath: string) => boolean; + }) => string; + existsSync: (targetPath: string) => boolean; + mkdirSync: (targetPath: string, options: { recursive: true }) => void; + joinPath: (...parts: string[]) => string; + app: { + setPath: (name: string, value: string) => void; + quit: () => void; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures + on: Function; + whenReady: () => Promise; + }; + }; + singleInstance: { + shouldBypassSingleInstanceLock: () => boolean; + requestSingleInstanceLockEarly: () => boolean; + registerSecondInstanceHandlerEarly: ( + listener: (_event: unknown, argv: string[]) => void, + ) => void; + onConfigStartupParseError: (error: ConfigStartupParseError) => void; + }; + factories: { + createConfigService: (configDir: string) => TConfigService; + createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore; + createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore; + createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue; + createSubtitleWebSocket: () => TSubtitleWebSocket; + createLogger: (scope: string) => TLogger & { + warn: (message: string) => void; + info: (message: string) => void; + error: (message: string, details?: unknown) => void; + }; + createMainRuntimeRegistry: () => TRuntimeRegistry; + createOverlayManager: () => TOverlayManager; + createOverlayModalInputState: (params: { + getModalWindow: () => BrowserWindow | null; + syncOverlayShortcutsForModal: (isActive: boolean) => void; + syncOverlayVisibilityForModal: () => void; + }) => TOverlayModalInputState; + createOverlayContentMeasurementStore: (params: { + logger: TLogger; + }) => TOverlayContentMeasurementStore; + getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void; + getSyncOverlayVisibilityForModal: () => () => void; + createOverlayModalRuntime: (params: { + overlayManager: TOverlayManager; + overlayModalInputState: TOverlayModalInputState; + onModalStateChange: (isActive: boolean) => void; + }) => TOverlayModalRuntime; + createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => TAppState; + }; +} + +export function createMainBootServicesBootstrap< + TConfigService, + TAnilistTokenStore, + TJellyfinTokenStore, + TAnilistUpdateQueue, + TSubtitleWebSocket, + TLogger, + TRuntimeRegistry, + TOverlayManager extends { getModalWindow: () => BrowserWindow | null }, + TOverlayModalInputState extends OverlayModalInputStateShape, + TOverlayContentMeasurementStore, + TOverlayModalRuntime, + TAppState, + TAppLifecycleApp extends AppLifecycleShape, +>( + input: MainBootServicesBootstrapInput< + TConfigService, + TAnilistTokenStore, + TJellyfinTokenStore, + TAnilistUpdateQueue, + TSubtitleWebSocket, + TLogger, + TRuntimeRegistry, + TOverlayManager, + TOverlayModalInputState, + TOverlayContentMeasurementStore, + TOverlayModalRuntime, + TAppState, + TAppLifecycleApp + >, +): MainBootServicesResult< + TConfigService, + TAnilistTokenStore, + TJellyfinTokenStore, + TAnilistUpdateQueue, + TSubtitleWebSocket, + TLogger, + TRuntimeRegistry, + TOverlayManager, + TOverlayModalInputState, + TOverlayContentMeasurementStore, + TOverlayModalRuntime, + TAppState, + TAppLifecycleApp +> { + return createMainBootServices({ + platform: input.system.platform, + argv: input.system.argv, + appDataDir: input.system.appDataDir, + xdgConfigHome: input.system.xdgConfigHome, + homeDir: input.system.homeDir, + defaultMpvLogFile: input.system.defaultMpvLogFile, + envMpvLog: input.system.envMpvLog, + defaultTexthookerPort: input.system.defaultTexthookerPort, + getDefaultSocketPath: input.system.getDefaultSocketPath, + resolveConfigDir: input.system.resolveConfigDir, + existsSync: input.system.existsSync, + mkdirSync: input.system.mkdirSync, + joinPath: input.system.joinPath, + app: input.system.app, + shouldBypassSingleInstanceLock: input.singleInstance.shouldBypassSingleInstanceLock, + requestSingleInstanceLockEarly: input.singleInstance.requestSingleInstanceLockEarly, + registerSecondInstanceHandlerEarly: input.singleInstance.registerSecondInstanceHandlerEarly, + onConfigStartupParseError: input.singleInstance.onConfigStartupParseError, + createConfigService: input.factories.createConfigService, + createAnilistTokenStore: input.factories.createAnilistTokenStore, + createJellyfinTokenStore: input.factories.createJellyfinTokenStore, + createAnilistUpdateQueue: input.factories.createAnilistUpdateQueue, + createSubtitleWebSocket: input.factories.createSubtitleWebSocket, + createLogger: input.factories.createLogger, + createMainRuntimeRegistry: input.factories.createMainRuntimeRegistry, + createOverlayManager: input.factories.createOverlayManager, + createOverlayModalInputState: input.factories.createOverlayModalInputState, + createOverlayContentMeasurementStore: input.factories.createOverlayContentMeasurementStore, + getSyncOverlayShortcutsForModal: input.factories.getSyncOverlayShortcutsForModal, + getSyncOverlayVisibilityForModal: input.factories.getSyncOverlayVisibilityForModal, + createOverlayModalRuntime: input.factories.createOverlayModalRuntime, + createAppState: input.factories.createAppState, + }); +} diff --git a/src/main/main-early-runtime.ts b/src/main/main-early-runtime.ts new file mode 100644 index 00000000..df7e8e0f --- /dev/null +++ b/src/main/main-early-runtime.ts @@ -0,0 +1,253 @@ +import type { BrowserWindow } from 'electron'; + +import type { ConfigService } from '../config'; +import type { ResolvedConfig } from '../types'; +import type { AppState } from './state'; +import { createFirstRunRuntimeCoordinator } from './first-run-runtime-coordinator'; +import { createStartupSupportFromMainState } from './startup-support-coordinator'; +import { createYoutubeRuntimeFromMainState } from './youtube-runtime-coordinator'; +import { createOverlayMpvSubtitleSuppressionRuntime } from './runtime/overlay-mpv-sub-visibility'; +import { createDiscordPresenceRuntimeFromMainState } from './runtime/discord-presence-runtime'; +import type { OverlayGeometryRuntime } from './overlay-geometry-runtime'; +import type { OverlayHostedModal } from '../shared/ipc/contracts'; +import type { SubtitleRuntime } from './subtitle-runtime'; +import type { OverlayUiRuntime } from './overlay-ui-runtime'; + +export interface MainEarlyRuntimeInput { + platform: NodeJS.Platform; + configDir: string; + homeDir: string; + xdgConfigHome?: string; + binaryPath: string; + appPath: string; + resourcesPath: string; + appDataDir: string; + desktopDir: string; + defaultImmersionDbPath: string; + defaultJimakuLanguagePreference: ResolvedConfig['jimaku']['languagePreference']; + defaultJimakuMaxEntryResults: number; + defaultJimakuApiBaseUrl: string; + jellyfinLangPref: string; + youtube: { + directPlaybackFormat: string; + mpvYtdlFormat: string; + autoLaunchTimeoutMs: number; + connectTimeoutMs: number; + logPath: string; + }; + discordPresenceAppId: string; + appState: AppState; + getResolvedConfig: () => ResolvedConfig; + getFallbackDiscordMediaDurationSec: () => number | null; + configService: Pick; + overlay: { + overlayManager: { + getVisibleOverlayVisible: () => boolean; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + getMainWindow: () => BrowserWindow | null; + }; + overlayModalRuntime: { + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => void; + }; + getOverlayUi: () => OverlayUiRuntime | null; + getOverlayGeometry: () => OverlayGeometryRuntime; + ensureTray: () => void; + hasTray: () => boolean; + }; + yomitan: { + ensureYomitanExtensionLoaded: () => Promise; + getParserRuntimeDeps: () => Parameters< + typeof import('../core/services').getYomitanDictionaryInfo + >[0]; + openYomitanSettings: () => boolean; + }; + subtitle: { + getSubtitle: () => SubtitleRuntime; + }; + tokenization: { + startTokenizationWarmups: () => Promise; + getGate: Parameters[0]['tokenization']['getGate']; + }; + appReady: { + ensureYoutubePlaybackRuntimeReady: () => Promise; + }; + shortcuts: { + refreshGlobalAndOverlayShortcuts: () => void; + }; + notifications: { + showDesktopNotification: (title: string, options: { body?: string }) => void; + showErrorBox: (title: string, content: string) => void; + }; + mpv: { + sendMpvCommandRuntime: (client: AppState['mpvClient'], command: (string | number)[]) => void; + setSubVisibility: (visible: boolean) => void; + showMpvOsd: (text: string) => void; + }; + actions: { + requestAppQuit: () => void; + writeShortcutLink: ( + shortcutPath: string, + operation: 'create' | 'update' | 'replace', + details: { + target: string; + args?: string; + cwd?: string; + description?: string; + icon?: string; + iconIndex?: number; + }, + ) => boolean; + }; + logger: { + error: (message: string, error?: unknown) => void; + info: (message: string, ...args: unknown[]) => void; + warn: (message: string, error?: unknown) => void; + debug: (message: string, meta?: unknown) => void; + }; +} + +export function createMainEarlyRuntime(input: MainEarlyRuntimeInput) { + const firstRun = createFirstRunRuntimeCoordinator({ + platform: input.platform, + configDir: input.configDir, + homeDir: input.homeDir, + xdgConfigHome: input.xdgConfigHome, + binaryPath: input.binaryPath, + appPath: input.appPath, + resourcesPath: input.resourcesPath, + appDataDir: input.appDataDir, + desktopDir: input.desktopDir, + appState: input.appState, + getResolvedConfig: () => input.getResolvedConfig(), + yomitan: input.yomitan, + overlay: { + ensureTray: () => input.overlay.ensureTray(), + hasTray: () => input.overlay.hasTray(), + }, + actions: { + writeShortcutLink: (shortcutPath, operation, details) => + input.actions.writeShortcutLink(shortcutPath, operation, details), + requestAppQuit: () => input.actions.requestAppQuit(), + }, + logger: { + error: (message, error) => input.logger.error(message, error), + info: (message, ...args) => input.logger.info(message, ...args), + }, + }); + + const { discordPresenceRuntime, initializeDiscordPresenceService } = + createDiscordPresenceRuntimeFromMainState({ + appId: input.discordPresenceAppId, + appState: input.appState, + getResolvedConfig: () => input.getResolvedConfig(), + getFallbackMediaDurationSec: () => input.getFallbackDiscordMediaDurationSec(), + logger: { + debug: (message, meta) => input.logger.debug(message, meta), + }, + }); + + const overlaySubtitleSuppression = createOverlayMpvSubtitleSuppressionRuntime({ + appState: input.appState, + getVisibleOverlayVisible: () => input.overlay.overlayManager.getVisibleOverlayVisible(), + setMpvSubVisibility: (visible) => input.mpv.setSubVisibility(visible), + logWarn: (message, error) => input.logger.warn(message, error), + }); + + const startupSupport = createStartupSupportFromMainState({ + platform: input.platform, + defaultImmersionDbPath: input.defaultImmersionDbPath, + defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference, + defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults, + defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl, + jellyfinLangPref: input.jellyfinLangPref, + getResolvedConfig: () => input.getResolvedConfig(), + appState: input.appState, + configService: input.configService, + overlay: { + broadcastToOverlayWindows: (channel, payload) => + input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => + input.overlay.overlayModalRuntime.sendToActiveOverlayWindow( + channel, + payload, + runtimeOptions, + ), + }, + shortcuts: { + refreshGlobalAndOverlayShortcuts: () => input.shortcuts.refreshGlobalAndOverlayShortcuts(), + }, + notifications: { + showDesktopNotification: (title, options) => + input.notifications.showDesktopNotification(title, options), + showErrorBox: (title, details) => input.notifications.showErrorBox(title, details), + }, + logger: { + debug: (message) => input.logger.debug(message), + info: (message) => input.logger.info(message), + warn: (message, error) => input.logger.warn(message, error), + }, + mpv: { + sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command), + showMpvOsd: (text) => input.mpv.showMpvOsd(text), + }, + }); + + const youtube = createYoutubeRuntimeFromMainState({ + platform: input.platform, + directPlaybackFormat: input.youtube.directPlaybackFormat, + mpvYtdlFormat: input.youtube.mpvYtdlFormat, + autoLaunchTimeoutMs: input.youtube.autoLaunchTimeoutMs, + connectTimeoutMs: input.youtube.connectTimeoutMs, + logPath: input.youtube.logPath, + appState: input.appState, + overlay: { + getOverlayUi: () => input.overlay.getOverlayUi(), + getMainWindow: () => input.overlay.overlayManager.getMainWindow(), + getOverlayGeometry: () => input.overlay.getOverlayGeometry(), + broadcastToOverlayWindows: (channel, payload) => + input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload), + }, + subtitle: { + getSubtitle: () => input.subtitle.getSubtitle(), + }, + tokenization: { + startTokenizationWarmups: () => input.tokenization.startTokenizationWarmups(), + getGate: () => input.tokenization.getGate(), + }, + appReady: { + ensureYoutubePlaybackRuntimeReady: () => input.appReady.ensureYoutubePlaybackRuntimeReady(), + }, + getResolvedConfig: () => input.getResolvedConfig(), + notifications: { + showDesktopNotification: (title, options) => + input.notifications.showDesktopNotification(title, options), + showErrorBox: (title, content) => input.notifications.showErrorBox(title, content), + }, + mpv: { + sendMpvCommand: (command) => + input.mpv.sendMpvCommandRuntime(input.appState.mpvClient, command), + showMpvOsd: (message) => input.mpv.showMpvOsd(message), + }, + logger: { + info: (message) => input.logger.info(message), + warn: (message, error) => input.logger.warn(message, error), + debug: (message) => input.logger.debug(message), + }, + }); + + return { + firstRun, + discordPresenceRuntime, + initializeDiscordPresenceService, + overlaySubtitleSuppression, + startupSupport, + youtube, + }; +} diff --git a/src/main/main-playback-runtime.ts b/src/main/main-playback-runtime.ts new file mode 100644 index 00000000..3c6ea47a --- /dev/null +++ b/src/main/main-playback-runtime.ts @@ -0,0 +1,129 @@ +import type { SubtitleTimingTracker } from '../subtitle-timing-tracker'; +import type { MpvSubtitleRenderMetrics } from '../types'; +import type { MpvIpcClient } from '../core/services/mpv'; +import { sendMpvCommandRuntime } from '../core/services'; +import type { AnilistRuntime } from './anilist-runtime'; +import type { DictionarySupportRuntime } from './dictionary-support-runtime'; +import type { JellyfinRuntime } from './jellyfin-runtime'; +import { createMiningRuntime } from './mining-runtime'; +import type { MiningRuntimeInput } from './mining-runtime'; +import { createMpvRuntimeFromMainState } from './mpv-runtime-bootstrap'; +import type { MpvRuntime } from './mpv-runtime'; +import type { SubtitleRuntime } from './subtitle-runtime'; +import type { YoutubeRuntime } from './youtube-runtime'; +import type { AppState } from './state'; + +export interface MainPlaybackRuntimeInput { + appState: AppState; + logPath: string; + logger: Parameters[0]['logger'] & { + error: (message: string, error: unknown) => void; + }; + getResolvedConfig: Parameters[0]['getResolvedConfig']; + getRuntimeBooleanOption: Parameters< + typeof createMpvRuntimeFromMainState + >[0]['getRuntimeBooleanOption']; + subtitle: SubtitleRuntime; + yomitan: { + ensureYomitanExtensionLoaded: () => Promise; + isCharacterDictionaryEnabled: () => boolean; + }; + currentMediaTokenizationGate: Parameters< + typeof createMpvRuntimeFromMainState + >[0]['currentMediaTokenizationGate']; + startupOsdSequencer: Parameters[0]['startupOsdSequencer']; + dictionarySupport: DictionarySupportRuntime; + overlay: { + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + getVisibleOverlayVisible: () => boolean; + getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined; + }; + lifecycle: { + requestAppQuit: () => void; + restoreOverlayMpvSubtitles: () => void; + syncOverlayMpvSubtitleSuppression: () => void; + publishDiscordPresence: () => void; + }; + stats: { + ensureImmersionTrackerStarted: () => void; + }; + anilist: AnilistRuntime; + jellyfin: JellyfinRuntime; + youtube: YoutubeRuntime; + mining: Omit< + MiningRuntimeInput, + 'showMpvOsd' | 'sendMpvCommand' | 'logError' | 'recordCardsMined' + > & { + readClipboardText: () => string; + writeClipboardText: (text: string) => void; + recordCardsMined: (count: number, noteIds?: number[]) => void; + }; +} + +export interface MainPlaybackRuntime { + mpvRuntime: MpvRuntime; + mining: ReturnType; +} + +export function createMainPlaybackRuntime(input: MainPlaybackRuntimeInput): MainPlaybackRuntime { + let mpvRuntime!: MpvRuntime; + + const showMpvOsd = (text: string): void => { + mpvRuntime.showMpvOsd(text); + }; + + const mining = createMiningRuntime({ + ...input.mining, + showMpvOsd: (text) => showMpvOsd(text), + sendMpvCommand: (command) => { + sendMpvCommandRuntime(input.appState.mpvClient, command); + }, + logError: (message, err) => { + input.logger.error(message, err); + }, + recordCardsMined: (count, noteIds) => input.mining.recordCardsMined(count, noteIds), + }); + + mpvRuntime = createMpvRuntimeFromMainState({ + appState: input.appState, + logPath: input.logPath, + logger: input.logger, + getResolvedConfig: input.getResolvedConfig, + getRuntimeBooleanOption: input.getRuntimeBooleanOption, + subtitle: input.subtitle, + yomitan: { + ensureYomitanExtensionLoaded: async () => { + await input.yomitan.ensureYomitanExtensionLoaded(); + }, + }, + currentMediaTokenizationGate: input.currentMediaTokenizationGate, + startupOsdSequencer: input.startupOsdSequencer, + dictionarySupport: input.dictionarySupport, + overlay: { + broadcastToOverlayWindows: (channel, payload) => { + input.overlay.broadcastToOverlayWindows(channel, payload); + }, + getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(), + getOverlayUi: () => input.overlay.getOverlayUi(), + }, + lifecycle: { + requestAppQuit: () => input.lifecycle.requestAppQuit(), + setQuitCheckTimer: (callback, timeoutMs) => { + setTimeout(callback, timeoutMs); + }, + restoreOverlayMpvSubtitles: input.lifecycle.restoreOverlayMpvSubtitles, + syncOverlayMpvSubtitleSuppression: input.lifecycle.syncOverlayMpvSubtitleSuppression, + publishDiscordPresence: () => input.lifecycle.publishDiscordPresence(), + }, + stats: input.stats, + anilist: input.anilist, + jellyfin: input.jellyfin, + youtube: input.youtube, + isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(), + }).mpvRuntime; + + return { + mpvRuntime, + mining, + }; +} diff --git a/src/main/main-startup-bootstrap-types.ts b/src/main/main-startup-bootstrap-types.ts new file mode 100644 index 00000000..afadf142 --- /dev/null +++ b/src/main/main-startup-bootstrap-types.ts @@ -0,0 +1,54 @@ +import type { CliArgs } from '../cli/args'; +import type { ResolvedConfig, SecondarySubMode, SubtitleData } from '../types'; +import { RuntimeOptionsManager } from '../runtime-options'; +import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; + +export type StartupBootstrapMpvClientLike = { + connected: boolean; + connect: () => void; + setSocketPath: (socketPath: string) => void; + currentSubStart?: number | null; + currentSubEnd?: number | null; +}; + +export type StartupBootstrapAppStateLike = { + subtitlePosition: unknown | null; + keybindings: unknown[]; + mpvSocketPath: string; + texthookerPort: number; + mpvClient: StartupBootstrapMpvClientLike | null; + runtimeOptionsManager: RuntimeOptionsManager | null; + subtitleTimingTracker: SubtitleTimingTracker | null; + currentSubtitleData: SubtitleData | null; + currentSubText: string | null; + initialArgs: CliArgs | null | undefined; + backgroundMode: boolean; + texthookerOnlyMode: boolean; + overlayRuntimeInitialized: boolean; + firstRunSetupCompleted: boolean; + secondarySubMode: SecondarySubMode; + ankiIntegration: unknown | null; + immersionTracker: unknown | null; +}; + +export type StartupBootstrapSubtitleWebsocketLike = { + start: ( + port: number, + getPayload: () => SubtitleData | null, + getFrequencyOptions: () => { + enabled: boolean; + topX: number; + mode: ResolvedConfig['subtitleStyle']['frequencyDictionary']['mode']; + }, + ) => void; +}; + +export type StartupBootstrapOverlayUiLike = { + broadcastRuntimeOptionsChanged: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + ensureTray: () => void; + initializeOverlayRuntime: () => void; + openRuntimeOptionsPalette: () => void; + setVisibleOverlayVisible: (visible: boolean) => void; + toggleVisibleOverlay: () => void; +}; diff --git a/src/main/main-startup-bootstrap.ts b/src/main/main-startup-bootstrap.ts new file mode 100644 index 00000000..e401aa3c --- /dev/null +++ b/src/main/main-startup-bootstrap.ts @@ -0,0 +1,505 @@ +import type { CliArgs, CliCommandSource } from '../cli/args'; +import type { LogLevelSource } from '../logger'; +import type { ConfigValidationWarning, ResolvedConfig, SubtitleData } from '../types'; +import type { StartupBootstrapRuntimeDeps } from '../core/services/startup'; +import { resolveKeybindings } from '../core/utils'; +import { RuntimeOptionsManager } from '../runtime-options'; +import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; +import type { AppReadyRuntimeInput } from './app-ready-runtime'; +import type { + CliCommandRuntimeServiceContext, + CliCommandRuntimeServiceContextHandlers, +} from './cli-runtime'; +import type { + StartupBootstrapAppStateLike, + StartupBootstrapMpvClientLike, + StartupBootstrapOverlayUiLike, + StartupBootstrapSubtitleWebsocketLike, +} from './main-startup-bootstrap-types'; +import type { MainStartupRuntime } from './main-startup-runtime'; +import { createMainStartupRuntime } from './main-startup-runtime'; + +export interface MainStartupBootstrapInput { + appState: StartupBootstrapAppStateLike; + appLifecycle: { + app: unknown; + argv: string[]; + platform: NodeJS.Platform; + }; + config: { + configService: { + reloadConfigStrict: AppReadyRuntimeInput['reload']['reloadConfigStrict']; + getConfigPath: () => string; + getWarnings: () => ConfigValidationWarning[]; + getConfig: () => ResolvedConfig; + }; + configHotReloadRuntime: { + start: () => void; + }; + configDerivedRuntime: { + shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; + }; + ensureDefaultConfigBootstrap: (options: { + configDir: string; + configFilePaths: unknown; + generateTemplate: () => string; + }) => void; + getDefaultConfigFilePaths: (configDir: string) => unknown; + generateConfigTemplate: (config: ResolvedConfig) => string; + defaultConfig: ResolvedConfig; + defaultKeybindings: unknown; + configDir: string; + }; + logging: { + appLogger: { + logInfo: (message: string) => void; + logWarning: (message: string) => void; + logConfigWarning: (warning: ConfigValidationWarning) => void; + logNoRunningInstance: () => void; + }; + logger: { + info: (message: string) => void; + warn: (message: string, error?: unknown) => void; + error: (message: string, error?: unknown) => void; + debug: (message: string) => void; + }; + setLogLevel: (level: string, source: LogLevelSource) => void; + }; + shell: { + dialog: { + showErrorBox: (title: string, message: string) => void; + }; + shell: { + openExternal: (url: string) => Promise; + }; + showDesktopNotification: (title: string, options: { body: string }) => void; + }; + runtime: { + subtitle: { + loadSubtitlePosition: () => void; + invalidateTokenizationCache: () => void; + refreshSubtitlePrefetchFromActiveTrack: () => Promise; + }; + overlayUi: { + get: () => StartupBootstrapOverlayUiLike | undefined; + }; + overlayManager: { + getMainWindow: () => unknown | null; + }; + firstRun: { + ensureSetupStateInitialized: () => Promise<{ state: { status: string } }>; + openFirstRunSetupWindow: () => void; + }; + anilist: { + refreshAnilistClientSecretStateIfEnabled: (options: { + force: boolean; + allowSetupPrompt?: boolean; + }) => Promise; + openAnilistSetupWindow: () => void; + getStatusSnapshot: CliCommandRuntimeServiceContext['getAnilistStatus']; + clearTokenState: () => void; + getQueueStatusSnapshot: CliCommandRuntimeServiceContext['getAnilistQueueStatus']; + processNextAnilistRetryUpdate: CliCommandRuntimeServiceContext['retryAnilistQueueNow']; + }; + jellyfin: { + startJellyfinRemoteSession: () => Promise; + openJellyfinSetupWindow: () => void; + runJellyfinCommand: (argsFromCommand: CliArgs) => Promise; + }; + stats: { + ensureImmersionTrackerStarted: () => void; + runStatsCliCommand: CliCommandRuntimeServiceContext['runStatsCommand']; + }; + mining: { + copyCurrentSubtitle: () => void; + mineSentenceCard: () => Promise; + updateLastCardFromClipboard: () => Promise; + refreshKnownWordCache: () => Promise; + triggerFieldGrouping: () => Promise; + markLastCardAsAudioCard: () => Promise; + }; + yomitan: { + loadYomitanExtension: () => Promise; + ensureYomitanExtensionLoaded: () => Promise; + openYomitanSettings: () => void; + }; + subsyncRuntime: { + triggerFromConfig: () => Promise; + }; + dictionarySupport: { + generateCharacterDictionaryForCurrentMedia: CliCommandRuntimeServiceContext['generateCharacterDictionary']; + }; + texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService']; + subtitleWsService: StartupBootstrapSubtitleWebsocketLike; + annotationSubtitleWsService: StartupBootstrapSubtitleWebsocketLike; + immersion: AppReadyRuntimeInput['immersion']; + }; + commands: { + createMpvClientRuntimeService: () => StartupBootstrapMpvClientLike; + createMecabTokenizerAndCheck: () => Promise; + prewarmSubtitleDictionaries: () => Promise; + startBackgroundWarmupsIfAllowed: () => void; + startBackgroundWarmups: () => void; + runHeadlessInitialCommand: () => Promise; + startPendingMultiCopy: (timeoutMs: number) => void; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + cycleSecondarySubMode: () => void; + refreshOverlayShortcuts: () => void; + hasMpvWebsocketPlugin: () => boolean; + startTexthooker: (port: number, websocketUrl?: string) => void; + showMpvOsd: (text: string) => void; + shouldAutoOpenFirstRunSetup: (args: CliArgs) => boolean; + generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary']; + runYoutubePlaybackFlow: (request: { + url: string; + mode: NonNullable; + source: CliCommandSource; + }) => Promise; + getMultiCopyTimeoutMs: () => number; + shouldEnsureTrayOnStartupForInitialArgs: ( + platform: NodeJS.Platform, + initialArgs: CliArgs | null | undefined, + ) => boolean; + isHeadlessInitialCommand: (args: CliArgs) => boolean; + commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean; + commandNeedsOverlayRuntime: (args: CliArgs) => boolean; + handleCliCommandRuntimeServiceWithContext: ( + args: CliArgs, + source: CliCommandSource, + context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers, + ) => void; + shouldStartApp: (args: CliArgs) => boolean; + parseArgs: (argv: string[]) => CliArgs; + printHelp: (defaultTexthookerPort: number) => void; + onWillQuitCleanupHandler: () => void; + shouldRestoreWindowsOnActivateHandler: () => boolean; + restoreWindowsOnActivateHandler: () => void; + forceX11Backend: (args: CliArgs) => void; + enforceUnsupportedWaylandMode: (args: CliArgs) => void; + getDefaultSocketPathHandler: () => string; + generateDefaultConfigFile: ( + args: CliArgs, + options: { + configDir: string; + defaultConfig: unknown; + generateTemplate: (config: unknown) => string; + }, + ) => Promise; + runStartupBootstrapRuntime: (deps: StartupBootstrapRuntimeDeps) => TStartupState; + applyStartupState: (startupState: TStartupState) => void; + getStartupModeFlags: (initialArgs: CliArgs | null | undefined) => { + shouldUseMinimalStartup: boolean; + shouldSkipHeavyStartup: boolean; + }; + requestAppQuit: () => void; + }; + constants: { + defaultTexthookerPort: number; + }; +} + +export function createMainStartupBootstrap( + input: MainStartupBootstrapInput, +): MainStartupRuntime { + let startup: MainStartupRuntime | null = null; + const getStartup = (): MainStartupRuntime => { + if (!startup) { + throw new Error('Main startup runtime not initialized'); + } + return startup; + }; + const getOverlayUi = (): StartupBootstrapOverlayUiLike | undefined => + input.runtime.overlayUi.get(); + const getSubtitlePayload = (): SubtitleData | null => + input.appState.currentSubtitleData ?? + (input.appState.currentSubText + ? { + text: input.appState.currentSubText, + tokens: null, + startTime: input.appState.mpvClient?.currentSubStart ?? null, + endTime: input.appState.mpvClient?.currentSubEnd ?? null, + } + : null); + const getSubtitleFrequencyOptions = () => ({ + enabled: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.enabled, + topX: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.topX, + mode: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.mode, + }); + + startup = createMainStartupRuntime({ + appReady: { + reload: { + reloadConfigStrict: () => input.config.configService.reloadConfigStrict(), + logInfo: (message) => input.logging.appLogger.logInfo(message), + logWarning: (message) => input.logging.appLogger.logWarning(message), + showDesktopNotification: (title, options) => + input.shell.showDesktopNotification(title, options), + startConfigHotReload: () => input.config.configHotReloadRuntime.start(), + refreshAnilistClientSecretState: (options) => + input.runtime.anilist.refreshAnilistClientSecretStateIfEnabled(options), + failHandlers: { + logError: (details) => input.logging.logger.error(details), + showErrorBox: (title, details) => input.shell.dialog.showErrorBox(title, details), + quit: () => input.commands.requestAppQuit(), + }, + }, + criticalConfig: { + getConfigPath: () => input.config.configService.getConfigPath(), + failHandlers: { + logError: (message) => input.logging.logger.error(message), + showErrorBox: (title, message) => input.shell.dialog.showErrorBox(title, message), + quit: () => input.commands.requestAppQuit(), + }, + }, + runner: { + ensureDefaultConfigBootstrap: () => { + input.config.ensureDefaultConfigBootstrap({ + configDir: input.config.configDir, + configFilePaths: input.config.getDefaultConfigFilePaths(input.config.configDir), + generateTemplate: () => input.config.generateConfigTemplate(input.config.defaultConfig), + }); + }, + getSubtitlePosition: () => input.appState.subtitlePosition, + loadSubtitlePosition: () => input.runtime.subtitle.loadSubtitlePosition(), + getKeybindingsCount: () => input.appState.keybindings.length, + resolveKeybindings: () => { + input.appState.keybindings = resolveKeybindings( + input.config.configService.getConfig(), + input.config.defaultKeybindings as never, + ); + }, + hasMpvClient: () => Boolean(input.appState.mpvClient), + createMpvClient: () => { + input.appState.mpvClient = input.commands.createMpvClientRuntimeService(); + }, + getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager, + getResolvedConfig: () => input.config.configService.getConfig(), + getConfigWarnings: () => input.config.configService.getWarnings(), + logConfigWarning: (warning) => input.logging.appLogger.logConfigWarning(warning), + setLogLevel: (level, source) => input.logging.setLogLevel(level, source), + initRuntimeOptionsManager: () => { + input.appState.runtimeOptionsManager = new RuntimeOptionsManager( + () => input.config.configService.getConfig().ankiConnect, + { + applyAnkiPatch: (patch: unknown) => { + ( + input.appState.ankiIntegration as { + applyRuntimeConfigPatch?: (patch: unknown) => void; + } | null + )?.applyRuntimeConfigPatch?.(patch); + }, + getSubtitleStyleConfig: () => input.config.configService.getConfig().subtitleStyle, + onOptionsChanged: () => { + input.runtime.subtitle.invalidateTokenizationCache(); + void input.runtime.subtitle.refreshSubtitlePrefetchFromActiveTrack(); + getOverlayUi()?.broadcastRuntimeOptionsChanged(); + input.commands.refreshOverlayShortcuts(); + }, + }, + ); + }, + getSubtitleTimingTracker: () => input.appState.subtitleTimingTracker, + createSubtitleTimingTracker: () => { + input.appState.subtitleTimingTracker = new SubtitleTimingTracker(); + }, + setSecondarySubMode: (mode) => { + input.appState.secondarySubMode = mode; + }, + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: input.config.defaultConfig.websocket.port, + defaultAnnotationWebsocketPort: input.config.defaultConfig.annotationWebsocket.port, + defaultTexthookerPort: input.constants.defaultTexthookerPort, + hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(), + startSubtitleWebsocket: (port) => { + input.runtime.subtitleWsService.start( + port, + getSubtitlePayload, + getSubtitleFrequencyOptions, + ); + }, + startAnnotationWebsocket: (port) => { + input.runtime.annotationSubtitleWsService.start( + port, + getSubtitlePayload, + getSubtitleFrequencyOptions, + ); + }, + startTexthooker: (port, websocketUrl) => input.commands.startTexthooker(port, websocketUrl), + log: (message) => input.logging.appLogger.logInfo(message), + createMecabTokenizerAndCheck: () => input.commands.createMecabTokenizerAndCheck(), + createImmersionTracker: () => { + input.runtime.stats.ensureImmersionTrackerStarted(); + }, + startJellyfinRemoteSession: () => input.runtime.jellyfin.startJellyfinRemoteSession(), + loadYomitanExtension: async () => { + await input.runtime.yomitan.loadYomitanExtension(); + }, + ensureYomitanExtensionLoaded: async () => { + await input.runtime.yomitan.ensureYomitanExtensionLoaded(); + }, + handleFirstRunSetup: async () => { + const snapshot = await input.runtime.firstRun.ensureSetupStateInitialized(); + input.appState.firstRunSetupCompleted = snapshot.state.status === 'completed'; + if ( + input.appState.initialArgs && + input.commands.shouldAutoOpenFirstRunSetup(input.appState.initialArgs) && + snapshot.state.status !== 'completed' + ) { + input.runtime.firstRun.openFirstRunSetupWindow(); + } + }, + prewarmSubtitleDictionaries: () => input.commands.prewarmSubtitleDictionaries(), + startBackgroundWarmups: () => input.commands.startBackgroundWarmupsIfAllowed(), + texthookerOnlyMode: input.appState.texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: () => + input.appState.backgroundMode + ? false + : input.config.configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(), + setVisibleOverlayVisible: (visible) => getOverlayUi()?.setVisibleOverlayVisible(visible), + initializeOverlayRuntime: () => getOverlayUi()?.initializeOverlayRuntime(), + ensureOverlayWindowsReadyForVisibilityActions: () => + getOverlayUi()?.ensureOverlayWindowsReadyForVisibilityActions(), + runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(), + handleInitialArgs: () => getStartup().handleInitialArgs(), + shouldRunHeadlessInitialCommand: () => + Boolean( + input.appState.initialArgs && + input.commands.isHeadlessInitialCommand(input.appState.initialArgs), + ), + shouldUseMinimalStartup: () => + input.commands.getStartupModeFlags(input.appState.initialArgs).shouldUseMinimalStartup, + shouldSkipHeavyStartup: () => + input.commands.getStartupModeFlags(input.appState.initialArgs).shouldSkipHeavyStartup, + logDebug: (message) => input.logging.logger.debug(message), + now: () => Date.now(), + }, + immersion: input.runtime.immersion, + isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized, + }, + cli: { + appState: { + appState: input.appState, + getInitialArgs: () => input.appState.initialArgs, + isBackgroundMode: () => input.appState.backgroundMode, + isTexthookerOnlyMode: () => input.appState.texthookerOnlyMode, + setTexthookerOnlyMode: (enabled) => { + input.appState.texthookerOnlyMode = enabled; + }, + hasImmersionTracker: () => Boolean(input.appState.immersionTracker), + getMpvClient: () => input.appState.mpvClient, + isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized, + }, + config: { + defaultConfig: input.config.defaultConfig, + getResolvedConfig: () => input.config.configService.getConfig(), + setCliLogLevel: (level) => input.logging.setLogLevel(level, 'cli'), + hasMpvWebsocketPlugin: () => true, + }, + io: { + texthookerService: input.runtime.texthookerService, + openExternal: (url) => input.shell.shell.openExternal(url), + logBrowserOpenError: (url, error) => + input.logging.logger.error(`Failed to open browser for texthooker URL: ${url}`, error), + showMpvOsd: (text) => input.commands.showMpvOsd(text), + schedule: (fn, delayMs) => setTimeout(fn, delayMs), + logInfo: (message) => input.logging.logger.info(message), + logWarn: (message) => input.logging.logger.warn(message), + logError: (message, err) => input.logging.logger.error(message, err), + }, + commands: { + initializeOverlayRuntime: () => getOverlayUi()?.initializeOverlayRuntime(), + toggleVisibleOverlay: () => getOverlayUi()?.toggleVisibleOverlay(), + openFirstRunSetupWindow: () => input.runtime.firstRun.openFirstRunSetupWindow(), + setVisibleOverlayVisible: (visible) => getOverlayUi()?.setVisibleOverlayVisible(visible), + copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs) => input.commands.startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => input.runtime.mining.mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs) => + input.commands.startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(), + refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(), + triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(), + triggerSubsyncFromConfig: () => input.runtime.subsyncRuntime.triggerFromConfig(), + markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(), + getAnilistStatus: () => input.runtime.anilist.getStatusSnapshot(), + clearAnilistToken: () => input.runtime.anilist.clearTokenState(), + openAnilistSetupWindow: () => input.runtime.anilist.openAnilistSetupWindow(), + openJellyfinSetupWindow: () => input.runtime.jellyfin.openJellyfinSetupWindow(), + getAnilistQueueStatus: () => input.runtime.anilist.getQueueStatusSnapshot(), + processNextAnilistRetryUpdate: () => input.runtime.anilist.processNextAnilistRetryUpdate(), + generateCharacterDictionary: (targetPath?: string) => + input.commands.generateCharacterDictionary(targetPath), + runJellyfinCommand: (argsFromCommand) => + input.runtime.jellyfin.runJellyfinCommand(argsFromCommand), + runStatsCommand: (argsFromCommand, source) => + input.runtime.stats.runStatsCliCommand(argsFromCommand, source), + runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request), + openYomitanSettings: () => input.runtime.yomitan.openYomitanSettings(), + cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(), + openRuntimeOptionsPalette: () => getOverlayUi()?.openRuntimeOptionsPalette(), + printHelp: () => input.commands.printHelp(input.constants.defaultTexthookerPort), + stopApp: () => input.commands.requestAppQuit(), + hasMainWindow: () => Boolean(input.runtime.overlayManager.getMainWindow()), + getMultiCopyTimeoutMs: () => input.commands.getMultiCopyTimeoutMs(), + }, + startup: { + shouldEnsureTrayOnStartup: () => + input.commands.shouldEnsureTrayOnStartupForInitialArgs( + input.appLifecycle.platform, + input.appState.initialArgs, + ), + shouldRunHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args), + ensureTray: () => getOverlayUi()?.ensureTray(), + commandNeedsOverlayStartupPrereqs: (args) => + input.commands.commandNeedsOverlayStartupPrereqs(args), + commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args), + ensureOverlayStartupPrereqs: () => getStartup().appReady.ensureOverlayStartupPrereqs(), + startBackgroundWarmups: () => input.commands.startBackgroundWarmups(), + }, + handleCliCommandRuntimeServiceWithContext: (args, source, context) => + input.commands.handleCliCommandRuntimeServiceWithContext(args, source, context), + }, + headless: { + appLifecycleRuntimeRunnerMainDeps: { + app: input.appLifecycle.app as never, + platform: input.appLifecycle.platform, + shouldStartApp: (nextArgs) => input.commands.shouldStartApp(nextArgs), + parseArgs: (argv) => input.commands.parseArgs(argv), + handleCliCommand: (nextArgs, source) => getStartup().handleCliCommand(nextArgs, source), + printHelp: () => input.commands.printHelp(input.constants.defaultTexthookerPort), + logNoRunningInstance: () => input.logging.appLogger.logNoRunningInstance(), + onReady: (): Promise => getStartup().appReady.runAppReady(), + onWillQuitCleanup: () => input.commands.onWillQuitCleanupHandler(), + shouldRestoreWindowsOnActivate: () => + input.commands.shouldRestoreWindowsOnActivateHandler(), + restoreWindowsOnActivate: () => input.commands.restoreWindowsOnActivateHandler(), + shouldQuitOnWindowAllClosed: () => !input.appState.backgroundMode, + }, + bootstrap: { + argv: input.appLifecycle.argv, + parseArgs: (argv) => input.commands.parseArgs(argv), + setLogLevel: (level, source) => input.logging.setLogLevel(level, source), + forceX11Backend: (args) => input.commands.forceX11Backend(args), + enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args), + shouldStartApp: (args) => input.commands.shouldStartApp(args), + getDefaultSocketPath: () => input.commands.getDefaultSocketPathHandler(), + defaultTexthookerPort: input.constants.defaultTexthookerPort, + configDir: input.config.configDir, + defaultConfig: input.config.defaultConfig, + generateConfigTemplate: (config) => input.config.generateConfigTemplate(config), + generateDefaultConfigFile: (args, options) => + input.commands.generateDefaultConfigFile(args, options), + setExitCode: (exitCode) => { + process.exitCode = exitCode; + }, + quitApp: () => input.commands.requestAppQuit(), + logGenerateConfigError: (message) => input.logging.logger.error(message), + startAppLifecycle: () => {}, + }, + runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps), + applyStartupState: (startupState) => input.commands.applyStartupState(startupState), + }, + }); + + return startup; +} diff --git a/src/main/main-startup-runtime-bootstrap.ts b/src/main/main-startup-runtime-bootstrap.ts new file mode 100644 index 00000000..af45db51 --- /dev/null +++ b/src/main/main-startup-runtime-bootstrap.ts @@ -0,0 +1,262 @@ +import type { MainStartupBootstrapInput } from './main-startup-bootstrap'; +import type { MainStartupRuntime } from './main-startup-runtime'; +import type { FirstRunRuntime } from './first-run-runtime'; +import { createMainStartupBootstrap } from './main-startup-bootstrap'; + +type StartupBootstrapYomitanRuntime = { + loadYomitanExtension: MainStartupBootstrapInput['runtime']['yomitan']['loadYomitanExtension']; + ensureYomitanExtensionLoaded: MainStartupBootstrapInput['runtime']['yomitan']['ensureYomitanExtensionLoaded']; + openYomitanSettings: MainStartupBootstrapInput['runtime']['yomitan']['openYomitanSettings']; +}; + +export interface MainStartupRuntimeBootstrapInput { + appState: MainStartupBootstrapInput['appState']; + appLifecycle: { + app: MainStartupBootstrapInput['appLifecycle']['app']; + argv: string[]; + platform: NodeJS.Platform; + }; + config: MainStartupBootstrapInput['config']; + logging: MainStartupBootstrapInput['logging']; + shell: MainStartupBootstrapInput['shell']; + runtime: Omit['runtime'], 'overlayUi' | 'yomitan'> & { + texthookerService: { + isRunning: () => boolean; + start: (port: number, websocketUrl?: string) => void; + }; + getOverlayUi: MainStartupBootstrapInput['runtime']['overlayUi']['get']; + getYomitanRuntime: () => StartupBootstrapYomitanRuntime; + getCharacterDictionaryDisabledReason: () => string | null; + }; + commands: Omit< + MainStartupBootstrapInput['commands'], + | 'startTexthooker' + | 'generateCharacterDictionary' + | 'runYoutubePlaybackFlow' + | 'getMultiCopyTimeoutMs' + > & { + getConfiguredShortcuts: () => { multiCopyTimeoutMs: number }; + runYoutubePlaybackFlow: MainStartupBootstrapInput['commands']['runYoutubePlaybackFlow']; + }; + constants: MainStartupBootstrapInput['constants']; +} + +export interface MainStartupRuntimeBootstrap { + startupRuntime: MainStartupRuntime; +} + +export interface MainStartupRuntimeFromMainStateInput { + appState: MainStartupRuntimeBootstrapInput['appState']; + appLifecycle: MainStartupRuntimeBootstrapInput['appLifecycle']; + config: MainStartupRuntimeBootstrapInput['config']; + logging: MainStartupRuntimeBootstrapInput['logging']; + shell: MainStartupRuntimeBootstrapInput['shell']; + runtime: { + subtitle: MainStartupRuntimeBootstrapInput['runtime']['subtitle']; + getOverlayUi: MainStartupRuntimeBootstrapInput['runtime']['getOverlayUi']; + overlayManager: MainStartupRuntimeBootstrapInput['runtime']['overlayManager']; + firstRun: { + ensureSetupStateInitialized: FirstRunRuntime['ensureSetupStateInitialized']; + openFirstRunSetupWindow: () => void; + }; + anilist: { + refreshAnilistClientSecretStateIfEnabled: MainStartupRuntimeBootstrapInput['runtime']['anilist']['refreshAnilistClientSecretStateIfEnabled']; + openAnilistSetupWindow: MainStartupRuntimeBootstrapInput['runtime']['anilist']['openAnilistSetupWindow']; + getStatusSnapshot: MainStartupRuntimeBootstrapInput['runtime']['anilist']['getStatusSnapshot']; + clearTokenState: MainStartupRuntimeBootstrapInput['runtime']['anilist']['clearTokenState']; + getQueueStatusSnapshot: MainStartupRuntimeBootstrapInput['runtime']['anilist']['getQueueStatusSnapshot']; + processNextAnilistRetryUpdate: MainStartupRuntimeBootstrapInput['runtime']['anilist']['processNextAnilistRetryUpdate']; + }; + jellyfin: MainStartupRuntimeBootstrapInput['runtime']['jellyfin']; + stats: MainStartupRuntimeBootstrapInput['runtime']['stats']; + mining: MainStartupRuntimeBootstrapInput['runtime']['mining']; + texthookerService: MainStartupRuntimeBootstrapInput['runtime']['texthookerService']; + yomitan: StartupBootstrapYomitanRuntime; + getCharacterDictionaryDisabledReason: () => string | null; + subsyncRuntime: MainStartupRuntimeBootstrapInput['runtime']['subsyncRuntime']; + dictionarySupport: MainStartupRuntimeBootstrapInput['runtime']['dictionarySupport']; + subtitleWsService: MainStartupRuntimeBootstrapInput['runtime']['subtitleWsService']; + annotationSubtitleWsService: MainStartupRuntimeBootstrapInput['runtime']['annotationSubtitleWsService']; + immersion: MainStartupRuntimeBootstrapInput['runtime']['immersion']; + }; + commands: { + mpvRuntime: { + createMpvClientRuntimeService: MainStartupRuntimeBootstrapInput['commands']['createMpvClientRuntimeService']; + createMecabTokenizerAndCheck: MainStartupRuntimeBootstrapInput['commands']['createMecabTokenizerAndCheck']; + prewarmSubtitleDictionaries: MainStartupRuntimeBootstrapInput['commands']['prewarmSubtitleDictionaries']; + startBackgroundWarmups: MainStartupRuntimeBootstrapInput['commands']['startBackgroundWarmups']; + }; + runHeadlessInitialCommand: MainStartupRuntimeBootstrapInput['commands']['runHeadlessInitialCommand']; + shortcuts: { + startPendingMultiCopy: MainStartupRuntimeBootstrapInput['commands']['startPendingMultiCopy']; + startPendingMineSentenceMultiple: MainStartupRuntimeBootstrapInput['commands']['startPendingMineSentenceMultiple']; + refreshOverlayShortcuts: MainStartupRuntimeBootstrapInput['commands']['refreshOverlayShortcuts']; + getConfiguredShortcuts: MainStartupRuntimeBootstrapInput['commands']['getConfiguredShortcuts']; + }; + cycleSecondarySubMode: MainStartupRuntimeBootstrapInput['commands']['cycleSecondarySubMode']; + hasMpvWebsocketPlugin: MainStartupRuntimeBootstrapInput['commands']['hasMpvWebsocketPlugin']; + showMpvOsd: MainStartupRuntimeBootstrapInput['commands']['showMpvOsd']; + shouldAutoOpenFirstRunSetup: MainStartupRuntimeBootstrapInput['commands']['shouldAutoOpenFirstRunSetup']; + youtube: { + runYoutubePlaybackFlow: MainStartupRuntimeBootstrapInput['commands']['runYoutubePlaybackFlow']; + }; + shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeBootstrapInput['commands']['shouldEnsureTrayOnStartupForInitialArgs']; + isHeadlessInitialCommand: MainStartupRuntimeBootstrapInput['commands']['isHeadlessInitialCommand']; + commandNeedsOverlayStartupPrereqs: MainStartupRuntimeBootstrapInput['commands']['commandNeedsOverlayStartupPrereqs']; + commandNeedsOverlayRuntime: MainStartupRuntimeBootstrapInput['commands']['commandNeedsOverlayRuntime']; + handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeBootstrapInput['commands']['handleCliCommandRuntimeServiceWithContext']; + shouldStartApp: MainStartupRuntimeBootstrapInput['commands']['shouldStartApp']; + parseArgs: MainStartupRuntimeBootstrapInput['commands']['parseArgs']; + printHelp: MainStartupRuntimeBootstrapInput['commands']['printHelp']; + onWillQuitCleanupHandler: MainStartupRuntimeBootstrapInput['commands']['onWillQuitCleanupHandler']; + shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeBootstrapInput['commands']['shouldRestoreWindowsOnActivateHandler']; + restoreWindowsOnActivateHandler: MainStartupRuntimeBootstrapInput['commands']['restoreWindowsOnActivateHandler']; + forceX11Backend: MainStartupRuntimeBootstrapInput['commands']['forceX11Backend']; + enforceUnsupportedWaylandMode: MainStartupRuntimeBootstrapInput['commands']['enforceUnsupportedWaylandMode']; + getDefaultSocketPath: MainStartupRuntimeBootstrapInput['commands']['getDefaultSocketPathHandler']; + generateDefaultConfigFile: MainStartupRuntimeBootstrapInput['commands']['generateDefaultConfigFile']; + runStartupBootstrapRuntime: MainStartupRuntimeBootstrapInput['commands']['runStartupBootstrapRuntime']; + applyStartupState: MainStartupRuntimeBootstrapInput['commands']['applyStartupState']; + getStartupModeFlags: MainStartupRuntimeBootstrapInput['commands']['getStartupModeFlags']; + requestAppQuit: MainStartupRuntimeBootstrapInput['commands']['requestAppQuit']; + }; + constants: MainStartupRuntimeBootstrapInput['constants']; +} + +export function createMainStartupRuntimeBootstrap( + input: MainStartupRuntimeBootstrapInput, +): MainStartupRuntimeBootstrap { + const startupRuntime = createMainStartupBootstrap({ + appState: input.appState, + appLifecycle: { + app: input.appLifecycle.app, + argv: input.appLifecycle.argv, + platform: input.appLifecycle.platform, + }, + config: input.config, + logging: input.logging, + shell: input.shell, + runtime: { + ...input.runtime, + overlayUi: { + get: () => input.runtime.getOverlayUi(), + }, + yomitan: { + loadYomitanExtension: () => input.runtime.getYomitanRuntime().loadYomitanExtension(), + ensureYomitanExtensionLoaded: () => + input.runtime.getYomitanRuntime().ensureYomitanExtensionLoaded(), + openYomitanSettings: () => input.runtime.getYomitanRuntime().openYomitanSettings(), + }, + }, + commands: { + ...input.commands, + startTexthooker: (port, websocketUrl) => { + if (!input.runtime.texthookerService.isRunning()) { + input.runtime.texthookerService.start(port, websocketUrl); + } + }, + generateCharacterDictionary: async (targetPath?: string) => { + const disabledReason = input.runtime.getCharacterDictionaryDisabledReason(); + if (disabledReason) { + throw new Error(disabledReason); + } + return await input.runtime.dictionarySupport.generateCharacterDictionaryForCurrentMedia( + targetPath, + ); + }, + runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request), + getMultiCopyTimeoutMs: () => input.commands.getConfiguredShortcuts().multiCopyTimeoutMs, + }, + constants: input.constants, + }); + + return { + startupRuntime, + }; +} + +export function createMainStartupRuntimeFromMainState( + input: MainStartupRuntimeFromMainStateInput, +): MainStartupRuntimeBootstrap { + return createMainStartupRuntimeBootstrap({ + appState: input.appState, + appLifecycle: input.appLifecycle, + config: input.config, + logging: input.logging, + shell: input.shell, + runtime: { + subtitle: input.runtime.subtitle, + getOverlayUi: () => input.runtime.getOverlayUi(), + overlayManager: input.runtime.overlayManager, + firstRun: { + ensureSetupStateInitialized: () => input.runtime.firstRun.ensureSetupStateInitialized(), + openFirstRunSetupWindow: () => input.runtime.firstRun.openFirstRunSetupWindow(), + }, + anilist: { + refreshAnilistClientSecretStateIfEnabled: (options) => + input.runtime.anilist.refreshAnilistClientSecretStateIfEnabled(options), + openAnilistSetupWindow: () => input.runtime.anilist.openAnilistSetupWindow(), + getStatusSnapshot: () => input.runtime.anilist.getStatusSnapshot(), + clearTokenState: () => input.runtime.anilist.clearTokenState(), + getQueueStatusSnapshot: () => input.runtime.anilist.getQueueStatusSnapshot(), + processNextAnilistRetryUpdate: () => input.runtime.anilist.processNextAnilistRetryUpdate(), + }, + jellyfin: input.runtime.jellyfin, + stats: input.runtime.stats, + mining: input.runtime.mining, + texthookerService: input.runtime.texthookerService, + getYomitanRuntime: () => input.runtime.yomitan, + getCharacterDictionaryDisabledReason: () => + input.runtime.getCharacterDictionaryDisabledReason(), + subsyncRuntime: input.runtime.subsyncRuntime, + dictionarySupport: input.runtime.dictionarySupport, + subtitleWsService: input.runtime.subtitleWsService, + annotationSubtitleWsService: input.runtime.annotationSubtitleWsService, + immersion: input.runtime.immersion, + }, + commands: { + createMpvClientRuntimeService: () => + input.commands.mpvRuntime.createMpvClientRuntimeService(), + createMecabTokenizerAndCheck: () => input.commands.mpvRuntime.createMecabTokenizerAndCheck(), + prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(), + startBackgroundWarmupsIfAllowed: () => input.commands.mpvRuntime.startBackgroundWarmups(), + startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(), + runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(), + startPendingMultiCopy: (timeoutMs) => + input.commands.shortcuts.startPendingMultiCopy(timeoutMs), + startPendingMineSentenceMultiple: (timeoutMs) => + input.commands.shortcuts.startPendingMineSentenceMultiple(timeoutMs), + cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(), + refreshOverlayShortcuts: () => input.commands.shortcuts.refreshOverlayShortcuts(), + hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(), + showMpvOsd: (text) => input.commands.showMpvOsd(text), + shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args), + getConfiguredShortcuts: () => input.commands.shortcuts.getConfiguredShortcuts(), + runYoutubePlaybackFlow: (request) => input.commands.youtube.runYoutubePlaybackFlow(request), + shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) => + input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs), + isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args), + commandNeedsOverlayStartupPrereqs: (args) => + input.commands.commandNeedsOverlayStartupPrereqs(args), + commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args), + handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) => + input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext), + shouldStartApp: (args) => input.commands.shouldStartApp(args), + parseArgs: (argv) => input.commands.parseArgs(argv), + printHelp: input.commands.printHelp, + onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(), + shouldRestoreWindowsOnActivateHandler: () => + input.commands.shouldRestoreWindowsOnActivateHandler(), + restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(), + forceX11Backend: (args) => input.commands.forceX11Backend(args), + enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args), + getDefaultSocketPathHandler: () => input.commands.getDefaultSocketPath(), + generateDefaultConfigFile: input.commands.generateDefaultConfigFile, + runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps), + applyStartupState: (startupState) => input.commands.applyStartupState(startupState), + getStartupModeFlags: input.commands.getStartupModeFlags, + requestAppQuit: input.commands.requestAppQuit, + }, + constants: input.constants, + }); +} diff --git a/src/main/main-startup-runtime-coordinator.ts b/src/main/main-startup-runtime-coordinator.ts new file mode 100644 index 00000000..e2b982a9 --- /dev/null +++ b/src/main/main-startup-runtime-coordinator.ts @@ -0,0 +1,370 @@ +import type { AnilistRuntime } from './anilist-runtime'; +import type { DictionarySupportRuntime } from './dictionary-support-runtime'; +import type { FirstRunRuntime } from './first-run-runtime'; +import type { JellyfinRuntime } from './jellyfin-runtime'; +import { + createMainStartupRuntimeFromMainState, + type MainStartupRuntimeBootstrap, + type MainStartupRuntimeFromMainStateInput, +} from './main-startup-runtime-bootstrap'; +import type { MiningRuntime } from './mining-runtime'; +import type { MpvRuntime } from './mpv-runtime'; +import type { ShortcutsRuntime } from './shortcuts-runtime'; +import type { SubtitleRuntime } from './subtitle-runtime'; +import type { YoutubeRuntime } from './youtube-runtime'; + +export interface MainStartupRuntimeCoordinatorInput { + appState: MainStartupRuntimeFromMainStateInput['appState']; + appLifecycle: MainStartupRuntimeFromMainStateInput['appLifecycle']; + config: MainStartupRuntimeFromMainStateInput['config']; + logging: MainStartupRuntimeFromMainStateInput['logging']; + shell: MainStartupRuntimeFromMainStateInput['shell']; + runtime: { + subtitle: SubtitleRuntime; + getOverlayUi: MainStartupRuntimeFromMainStateInput['runtime']['getOverlayUi']; + overlayManager: MainStartupRuntimeFromMainStateInput['runtime']['overlayManager']; + firstRun: Pick; + anilist: AnilistRuntime; + jellyfin: JellyfinRuntime; + stats: { + ensureImmersionTrackerStarted: MainStartupRuntimeFromMainStateInput['runtime']['stats']['ensureImmersionTrackerStarted']; + runStatsCliCommand: MainStartupRuntimeFromMainStateInput['runtime']['stats']['runStatsCliCommand']; + immersion: MainStartupRuntimeFromMainStateInput['runtime']['immersion']; + }; + mining: { + copyCurrentSubtitle: Pick['copyCurrentSubtitle']; + markLastCardAsAudioCard: Pick< + MiningRuntime, + 'markLastCardAsAudioCard' + >['markLastCardAsAudioCard']; + mineSentenceCard: Pick['mineSentenceCard']; + refreshKnownWordCache: Pick['refreshKnownWordCache']; + triggerFieldGrouping: Pick['triggerFieldGrouping']; + updateLastCardFromClipboard: Pick< + MiningRuntime, + 'updateLastCardFromClipboard' + >['updateLastCardFromClipboard']; + }; + texthookerService: MainStartupRuntimeFromMainStateInput['runtime']['texthookerService']; + yomitan: { + loadYomitanExtension: () => Promise; + ensureYomitanExtensionLoaded: () => Promise; + openYomitanSettings: () => boolean; + getCharacterDictionaryDisabledReason: () => string | null; + }; + subsyncRuntime: MainStartupRuntimeFromMainStateInput['runtime']['subsyncRuntime']; + dictionarySupport: DictionarySupportRuntime; + subtitleWsService: MainStartupRuntimeFromMainStateInput['runtime']['subtitleWsService']; + annotationSubtitleWsService: MainStartupRuntimeFromMainStateInput['runtime']['annotationSubtitleWsService']; + }; + commands: { + mpvRuntime: { + createMpvClientRuntimeService: Pick< + MpvRuntime, + 'createMpvClientRuntimeService' + >['createMpvClientRuntimeService']; + createMecabTokenizerAndCheck: Pick< + MpvRuntime, + 'createMecabTokenizerAndCheck' + >['createMecabTokenizerAndCheck']; + prewarmSubtitleDictionaries: Pick< + MpvRuntime, + 'prewarmSubtitleDictionaries' + >['prewarmSubtitleDictionaries']; + startBackgroundWarmups: Pick['startBackgroundWarmups']; + }; + runHeadlessInitialCommand: MainStartupRuntimeFromMainStateInput['commands']['runHeadlessInitialCommand']; + shortcuts: Pick< + ShortcutsRuntime, + | 'getConfiguredShortcuts' + | 'refreshOverlayShortcuts' + | 'startPendingMineSentenceMultiple' + | 'startPendingMultiCopy' + >; + cycleSecondarySubMode: MainStartupRuntimeFromMainStateInput['commands']['cycleSecondarySubMode']; + hasMpvWebsocketPlugin: MainStartupRuntimeFromMainStateInput['commands']['hasMpvWebsocketPlugin']; + showMpvOsd: MainStartupRuntimeFromMainStateInput['commands']['showMpvOsd']; + shouldAutoOpenFirstRunSetup: MainStartupRuntimeFromMainStateInput['commands']['shouldAutoOpenFirstRunSetup']; + youtube: Pick; + shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeFromMainStateInput['commands']['shouldEnsureTrayOnStartupForInitialArgs']; + isHeadlessInitialCommand: MainStartupRuntimeFromMainStateInput['commands']['isHeadlessInitialCommand']; + commandNeedsOverlayStartupPrereqs: MainStartupRuntimeFromMainStateInput['commands']['commandNeedsOverlayStartupPrereqs']; + commandNeedsOverlayRuntime: MainStartupRuntimeFromMainStateInput['commands']['commandNeedsOverlayRuntime']; + handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeFromMainStateInput['commands']['handleCliCommandRuntimeServiceWithContext']; + shouldStartApp: MainStartupRuntimeFromMainStateInput['commands']['shouldStartApp']; + parseArgs: MainStartupRuntimeFromMainStateInput['commands']['parseArgs']; + printHelp: MainStartupRuntimeFromMainStateInput['commands']['printHelp']; + onWillQuitCleanupHandler: MainStartupRuntimeFromMainStateInput['commands']['onWillQuitCleanupHandler']; + shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeFromMainStateInput['commands']['shouldRestoreWindowsOnActivateHandler']; + restoreWindowsOnActivateHandler: MainStartupRuntimeFromMainStateInput['commands']['restoreWindowsOnActivateHandler']; + forceX11Backend: MainStartupRuntimeFromMainStateInput['commands']['forceX11Backend']; + enforceUnsupportedWaylandMode: MainStartupRuntimeFromMainStateInput['commands']['enforceUnsupportedWaylandMode']; + getDefaultSocketPath: MainStartupRuntimeFromMainStateInput['commands']['getDefaultSocketPath']; + generateDefaultConfigFile: MainStartupRuntimeFromMainStateInput['commands']['generateDefaultConfigFile']; + runStartupBootstrapRuntime: MainStartupRuntimeFromMainStateInput['commands']['runStartupBootstrapRuntime']; + applyStartupState: MainStartupRuntimeFromMainStateInput['commands']['applyStartupState']; + getStartupModeFlags: MainStartupRuntimeFromMainStateInput['commands']['getStartupModeFlags']; + requestAppQuit: MainStartupRuntimeFromMainStateInput['commands']['requestAppQuit']; + }; + constants: MainStartupRuntimeFromMainStateInput['constants']; +} + +export interface MainStartupRuntimeFromProcessStateInput { + appState: MainStartupRuntimeCoordinatorInput['appState']; + appLifecycle: MainStartupRuntimeCoordinatorInput['appLifecycle']; + config: MainStartupRuntimeCoordinatorInput['config']; + logging: MainStartupRuntimeCoordinatorInput['logging']; + shell: MainStartupRuntimeCoordinatorInput['shell']; + runtime: { + subtitle: SubtitleRuntime; + startupOverlayUiAdapter: MainStartupRuntimeCoordinatorInput['runtime']['getOverlayUi'] extends () => infer T + ? T + : never; + overlayManager: MainStartupRuntimeCoordinatorInput['runtime']['overlayManager']; + firstRun: Pick; + anilist: AnilistRuntime; + jellyfin: JellyfinRuntime; + stats: { + ensureImmersionTrackerStarted: MainStartupRuntimeCoordinatorInput['runtime']['stats']['ensureImmersionTrackerStarted']; + runStatsCliCommand: MainStartupRuntimeCoordinatorInput['runtime']['stats']['runStatsCliCommand']; + immersion: MainStartupRuntimeCoordinatorInput['runtime']['stats']['immersion']; + }; + mining: MiningRuntime; + texthookerService: MainStartupRuntimeCoordinatorInput['runtime']['texthookerService']; + yomitan: { + loadYomitanExtension: () => Promise; + ensureYomitanExtensionLoaded: () => Promise; + openYomitanSettings: () => boolean; + getCharacterDictionaryDisabledReason: () => string | null; + }; + subsyncRuntime: MainStartupRuntimeCoordinatorInput['runtime']['subsyncRuntime']; + dictionarySupport: DictionarySupportRuntime; + subtitleWsService: MainStartupRuntimeCoordinatorInput['runtime']['subtitleWsService']; + annotationSubtitleWsService: MainStartupRuntimeCoordinatorInput['runtime']['annotationSubtitleWsService']; + }; + commands: { + mpvRuntime: MpvRuntime; + runHeadlessInitialCommand: MainStartupRuntimeCoordinatorInput['commands']['runHeadlessInitialCommand']; + shortcuts: Pick< + ShortcutsRuntime, + | 'getConfiguredShortcuts' + | 'refreshOverlayShortcuts' + | 'startPendingMineSentenceMultiple' + | 'startPendingMultiCopy' + >; + cycleSecondarySubMode: MainStartupRuntimeCoordinatorInput['commands']['cycleSecondarySubMode']; + hasMpvWebsocketPlugin: MainStartupRuntimeCoordinatorInput['commands']['hasMpvWebsocketPlugin']; + showMpvOsd: MainStartupRuntimeCoordinatorInput['commands']['showMpvOsd']; + shouldAutoOpenFirstRunSetup: MainStartupRuntimeCoordinatorInput['commands']['shouldAutoOpenFirstRunSetup']; + youtube: YoutubeRuntime; + shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeCoordinatorInput['commands']['shouldEnsureTrayOnStartupForInitialArgs']; + isHeadlessInitialCommand: MainStartupRuntimeCoordinatorInput['commands']['isHeadlessInitialCommand']; + commandNeedsOverlayStartupPrereqs: MainStartupRuntimeCoordinatorInput['commands']['commandNeedsOverlayStartupPrereqs']; + commandNeedsOverlayRuntime: MainStartupRuntimeCoordinatorInput['commands']['commandNeedsOverlayRuntime']; + handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeCoordinatorInput['commands']['handleCliCommandRuntimeServiceWithContext']; + shouldStartApp: MainStartupRuntimeCoordinatorInput['commands']['shouldStartApp']; + parseArgs: MainStartupRuntimeCoordinatorInput['commands']['parseArgs']; + printHelp: MainStartupRuntimeCoordinatorInput['commands']['printHelp']; + onWillQuitCleanupHandler: MainStartupRuntimeCoordinatorInput['commands']['onWillQuitCleanupHandler']; + shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeCoordinatorInput['commands']['shouldRestoreWindowsOnActivateHandler']; + restoreWindowsOnActivateHandler: MainStartupRuntimeCoordinatorInput['commands']['restoreWindowsOnActivateHandler']; + forceX11Backend: MainStartupRuntimeCoordinatorInput['commands']['forceX11Backend']; + enforceUnsupportedWaylandMode: MainStartupRuntimeCoordinatorInput['commands']['enforceUnsupportedWaylandMode']; + getDefaultSocketPath: MainStartupRuntimeCoordinatorInput['commands']['getDefaultSocketPath']; + generateDefaultConfigFile: MainStartupRuntimeCoordinatorInput['commands']['generateDefaultConfigFile']; + runStartupBootstrapRuntime: MainStartupRuntimeCoordinatorInput['commands']['runStartupBootstrapRuntime']; + applyStartupState: MainStartupRuntimeCoordinatorInput['commands']['applyStartupState']; + getStartupModeFlags: MainStartupRuntimeCoordinatorInput['commands']['getStartupModeFlags']; + requestAppQuit: MainStartupRuntimeCoordinatorInput['commands']['requestAppQuit']; + }; + constants: MainStartupRuntimeCoordinatorInput['constants']; +} + +export function createMainStartupRuntimeCoordinator( + input: MainStartupRuntimeCoordinatorInput, +): MainStartupRuntimeBootstrap { + return createMainStartupRuntimeFromMainState({ + appState: input.appState, + appLifecycle: input.appLifecycle, + config: input.config, + logging: input.logging, + shell: input.shell, + runtime: { + subtitle: { + loadSubtitlePosition: () => input.runtime.subtitle.loadSubtitlePosition(), + invalidateTokenizationCache: () => { + input.runtime.subtitle.invalidateTokenizationCache(); + }, + refreshSubtitlePrefetchFromActiveTrack: () => + input.runtime.subtitle.refreshSubtitlePrefetchFromActiveTrack(), + }, + getOverlayUi: () => input.runtime.getOverlayUi(), + overlayManager: input.runtime.overlayManager, + firstRun: input.runtime.firstRun, + anilist: input.runtime.anilist, + jellyfin: { + startJellyfinRemoteSession: () => input.runtime.jellyfin.startJellyfinRemoteSession(), + openJellyfinSetupWindow: () => input.runtime.jellyfin.openJellyfinSetupWindow(), + runJellyfinCommand: (argsFromCommand) => + input.runtime.jellyfin.runJellyfinCommand(argsFromCommand), + }, + stats: { + ensureImmersionTrackerStarted: () => input.runtime.stats.ensureImmersionTrackerStarted(), + runStatsCliCommand: (argsFromCommand, source) => + input.runtime.stats.runStatsCliCommand(argsFromCommand, source), + }, + mining: { + copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(), + mineSentenceCard: () => input.runtime.mining.mineSentenceCard(), + updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(), + refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(), + triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(), + markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(), + }, + texthookerService: input.runtime.texthookerService, + yomitan: { + loadYomitanExtension: () => input.runtime.yomitan.loadYomitanExtension(), + ensureYomitanExtensionLoaded: () => input.runtime.yomitan.ensureYomitanExtensionLoaded(), + openYomitanSettings: () => input.runtime.yomitan.openYomitanSettings(), + }, + getCharacterDictionaryDisabledReason: () => + input.runtime.yomitan.getCharacterDictionaryDisabledReason(), + subsyncRuntime: input.runtime.subsyncRuntime, + dictionarySupport: input.runtime.dictionarySupport, + subtitleWsService: input.runtime.subtitleWsService, + annotationSubtitleWsService: input.runtime.annotationSubtitleWsService, + immersion: input.runtime.stats.immersion, + }, + commands: { + mpvRuntime: { + createMpvClientRuntimeService: () => + input.commands.mpvRuntime.createMpvClientRuntimeService(), + createMecabTokenizerAndCheck: () => + input.commands.mpvRuntime.createMecabTokenizerAndCheck(), + prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(), + startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(), + }, + runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(), + shortcuts: { + startPendingMultiCopy: (timeoutMs) => + input.commands.shortcuts.startPendingMultiCopy(timeoutMs), + startPendingMineSentenceMultiple: (timeoutMs) => + input.commands.shortcuts.startPendingMineSentenceMultiple(timeoutMs), + refreshOverlayShortcuts: () => input.commands.shortcuts.refreshOverlayShortcuts(), + getConfiguredShortcuts: () => input.commands.shortcuts.getConfiguredShortcuts(), + }, + cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(), + hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(), + showMpvOsd: (text) => input.commands.showMpvOsd(text), + shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args), + youtube: { + runYoutubePlaybackFlow: (request) => input.commands.youtube.runYoutubePlaybackFlow(request), + }, + shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) => + input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs ?? null), + isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args), + commandNeedsOverlayStartupPrereqs: (args) => + input.commands.commandNeedsOverlayStartupPrereqs(args), + commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args), + handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) => + input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext), + shouldStartApp: (args) => input.commands.shouldStartApp(args), + parseArgs: (argv) => input.commands.parseArgs(argv), + printHelp: input.commands.printHelp, + onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(), + shouldRestoreWindowsOnActivateHandler: () => + input.commands.shouldRestoreWindowsOnActivateHandler(), + restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(), + forceX11Backend: (args) => input.commands.forceX11Backend(args), + enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args), + getDefaultSocketPath: () => input.commands.getDefaultSocketPath(), + generateDefaultConfigFile: input.commands.generateDefaultConfigFile, + runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps), + applyStartupState: (startupState) => input.commands.applyStartupState(startupState), + getStartupModeFlags: input.commands.getStartupModeFlags, + requestAppQuit: input.commands.requestAppQuit, + }, + constants: input.constants, + }); +} + +export function createMainStartupRuntimeFromProcessState( + input: MainStartupRuntimeFromProcessStateInput, +) { + return createMainStartupRuntimeCoordinator({ + appState: input.appState, + appLifecycle: input.appLifecycle, + config: input.config, + logging: input.logging, + shell: input.shell, + runtime: { + subtitle: input.runtime.subtitle, + getOverlayUi: () => input.runtime.startupOverlayUiAdapter, + overlayManager: input.runtime.overlayManager, + firstRun: input.runtime.firstRun, + anilist: input.runtime.anilist, + jellyfin: input.runtime.jellyfin, + stats: { + ensureImmersionTrackerStarted: () => input.runtime.stats.ensureImmersionTrackerStarted(), + runStatsCliCommand: (argsFromCommand, source) => + input.runtime.stats.runStatsCliCommand(argsFromCommand, source), + immersion: input.runtime.stats.immersion, + }, + mining: { + copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(), + markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(), + mineSentenceCard: () => input.runtime.mining.mineSentenceCard(), + refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(), + triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(), + updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(), + }, + texthookerService: input.runtime.texthookerService, + yomitan: input.runtime.yomitan, + subsyncRuntime: input.runtime.subsyncRuntime, + dictionarySupport: input.runtime.dictionarySupport, + subtitleWsService: input.runtime.subtitleWsService, + annotationSubtitleWsService: input.runtime.annotationSubtitleWsService, + }, + commands: { + mpvRuntime: { + createMpvClientRuntimeService: () => + input.commands.mpvRuntime.createMpvClientRuntimeService(), + createMecabTokenizerAndCheck: () => + input.commands.mpvRuntime.createMecabTokenizerAndCheck(), + prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(), + startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(), + }, + runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(), + shortcuts: input.commands.shortcuts, + cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(), + hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(), + showMpvOsd: (text) => input.commands.showMpvOsd(text), + shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args), + youtube: input.commands.youtube, + shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) => + input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs ?? null), + isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args), + commandNeedsOverlayStartupPrereqs: (args) => + input.commands.commandNeedsOverlayStartupPrereqs(args), + commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args), + handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) => + input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext), + shouldStartApp: (args) => input.commands.shouldStartApp(args), + parseArgs: (argv) => input.commands.parseArgs(argv), + printHelp: input.commands.printHelp, + onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(), + shouldRestoreWindowsOnActivateHandler: () => + input.commands.shouldRestoreWindowsOnActivateHandler(), + restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(), + forceX11Backend: (args) => input.commands.forceX11Backend(args), + enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args), + getDefaultSocketPath: () => input.commands.getDefaultSocketPath(), + generateDefaultConfigFile: input.commands.generateDefaultConfigFile, + runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps), + applyStartupState: (startupState) => input.commands.applyStartupState(startupState), + getStartupModeFlags: input.commands.getStartupModeFlags, + requestAppQuit: input.commands.requestAppQuit, + }, + constants: input.constants, + }).startupRuntime; +} diff --git a/src/main/main-startup-runtime.test.ts b/src/main/main-startup-runtime.test.ts new file mode 100644 index 00000000..1b5f6c64 --- /dev/null +++ b/src/main/main-startup-runtime.test.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createMainStartupRuntime } from './main-startup-runtime'; + +test('main startup runtime composes app-ready, cli, and headless runtimes', async () => { + const calls: string[] = []; + + const runtime = createMainStartupRuntime<{ mode: string }>({ + appReady: { + reload: { + reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }), + logInfo: () => {}, + logWarning: () => {}, + showDesktopNotification: () => {}, + startConfigHotReload: () => {}, + refreshAnilistClientSecretState: async () => undefined, + failHandlers: { + logError: () => {}, + showErrorBox: () => {}, + quit: () => {}, + }, + }, + criticalConfig: { + getConfigPath: () => '/tmp/config.jsonc', + failHandlers: { + logError: () => {}, + showErrorBox: () => {}, + quit: () => {}, + }, + }, + immersion: { + getResolvedConfig: () => ({ immersionTracking: { enabled: false } }) as never, + getConfiguredDbPath: () => '/tmp/immersion.sqlite', + createTrackerService: () => ({}) as never, + setTracker: () => {}, + getMpvClient: () => null, + seedTrackerFromCurrentMedia: () => {}, + logInfo: () => {}, + logDebug: () => {}, + logWarn: () => {}, + }, + runner: { + ensureDefaultConfigBootstrap: () => { + calls.push('ensureDefaultConfigBootstrap'); + }, + getSubtitlePosition: () => null, + loadSubtitlePosition: () => { + calls.push('loadSubtitlePosition'); + }, + getKeybindingsCount: () => 0, + resolveKeybindings: () => { + calls.push('resolveKeybindings'); + }, + hasMpvClient: () => false, + createMpvClient: () => { + calls.push('createMpvClient'); + }, + getRuntimeOptionsManager: () => null, + initRuntimeOptionsManager: () => { + calls.push('initRuntimeOptionsManager'); + }, + getSubtitleTimingTracker: () => null, + createSubtitleTimingTracker: () => { + calls.push('createSubtitleTimingTracker'); + }, + getResolvedConfig: () => ({ ankiConnect: { enabled: false } }) as never, + getConfigWarnings: () => [], + logConfigWarning: () => {}, + setLogLevel: () => {}, + setSecondarySubMode: () => {}, + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: 5174, + defaultAnnotationWebsocketPort: 6678, + defaultTexthookerPort: 5174, + hasMpvWebsocketPlugin: () => false, + startSubtitleWebsocket: () => {}, + startAnnotationWebsocket: () => {}, + startTexthooker: () => {}, + log: () => {}, + createMecabTokenizerAndCheck: async () => {}, + loadYomitanExtension: async () => {}, + ensureYomitanExtensionLoaded: async () => { + calls.push('ensureYomitanExtensionLoaded'); + }, + handleFirstRunSetup: async () => {}, + startBackgroundWarmups: () => { + calls.push('startBackgroundWarmups'); + }, + texthookerOnlyMode: false, + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + setVisibleOverlayVisible: () => {}, + initializeOverlayRuntime: () => { + calls.push('initializeOverlayRuntime'); + }, + ensureOverlayWindowsReadyForVisibilityActions: () => { + calls.push('ensureOverlayWindowsReadyForVisibilityActions'); + }, + handleInitialArgs: () => { + calls.push('handleInitialArgs'); + }, + }, + isOverlayRuntimeInitialized: () => false, + }, + cli: { + appState: { + appState: {} as never, + getInitialArgs: () => null, + isBackgroundMode: () => false, + isTexthookerOnlyMode: () => false, + setTexthookerOnlyMode: () => {}, + hasImmersionTracker: () => false, + getMpvClient: () => null, + isOverlayRuntimeInitialized: () => false, + }, + config: { + defaultConfig: { websocket: { port: 6677 }, annotationWebsocket: { port: 6678 } } as never, + getResolvedConfig: () => ({}) as never, + setCliLogLevel: () => {}, + hasMpvWebsocketPlugin: () => false, + }, + io: { + texthookerService: {} as never, + openExternal: async () => {}, + logBrowserOpenError: () => {}, + showMpvOsd: () => {}, + schedule: () => 0 as never, + logInfo: () => {}, + logWarn: () => {}, + logError: () => {}, + }, + commands: { + initializeOverlayRuntime: () => {}, + toggleVisibleOverlay: () => {}, + openFirstRunSetupWindow: () => {}, + setVisibleOverlayVisible: () => {}, + copyCurrentSubtitle: () => {}, + startPendingMultiCopy: () => {}, + mineSentenceCard: async () => {}, + startPendingMineSentenceMultiple: () => {}, + updateLastCardFromClipboard: async () => {}, + refreshKnownWordCache: async () => {}, + triggerFieldGrouping: async () => {}, + triggerSubsyncFromConfig: async () => {}, + markLastCardAsAudioCard: async () => {}, + getAnilistStatus: () => ({}) as never, + clearAnilistToken: () => {}, + openAnilistSetupWindow: () => {}, + openJellyfinSetupWindow: () => {}, + getAnilistQueueStatus: () => ({}) as never, + processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }), + generateCharacterDictionary: async () => ({ + zipPath: '/tmp/test.zip', + fromCache: false, + mediaId: 1, + mediaTitle: 'Test', + entryCount: 1, + }), + runJellyfinCommand: async () => {}, + runStatsCommand: async () => {}, + runYoutubePlaybackFlow: async () => {}, + openYomitanSettings: () => {}, + cycleSecondarySubMode: () => {}, + openRuntimeOptionsPalette: () => {}, + printHelp: () => {}, + stopApp: () => { + calls.push('stopApp'); + }, + hasMainWindow: () => false, + getMultiCopyTimeoutMs: () => 0, + }, + startup: { + shouldEnsureTrayOnStartup: () => false, + shouldRunHeadlessInitialCommand: () => false, + ensureTray: () => {}, + commandNeedsOverlayStartupPrereqs: () => false, + commandNeedsOverlayRuntime: () => false, + ensureOverlayStartupPrereqs: () => { + calls.push('ensureOverlayStartupPrereqs'); + }, + startBackgroundWarmups: () => { + calls.push('startupStartBackgroundWarmups'); + }, + }, + handleCliCommandRuntimeServiceWithContext: (args) => { + calls.push(`handle:${(args as { command?: string }).command ?? 'unknown'}`); + }, + }, + headless: { + appLifecycleRuntimeRunnerMainDeps: { + app: { on: () => {} } as never, + platform: 'darwin', + shouldStartApp: () => true, + parseArgs: () => ({}) as never, + handleCliCommand: () => {}, + printHelp: () => {}, + logNoRunningInstance: () => {}, + onReady: async () => {}, + onWillQuitCleanup: () => {}, + shouldRestoreWindowsOnActivate: () => false, + restoreWindowsOnActivate: () => {}, + shouldQuitOnWindowAllClosed: () => false, + }, + createAppLifecycleRuntimeRunner: () => (args) => { + calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`); + }, + bootstrap: { + argv: ['node', 'main.js'], + parseArgs: () => ({ command: 'start' }) as never, + setLogLevel: () => {}, + forceX11Backend: () => {}, + enforceUnsupportedWaylandMode: () => {}, + shouldStartApp: () => true, + getDefaultSocketPath: () => '/tmp/mpv.sock', + defaultTexthookerPort: 5174, + configDir: '/tmp/config', + defaultConfig: {} as never, + generateConfigTemplate: () => 'template', + generateDefaultConfigFile: async () => 0, + setExitCode: () => {}, + quitApp: () => {}, + logGenerateConfigError: () => {}, + startAppLifecycle: () => {}, + }, + runStartupBootstrapRuntime: (deps) => { + deps.startAppLifecycle({ command: 'start' } as never); + return { mode: 'started' }; + }, + applyStartupState: (state: { mode: string }) => { + calls.push(`apply:${state.mode}`); + }, + }, + }); + + assert.equal(typeof runtime.appReady.runAppReady, 'function'); + assert.equal(typeof runtime.cliStartup.handleCliCommand, 'function'); + assert.equal(typeof runtime.headlessStartup.runAndApplyStartupState, 'function'); + assert.equal(runtime.handleCliCommand, runtime.cliStartup.handleCliCommand); + assert.equal(runtime.handleInitialArgs, runtime.cliStartup.handleInitialArgs); + assert.equal( + runtime.appLifecycleRuntimeRunner, + runtime.headlessStartup.appLifecycleRuntimeRunner, + ); + assert.equal(runtime.runAndApplyStartupState, runtime.headlessStartup.runAndApplyStartupState); + + runtime.appReady.ensureOverlayStartupPrereqs(); + runtime.handleCliCommand({ command: 'start' } as never); + assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' }); + + assert.deepEqual(calls, [ + 'loadSubtitlePosition', + 'resolveKeybindings', + 'createMpvClient', + 'initRuntimeOptionsManager', + 'createSubtitleTimingTracker', + 'handle:start', + 'lifecycle:start', + 'apply:started', + ]); +}); diff --git a/src/main/main-startup-runtime.ts b/src/main/main-startup-runtime.ts new file mode 100644 index 00000000..2c953196 --- /dev/null +++ b/src/main/main-startup-runtime.ts @@ -0,0 +1,43 @@ +import type { AppReadyRuntimeInput, AppReadyRuntime } from './app-ready-runtime'; +import type { CliStartupRuntimeInput, CliStartupRuntime } from './cli-startup-runtime'; +import type { + HeadlessStartupRuntimeInput, + HeadlessStartupRuntime, +} from './headless-startup-runtime'; +import { createAppReadyRuntime } from './app-ready-runtime'; +import { createCliStartupRuntime } from './cli-startup-runtime'; +import { createHeadlessStartupRuntime } from './headless-startup-runtime'; + +export interface MainStartupRuntimeInput { + appReady: AppReadyRuntimeInput; + cli: CliStartupRuntimeInput; + headless: HeadlessStartupRuntimeInput; +} + +export interface MainStartupRuntime { + appReady: AppReadyRuntime; + cliStartup: CliStartupRuntime; + headlessStartup: HeadlessStartupRuntime; + handleCliCommand: CliStartupRuntime['handleCliCommand']; + handleInitialArgs: CliStartupRuntime['handleInitialArgs']; + appLifecycleRuntimeRunner: HeadlessStartupRuntime['appLifecycleRuntimeRunner']; + runAndApplyStartupState: HeadlessStartupRuntime['runAndApplyStartupState']; +} + +export function createMainStartupRuntime( + input: MainStartupRuntimeInput, +): MainStartupRuntime { + const appReady = createAppReadyRuntime(input.appReady); + const cliStartup = createCliStartupRuntime(input.cli); + const headlessStartup = createHeadlessStartupRuntime(input.headless); + + return { + appReady, + cliStartup, + headlessStartup, + handleCliCommand: cliStartup.handleCliCommand, + handleInitialArgs: cliStartup.handleInitialArgs, + appLifecycleRuntimeRunner: headlessStartup.appLifecycleRuntimeRunner, + runAndApplyStartupState: headlessStartup.runAndApplyStartupState, + }; +} diff --git a/src/main/media-runtime.ts b/src/main/media-runtime.ts index a994c93b..cba42a43 100644 --- a/src/main/media-runtime.ts +++ b/src/main/media-runtime.ts @@ -1,4 +1,4 @@ -import { updateCurrentMediaPath } from '../core/services'; +import { updateCurrentMediaPath } from '../core/services/subtitle-position'; import type { SubtitlePosition } from '../types'; diff --git a/src/main/mining-runtime.ts b/src/main/mining-runtime.ts new file mode 100644 index 00000000..f9c004d8 --- /dev/null +++ b/src/main/mining-runtime.ts @@ -0,0 +1,163 @@ +import type { SubtitleTimingTracker } from '../subtitle-timing-tracker'; +import { appendClipboardVideoToQueueRuntime } from './runtime/clipboard-queue'; +import { + createUpdateLastCardFromClipboardHandler, + createRefreshKnownWordCacheHandler, + createTriggerFieldGroupingHandler, + createMarkLastCardAsAudioCardHandler, + createMineSentenceCardHandler, +} from './runtime/anki-actions'; +import { + createHandleMultiCopyDigitHandler, + createCopyCurrentSubtitleHandler, + createHandleMineSentenceDigitHandler, +} from './runtime/mining-actions'; + +export interface MiningRuntimeInput { + getSubtitleTimingTracker: () => SubtitleTimingTracker; + getAnkiIntegration: () => TAnkiIntegration; + getMpvClient: () => TMpvClient; + readClipboardText: () => string; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + sendMpvCommand: (command: (string | number)[]) => void; + updateLastCardFromClipboardCore: (options: { + ankiIntegration: TAnkiIntegration; + readClipboardText: () => string; + showMpvOsd: (text: string) => void; + }) => Promise; + triggerFieldGroupingCore: (options: { + ankiIntegration: TAnkiIntegration; + showMpvOsd: (text: string) => void; + }) => Promise; + markLastCardAsAudioCardCore: (options: { + ankiIntegration: TAnkiIntegration; + showMpvOsd: (text: string) => void; + }) => Promise; + mineSentenceCardCore: (options: { + ankiIntegration: TAnkiIntegration; + mpvClient: TMpvClient; + showMpvOsd: (text: string) => void; + }) => Promise; + handleMultiCopyDigitCore: ( + count: number, + options: { + subtitleTimingTracker: SubtitleTimingTracker; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + }, + ) => void; + copyCurrentSubtitleCore: (options: { + subtitleTimingTracker: SubtitleTimingTracker; + writeClipboardText: (text: string) => void; + showMpvOsd: (text: string) => void; + }) => void; + handleMineSentenceDigitCore: ( + count: number, + options: { + subtitleTimingTracker: SubtitleTimingTracker; + ankiIntegration: TAnkiIntegration; + getCurrentSecondarySubText: () => string | undefined; + showMpvOsd: (text: string) => void; + logError: (message: string, err: unknown) => void; + onCardsMined: (count: number) => void; + }, + ) => void; + getCurrentSecondarySubText: () => string | undefined; + logError: (message: string, err: unknown) => void; + recordCardsMined: (count: number, noteIds?: number[]) => void; +} + +export interface MiningRuntime { + updateLastCardFromClipboard: () => Promise; + refreshKnownWordCache: () => Promise; + triggerFieldGrouping: () => Promise; + markLastCardAsAudioCard: () => Promise; + mineSentenceCard: () => Promise; + handleMultiCopyDigit: (count: number) => void; + copyCurrentSubtitle: () => void; + handleMineSentenceDigit: (count: number) => void; + appendClipboardVideoToQueue: () => { ok: boolean; message: string }; +} + +export function createMiningRuntime( + input: MiningRuntimeInput, +): MiningRuntime { + const updateLastCardFromClipboard = createUpdateLastCardFromClipboardHandler({ + getAnkiIntegration: () => input.getAnkiIntegration(), + readClipboardText: () => input.readClipboardText(), + showMpvOsd: (text) => input.showMpvOsd(text), + updateLastCardFromClipboardCore: (options) => input.updateLastCardFromClipboardCore(options), + }); + + const refreshKnownWordCache = createRefreshKnownWordCacheHandler({ + getAnkiIntegration: () => + input.getAnkiIntegration() as { refreshKnownWordCache: () => Promise } | null, + missingIntegrationMessage: 'AnkiConnect integration not enabled', + }); + + const triggerFieldGrouping = createTriggerFieldGroupingHandler({ + getAnkiIntegration: () => input.getAnkiIntegration(), + showMpvOsd: (text) => input.showMpvOsd(text), + triggerFieldGroupingCore: (options) => input.triggerFieldGroupingCore(options), + }); + + const markLastCardAsAudioCard = createMarkLastCardAsAudioCardHandler({ + getAnkiIntegration: () => input.getAnkiIntegration(), + showMpvOsd: (text) => input.showMpvOsd(text), + markLastCardAsAudioCardCore: (options) => input.markLastCardAsAudioCardCore(options), + }); + + const mineSentenceCard = createMineSentenceCardHandler({ + getAnkiIntegration: () => input.getAnkiIntegration(), + getMpvClient: () => input.getMpvClient(), + showMpvOsd: (text) => input.showMpvOsd(text), + mineSentenceCardCore: (options) => input.mineSentenceCardCore(options), + recordCardsMined: (count, noteIds) => input.recordCardsMined(count, noteIds), + }); + + const handleMultiCopyDigit = createHandleMultiCopyDigitHandler({ + getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(), + writeClipboardText: (text) => input.writeClipboardText(text), + showMpvOsd: (text) => input.showMpvOsd(text), + handleMultiCopyDigitCore: (count, options) => input.handleMultiCopyDigitCore(count, options), + }); + + const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({ + getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(), + writeClipboardText: (text) => input.writeClipboardText(text), + showMpvOsd: (text) => input.showMpvOsd(text), + copyCurrentSubtitleCore: (options) => input.copyCurrentSubtitleCore(options), + }); + + const handleMineSentenceDigit = createHandleMineSentenceDigitHandler({ + getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(), + getAnkiIntegration: () => input.getAnkiIntegration(), + getCurrentSecondarySubText: () => input.getCurrentSecondarySubText(), + showMpvOsd: (text) => input.showMpvOsd(text), + logError: (message, err) => input.logError(message, err), + onCardsMined: (count) => input.recordCardsMined(count), + handleMineSentenceDigitCore: (count, options) => + input.handleMineSentenceDigitCore(count, options), + }); + + const appendClipboardVideoToQueue = (): { ok: boolean; message: string } => + appendClipboardVideoToQueueRuntime({ + getMpvClient: () => input.getMpvClient() as { connected: boolean } | null, + readClipboardText: () => input.readClipboardText(), + showMpvOsd: (text) => input.showMpvOsd(text), + sendMpvCommand: (command) => input.sendMpvCommand(command), + }); + + return { + updateLastCardFromClipboard, + refreshKnownWordCache, + triggerFieldGrouping, + markLastCardAsAudioCard, + mineSentenceCard, + handleMultiCopyDigit, + copyCurrentSubtitle, + handleMineSentenceDigit, + appendClipboardVideoToQueue, + }; +} diff --git a/src/main/mpv-runtime-bootstrap.ts b/src/main/mpv-runtime-bootstrap.ts new file mode 100644 index 00000000..1e79554d --- /dev/null +++ b/src/main/mpv-runtime-bootstrap.ts @@ -0,0 +1,285 @@ +import type { MpvRuntime, MpvRuntimeInput } from './mpv-runtime'; +import { createMpvRuntime } from './mpv-runtime'; +import type { SubtitleRuntime } from './subtitle-runtime'; +import type { DictionarySupportRuntime } from './dictionary-support-runtime'; +import type { createCurrentMediaTokenizationGate } from './runtime/current-media-tokenization-gate'; +import type { createStartupOsdSequencer } from './runtime/startup-osd-sequencer'; +import type { AnilistRuntime } from './anilist-runtime'; +import type { JellyfinRuntime } from './jellyfin-runtime'; +import type { YoutubeRuntime } from './youtube-runtime'; + +export interface MpvRuntimeBootstrapInput { + appState: MpvRuntimeInput['appState']; + logPath: string; + logger: MpvRuntimeInput['logger']; + getResolvedConfig: MpvRuntimeInput['getResolvedConfig']; + getRuntimeBooleanOption: MpvRuntimeInput['getRuntimeBooleanOption']; + subtitle: MpvRuntimeInput['subtitle']; + ensureYomitanExtensionLoaded: MpvRuntimeInput['ensureYomitanExtensionLoaded']; + currentMediaTokenizationGate: MpvRuntimeInput['currentMediaTokenizationGate']; + startupOsdSequencer: MpvRuntimeInput['startupOsdSequencer']; + dictionarySupport: { + ensureJlptDictionaryLookup: () => Promise; + ensureFrequencyDictionaryLookup: () => Promise; + syncImmersionMediaState: () => void; + updateCurrentMediaPath: (mediaPath: unknown) => void; + updateCurrentMediaTitle: (mediaTitle: unknown) => void; + scheduleCharacterDictionarySync: () => void; + }; + overlay: { + overlayManager: { + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + getVisibleOverlayVisible: () => boolean; + }; + getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined; + }; + lifecycle: { + requestAppQuit: () => void; + setQuitCheckTimer: (callback: () => void, timeoutMs: number) => void; + restoreOverlayMpvSubtitles: () => void; + syncOverlayMpvSubtitleSuppression: () => void; + publishDiscordPresence: () => void; + }; + stats: MpvRuntimeInput['stats']; + anilist: MpvRuntimeInput['anilist']; + jellyfin: MpvRuntimeInput['jellyfin']; + youtube: MpvRuntimeInput['youtube']; + isCharacterDictionaryEnabled: MpvRuntimeInput['isCharacterDictionaryEnabled']; +} + +export interface MpvRuntimeBootstrap { + mpvRuntime: MpvRuntime; +} + +export interface MpvRuntimeFromMainStateInput { + appState: MpvRuntimeInput['appState']; + logPath: string; + logger: MpvRuntimeInput['logger']; + getResolvedConfig: MpvRuntimeInput['getResolvedConfig']; + getRuntimeBooleanOption: MpvRuntimeInput['getRuntimeBooleanOption']; + subtitle: SubtitleRuntime; + yomitan: { + ensureYomitanExtensionLoaded: () => Promise; + }; + currentMediaTokenizationGate: ReturnType; + startupOsdSequencer: ReturnType; + dictionarySupport: DictionarySupportRuntime; + overlay: { + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + getVisibleOverlayVisible: () => boolean; + getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined; + }; + lifecycle: { + requestAppQuit: () => void; + setQuitCheckTimer: (callback: () => void, timeoutMs: number) => void; + restoreOverlayMpvSubtitles: () => void; + syncOverlayMpvSubtitleSuppression: () => void; + publishDiscordPresence: () => void; + }; + stats: { + ensureImmersionTrackerStarted: () => void; + }; + anilist: AnilistRuntime; + jellyfin: JellyfinRuntime; + youtube: YoutubeRuntime; + isCharacterDictionaryEnabled: () => boolean; +} + +export function createMpvRuntimeBootstrap(input: MpvRuntimeBootstrapInput): MpvRuntimeBootstrap { + const mpvRuntime = createMpvRuntime({ + appState: input.appState, + logPath: input.logPath, + logger: input.logger, + getResolvedConfig: input.getResolvedConfig, + getRuntimeBooleanOption: input.getRuntimeBooleanOption, + subtitle: input.subtitle, + ensureYomitanExtensionLoaded: input.ensureYomitanExtensionLoaded, + currentMediaTokenizationGate: input.currentMediaTokenizationGate, + startupOsdSequencer: input.startupOsdSequencer, + dictionaries: { + ensureJlptDictionaryLookup: () => input.dictionarySupport.ensureJlptDictionaryLookup(), + ensureFrequencyDictionaryLookup: () => + input.dictionarySupport.ensureFrequencyDictionaryLookup(), + }, + mediaRuntime: { + syncImmersionMediaState: () => input.dictionarySupport.syncImmersionMediaState(), + updateCurrentMediaPath: (mediaPath) => { + input.dictionarySupport.updateCurrentMediaPath(mediaPath); + }, + updateCurrentMediaTitle: (mediaTitle) => { + input.dictionarySupport.updateCurrentMediaTitle(mediaTitle); + }, + }, + characterDictionaryAutoSyncRuntime: { + scheduleSync: () => { + input.dictionarySupport.scheduleCharacterDictionarySync(); + }, + }, + overlay: { + broadcastToOverlayWindows: (channel, payload) => { + input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload); + }, + getVisibleOverlayVisible: () => input.overlay.overlayManager.getVisibleOverlayVisible(), + setOverlayVisible: (visible) => { + input.overlay.getOverlayUi()?.setOverlayVisible(visible); + }, + }, + lifecycle: { + requestAppQuit: () => input.lifecycle.requestAppQuit(), + scheduleQuitCheck: (callback) => { + input.lifecycle.setQuitCheckTimer(callback, 500); + }, + restoreOverlayMpvSubtitles: () => { + input.lifecycle.restoreOverlayMpvSubtitles(); + }, + syncOverlayMpvSubtitleSuppression: () => { + input.lifecycle.syncOverlayMpvSubtitleSuppression(); + }, + refreshDiscordPresence: () => { + input.lifecycle.publishDiscordPresence(); + }, + }, + stats: input.stats, + anilist: input.anilist, + jellyfin: input.jellyfin, + youtube: input.youtube, + isCharacterDictionaryEnabled: input.isCharacterDictionaryEnabled, + }); + + return { + mpvRuntime, + }; +} + +export function createMpvRuntimeFromMainState( + input: MpvRuntimeFromMainStateInput, +): MpvRuntimeBootstrap { + return createMpvRuntimeBootstrap({ + appState: input.appState, + logPath: input.logPath, + logger: input.logger, + getResolvedConfig: input.getResolvedConfig, + getRuntimeBooleanOption: input.getRuntimeBooleanOption, + subtitle: { + consumeCachedSubtitle: (text) => input.subtitle.consumeCachedSubtitle(text), + emitSubtitlePayload: (payload) => input.subtitle.emitSubtitlePayload(payload), + onSubtitleChange: (text) => { + input.subtitle.onSubtitleChange(text); + }, + onCurrentMediaPathChange: (path) => { + input.subtitle.onCurrentMediaPathChange(path); + }, + onTimePosUpdate: (time) => { + input.subtitle.onTimePosUpdate(time); + }, + scheduleSubtitlePrefetchRefresh: (delayMs) => + input.subtitle.scheduleSubtitlePrefetchRefresh(delayMs), + loadSubtitleSourceText: (source) => input.subtitle.loadSubtitleSourceText(source), + setTokenizeSubtitleDeferred: (tokenize) => { + input.subtitle.setTokenizeSubtitleDeferred(tokenize); + }, + }, + ensureYomitanExtensionLoaded: async () => { + await input.yomitan.ensureYomitanExtensionLoaded(); + }, + currentMediaTokenizationGate: input.currentMediaTokenizationGate, + startupOsdSequencer: input.startupOsdSequencer, + dictionarySupport: { + ensureJlptDictionaryLookup: () => input.dictionarySupport.ensureJlptDictionaryLookup(), + ensureFrequencyDictionaryLookup: () => + input.dictionarySupport.ensureFrequencyDictionaryLookup(), + syncImmersionMediaState: () => { + input.dictionarySupport.syncImmersionMediaState(); + }, + updateCurrentMediaPath: (mediaPath) => { + input.dictionarySupport.updateCurrentMediaPath(mediaPath); + }, + updateCurrentMediaTitle: (mediaTitle) => { + input.dictionarySupport.updateCurrentMediaTitle(mediaTitle); + }, + scheduleCharacterDictionarySync: () => { + input.dictionarySupport.scheduleCharacterDictionarySync(); + }, + }, + overlay: { + overlayManager: { + broadcastToOverlayWindows: (channel, payload) => { + input.overlay.broadcastToOverlayWindows(channel, payload); + }, + getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(), + }, + getOverlayUi: () => input.overlay.getOverlayUi(), + }, + lifecycle: { + requestAppQuit: () => input.lifecycle.requestAppQuit(), + setQuitCheckTimer: (callback, timeoutMs) => { + input.lifecycle.setQuitCheckTimer(callback, timeoutMs); + }, + restoreOverlayMpvSubtitles: () => { + input.lifecycle.restoreOverlayMpvSubtitles(); + }, + syncOverlayMpvSubtitleSuppression: () => { + input.lifecycle.syncOverlayMpvSubtitleSuppression(); + }, + publishDiscordPresence: () => { + input.lifecycle.publishDiscordPresence(); + }, + }, + stats: { + ensureImmersionTrackerStarted: () => input.stats.ensureImmersionTrackerStarted(), + }, + anilist: { + getCurrentAnilistMediaKey: () => input.anilist.getCurrentAnilistMediaKey(), + resetAnilistMediaTracking: (mediaKey) => { + input.anilist.resetAnilistMediaTracking(mediaKey); + }, + maybeProbeAnilistDuration: (mediaKey) => { + if (mediaKey) { + void input.anilist.maybeProbeAnilistDuration(mediaKey); + } + }, + ensureAnilistMediaGuess: (mediaKey) => { + if (mediaKey) { + void input.anilist.ensureAnilistMediaGuess(mediaKey); + } + }, + maybeRunAnilistPostWatchUpdate: () => input.anilist.maybeRunAnilistPostWatchUpdate(), + resetAnilistMediaGuessState: () => { + input.anilist.resetAnilistMediaGuessState(); + }, + }, + jellyfin: { + getQuitOnDisconnectArmed: () => input.jellyfin.getQuitOnDisconnectArmed(), + reportJellyfinRemoteStopped: () => input.jellyfin.reportJellyfinRemoteStopped(), + reportJellyfinRemoteProgress: (forceImmediate) => + input.jellyfin.reportJellyfinRemoteProgress(forceImmediate), + startJellyfinRemoteSession: () => input.jellyfin.startJellyfinRemoteSession(), + }, + youtube: { + getQuitOnDisconnectArmed: () => input.youtube.getQuitOnDisconnectArmed(), + handleMpvConnectionChange: (connected) => { + input.youtube.handleMpvConnectionChange(connected); + }, + handleMediaPathChange: (path) => { + input.youtube.invalidatePendingAutoplayReadyFallbacks(); + input.currentMediaTokenizationGate.updateCurrentMediaPath(path); + input.startupOsdSequencer.reset(); + input.youtube.handleMediaPathChange(path); + if (path) { + input.stats.ensureImmersionTrackerStarted(); + } + }, + handleSubtitleTrackChange: (sid) => { + input.youtube.handleSubtitleTrackChange(sid); + }, + handleSubtitleTrackListChange: (trackList) => { + input.youtube.handleSubtitleTrackListChange(trackList); + }, + invalidatePendingAutoplayReadyFallbacks: () => + input.youtube.invalidatePendingAutoplayReadyFallbacks(), + maybeSignalPluginAutoplayReady: (subtitle, options) => + input.youtube.maybeSignalPluginAutoplayReady(subtitle, options), + }, + isCharacterDictionaryEnabled: input.isCharacterDictionaryEnabled, + }); +} diff --git a/src/main/mpv-runtime.ts b/src/main/mpv-runtime.ts new file mode 100644 index 00000000..942290fc --- /dev/null +++ b/src/main/mpv-runtime.ts @@ -0,0 +1,504 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { MecabTokenizer } from '../mecab-tokenizer'; +import { + MpvIpcClient, + applyMpvSubtitleRenderMetricsPatch, + createShiftSubtitleDelayToAdjacentCueHandler, + createTokenizerDepsRuntime, + cycleSecondarySubMode as cycleSecondarySubModeCore, + sendMpvCommandRuntime, + showMpvOsdRuntime, + tokenizeSubtitle as tokenizeSubtitleCore, +} from '../core/services'; +import type { + MpvSubtitleRenderMetrics, + ResolvedConfig, + SecondarySubMode, + SubtitleData, +} from '../types'; +import type { AppState } from './state'; +import type { SubtitleRuntime } from './subtitle-runtime'; +import type { createCurrentMediaTokenizationGate } from './runtime/current-media-tokenization-gate'; +import type { createStartupOsdSequencer } from './runtime/startup-osd-sequencer'; +import { createMpvOsdRuntimeHandlers } from './runtime/mpv-osd-runtime-handlers'; +import { createCycleSecondarySubModeRuntimeHandler } from './runtime/secondary-sub-mode-runtime-handler'; +import { composeMpvRuntimeHandlers } from './runtime/composers'; + +type RuntimeOptionId = + | 'subtitle.annotation.nPlusOne' + | 'subtitle.annotation.jlpt' + | 'subtitle.annotation.frequency'; + +interface MpvRuntimeLogger { + debug: (message: string, meta?: unknown) => void; + info: (message: string, meta?: unknown) => void; + warn: (message: string, meta?: unknown) => void; + error: (message: string, error?: unknown) => void; +} + +export interface MpvRuntimeInput { + appState: AppState; + logPath: string; + logger: MpvRuntimeLogger; + getResolvedConfig: () => ResolvedConfig; + getRuntimeBooleanOption: (id: RuntimeOptionId, fallback: boolean) => boolean; + subtitle: Pick< + SubtitleRuntime, + | 'consumeCachedSubtitle' + | 'emitSubtitlePayload' + | 'onSubtitleChange' + | 'onCurrentMediaPathChange' + | 'onTimePosUpdate' + | 'scheduleSubtitlePrefetchRefresh' + | 'loadSubtitleSourceText' + | 'setTokenizeSubtitleDeferred' + >; + ensureYomitanExtensionLoaded: () => Promise; + currentMediaTokenizationGate: ReturnType; + startupOsdSequencer: ReturnType; + dictionaries: { + ensureJlptDictionaryLookup: () => Promise; + ensureFrequencyDictionaryLookup: () => Promise; + }; + mediaRuntime: { + syncImmersionMediaState: () => void; + updateCurrentMediaPath: (mediaPath: unknown) => void; + updateCurrentMediaTitle: (mediaTitle: unknown) => void; + }; + characterDictionaryAutoSyncRuntime: { + scheduleSync: () => void; + }; + overlay: { + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + getVisibleOverlayVisible: () => boolean; + setOverlayVisible: (visible: boolean) => void; + }; + lifecycle: { + requestAppQuit: () => void; + scheduleQuitCheck: (callback: () => void) => void; + restoreOverlayMpvSubtitles: () => void; + syncOverlayMpvSubtitleSuppression: () => void; + refreshDiscordPresence: () => void; + }; + stats: { + ensureImmersionTrackerStarted: () => void; + }; + anilist: { + getCurrentAnilistMediaKey: () => string | null; + resetAnilistMediaTracking: (mediaKey: string | null) => void; + maybeProbeAnilistDuration: (mediaKey: string | null) => void; + ensureAnilistMediaGuess: (mediaKey: string | null) => void; + maybeRunAnilistPostWatchUpdate: () => Promise; + resetAnilistMediaGuessState: () => void; + }; + jellyfin: { + getQuitOnDisconnectArmed: () => boolean; + reportJellyfinRemoteStopped: () => Promise; + reportJellyfinRemoteProgress: (forceImmediate?: boolean) => Promise; + startJellyfinRemoteSession: () => Promise; + }; + youtube: { + getQuitOnDisconnectArmed: () => boolean; + handleMpvConnectionChange: (connected: boolean) => void; + handleMediaPathChange: (path: string | null) => void; + handleSubtitleTrackChange: (sid: number | null) => void; + handleSubtitleTrackListChange: (trackList: unknown[] | null) => void; + invalidatePendingAutoplayReadyFallbacks: () => void; + maybeSignalPluginAutoplayReady: ( + subtitle: { text: string; tokens: null }, + options?: { forceWhilePaused?: boolean }, + ) => void; + }; + isCharacterDictionaryEnabled: () => boolean; +} + +export interface MpvRuntime { + createMpvClientRuntimeService: () => MpvIpcClient; + updateMpvSubtitleRenderMetrics: (patch: Partial) => void; + createMecabTokenizerAndCheck: () => Promise; + prewarmSubtitleDictionaries: () => Promise; + startTokenizationWarmups: () => Promise; + isTokenizationWarmupReady: () => boolean; + startBackgroundWarmups: () => void; + showMpvOsd: (text: string) => void; + flushMpvLog: () => Promise; + cycleSecondarySubMode: () => void; + shiftSubtitleDelayToAdjacentCue: (direction: 'next' | 'previous') => Promise; +} + +function getActiveMediaPath(appState: AppState): string | null { + return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null; +} + +export function createMpvRuntime(input: MpvRuntimeInput): MpvRuntime { + let backgroundWarmupsStarted = false; + let tokenizeSubtitleDeferred: ((text: string) => Promise) | null = null; + + const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ + appendToMpvLogMainDeps: { + logPath: input.logPath, + dirname: (targetPath) => path.dirname(targetPath), + mkdir: async (targetPath, options) => { + await fs.promises.mkdir(targetPath, options); + }, + appendFile: async (targetPath, data, options) => { + await fs.promises.appendFile(targetPath, data, options); + }, + now: () => new Date(), + }, + buildShowMpvOsdMainDeps: (appendToMpvLog) => ({ + appendToMpvLog, + showMpvOsdRuntime: (mpvClient, text, fallbackLog) => + showMpvOsdRuntime(mpvClient, text, fallbackLog), + getMpvClient: () => input.appState.mpvClient, + logInfo: (line) => input.logger.info(line), + }), + }); + + const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({ + cycleSecondarySubModeMainDeps: { + getSecondarySubMode: () => input.appState.secondarySubMode, + setSecondarySubMode: (mode: SecondarySubMode) => { + input.appState.secondarySubMode = mode; + }, + getLastSecondarySubToggleAtMs: () => input.appState.lastSecondarySubToggleAtMs, + setLastSecondarySubToggleAtMs: (timestampMs: number) => { + input.appState.lastSecondarySubToggleAtMs = timestampMs; + }, + broadcastToOverlayWindows: (channel, mode) => { + input.overlay.broadcastToOverlayWindows(channel, mode); + }, + showMpvOsd: (text: string) => showMpvOsd(text), + }, + cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps), + }); + + const { + createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, + updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler, + tokenizeSubtitle, + createMecabTokenizerAndCheck, + prewarmSubtitleDictionaries, + startBackgroundWarmups, + startTokenizationWarmups, + isTokenizationWarmupReady, + } = composeMpvRuntimeHandlers< + MpvIpcClient, + ReturnType, + SubtitleData + >({ + bindMpvMainEventHandlersMainDeps: { + appState: input.appState, + getQuitOnDisconnectArmed: () => + input.jellyfin.getQuitOnDisconnectArmed() || input.youtube.getQuitOnDisconnectArmed(), + scheduleQuitCheck: (callback) => { + input.lifecycle.scheduleQuitCheck(callback); + }, + quitApp: () => input.lifecycle.requestAppQuit(), + reportJellyfinRemoteStopped: () => { + void input.jellyfin.reportJellyfinRemoteStopped(); + }, + maybeRunAnilistPostWatchUpdate: () => input.anilist.maybeRunAnilistPostWatchUpdate(), + logSubtitleTimingError: (message, error) => input.logger.error(message, error), + broadcastToOverlayWindows: (channel, payload) => { + input.overlay.broadcastToOverlayWindows(channel, payload); + }, + getImmediateSubtitlePayload: (text) => input.subtitle.consumeCachedSubtitle(text), + emitImmediateSubtitle: (payload) => { + input.subtitle.emitSubtitlePayload(payload); + }, + onSubtitleChange: (text) => { + input.subtitle.onSubtitleChange(text); + }, + refreshDiscordPresence: () => { + input.lifecycle.refreshDiscordPresence(); + }, + ensureImmersionTrackerInitialized: () => { + input.stats.ensureImmersionTrackerStarted(); + }, + tokenizeSubtitleForImmersion: async (text): Promise => + tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, + updateCurrentMediaPath: (mediaPath) => { + input.youtube.invalidatePendingAutoplayReadyFallbacks(); + input.currentMediaTokenizationGate.updateCurrentMediaPath(mediaPath); + input.startupOsdSequencer.reset(); + input.subtitle.onCurrentMediaPathChange(mediaPath); + input.youtube.handleMediaPathChange(mediaPath); + if (mediaPath) { + input.stats.ensureImmersionTrackerStarted(); + } + input.mediaRuntime.updateCurrentMediaPath(mediaPath); + }, + restoreMpvSubVisibility: () => { + input.lifecycle.restoreOverlayMpvSubtitles(); + }, + resetSubtitleSidebarEmbeddedLayout: () => { + sendMpvCommandRuntime(input.appState.mpvClient, [ + 'set_property', + 'video-margin-ratio-right', + 0, + ]); + sendMpvCommandRuntime(input.appState.mpvClient, ['set_property', 'video-pan-x', 0]); + }, + getCurrentAnilistMediaKey: () => input.anilist.getCurrentAnilistMediaKey(), + resetAnilistMediaTracking: (mediaKey) => { + input.anilist.resetAnilistMediaTracking(mediaKey); + }, + maybeProbeAnilistDuration: (mediaKey) => { + if (mediaKey) { + input.anilist.maybeProbeAnilistDuration(mediaKey); + } + }, + ensureAnilistMediaGuess: (mediaKey) => { + if (mediaKey) { + input.anilist.ensureAnilistMediaGuess(mediaKey); + } + }, + syncImmersionMediaState: () => { + input.mediaRuntime.syncImmersionMediaState(); + }, + signalAutoplayReadyIfWarm: () => { + if (!isTokenizationWarmupReady()) { + return; + } + input.youtube.maybeSignalPluginAutoplayReady( + { text: '__warm__', tokens: null }, + { forceWhilePaused: true }, + ); + }, + scheduleCharacterDictionarySync: () => { + if (!input.isCharacterDictionaryEnabled()) { + return; + } + input.characterDictionaryAutoSyncRuntime.scheduleSync(); + }, + updateCurrentMediaTitle: (title) => { + input.mediaRuntime.updateCurrentMediaTitle(title); + }, + resetAnilistMediaGuessState: () => { + input.anilist.resetAnilistMediaGuessState(); + }, + reportJellyfinRemoteProgress: (forceImmediate) => { + void input.jellyfin.reportJellyfinRemoteProgress(forceImmediate); + }, + onTimePosUpdate: (time) => { + input.subtitle.onTimePosUpdate(time); + }, + onSubtitleTrackChange: (sid) => { + input.subtitle.scheduleSubtitlePrefetchRefresh(); + input.youtube.handleSubtitleTrackChange(sid); + }, + onSubtitleTrackListChange: (trackList) => { + input.subtitle.scheduleSubtitlePrefetchRefresh(); + input.youtube.handleSubtitleTrackListChange(trackList); + }, + updateSubtitleRenderMetrics: (patch) => { + updateMpvSubtitleRenderMetricsHandler(patch as Partial); + }, + syncOverlayMpvSubtitleSuppression: () => { + input.lifecycle.syncOverlayMpvSubtitleSuppression(); + }, + }, + mpvClientRuntimeServiceFactoryMainDeps: { + createClient: MpvIpcClient, + getSocketPath: () => input.appState.mpvSocketPath, + getResolvedConfig: () => input.getResolvedConfig(), + isAutoStartOverlayEnabled: () => input.appState.autoStartOverlay, + setOverlayVisible: (visible: boolean) => { + input.overlay.setOverlayVisible(visible); + }, + isVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(), + getReconnectTimer: () => input.appState.reconnectTimer, + setReconnectTimer: (timer: ReturnType | null) => { + input.appState.reconnectTimer = timer; + }, + }, + updateMpvSubtitleRenderMetricsMainDeps: { + getCurrentMetrics: () => input.appState.mpvSubtitleRenderMetrics, + setCurrentMetrics: (metrics) => { + input.appState.mpvSubtitleRenderMetrics = metrics; + }, + applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch), + broadcastMetrics: () => {}, + }, + tokenizer: { + buildTokenizerDepsMainDeps: { + getYomitanExt: () => input.appState.yomitanExt, + getYomitanSession: () => input.appState.yomitanSession, + getYomitanParserWindow: () => input.appState.yomitanParserWindow, + setYomitanParserWindow: (window) => { + input.appState.yomitanParserWindow = window; + }, + getYomitanParserReadyPromise: () => input.appState.yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise) => { + input.appState.yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => input.appState.yomitanParserInitPromise, + setYomitanParserInitPromise: (promise) => { + input.appState.yomitanParserInitPromise = promise; + }, + isKnownWord: (text) => Boolean(input.appState.ankiIntegration?.isKnownWord(text)), + recordLookup: (hit) => { + input.stats.ensureImmersionTrackerStarted(); + input.appState.immersionTracker?.recordLookup(hit); + }, + getKnownWordMatchMode: () => + input.appState.ankiIntegration?.getKnownWordMatchMode() ?? + input.getResolvedConfig().ankiConnect.knownWords.matchMode, + getNPlusOneEnabled: () => + input.getRuntimeBooleanOption( + 'subtitle.annotation.nPlusOne', + input.getResolvedConfig().ankiConnect.knownWords.highlightEnabled, + ), + getMinSentenceWordsForNPlusOne: () => + input.getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords, + getJlptLevel: (text) => input.appState.jlptLevelLookup(text), + getJlptEnabled: () => + input.getRuntimeBooleanOption( + 'subtitle.annotation.jlpt', + input.getResolvedConfig().subtitleStyle.enableJlpt, + ), + getCharacterDictionaryEnabled: () => input.isCharacterDictionaryEnabled(), + getNameMatchEnabled: () => input.getResolvedConfig().subtitleStyle.nameMatchEnabled, + getFrequencyDictionaryEnabled: () => + input.getRuntimeBooleanOption( + 'subtitle.annotation.frequency', + input.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + ), + getFrequencyDictionaryMatchMode: () => + input.getResolvedConfig().subtitleStyle.frequencyDictionary.matchMode, + getFrequencyRank: (text) => input.appState.frequencyRankLookup(text), + getYomitanGroupDebugEnabled: () => input.appState.overlayDebugVisualizationEnabled, + getMecabTokenizer: () => input.appState.mecabTokenizer, + onTokenizationReady: (text) => { + input.currentMediaTokenizationGate.markReady(getActiveMediaPath(input.appState)); + input.startupOsdSequencer.markTokenizationReady(); + input.youtube.maybeSignalPluginAutoplayReady( + { text, tokens: null }, + { forceWhilePaused: true }, + ); + }, + }, + createTokenizerRuntimeDeps: (deps) => + createTokenizerDepsRuntime(deps as Parameters[0]), + tokenizeSubtitle: (text, deps) => tokenizeSubtitleCore(text, deps), + createMecabTokenizerAndCheckMainDeps: { + getMecabTokenizer: () => input.appState.mecabTokenizer, + setMecabTokenizer: (tokenizer) => { + input.appState.mecabTokenizer = tokenizer as MecabTokenizer | null; + }, + createMecabTokenizer: () => new MecabTokenizer(), + checkAvailability: async (tokenizer) => (tokenizer as MecabTokenizer).checkAvailability(), + }, + prewarmSubtitleDictionariesMainDeps: { + ensureJlptDictionaryLookup: () => input.dictionaries.ensureJlptDictionaryLookup(), + ensureFrequencyDictionaryLookup: () => input.dictionaries.ensureFrequencyDictionaryLookup(), + showMpvOsd: (message: string) => showMpvOsd(message), + showLoadingOsd: (message: string) => + input.startupOsdSequencer.showAnnotationLoading(message), + showLoadedOsd: (message: string) => + input.startupOsdSequencer.markAnnotationLoadingComplete(message), + shouldShowOsdNotification: () => { + const type = input.getResolvedConfig().ankiConnect.behavior.notificationType; + return type === 'osd' || type === 'both'; + }, + }, + }, + warmups: { + launchBackgroundWarmupTaskMainDeps: { + now: () => Date.now(), + logDebug: (message) => input.logger.debug(message), + logWarn: (message) => input.logger.warn(message), + }, + startBackgroundWarmupsMainDeps: { + getStarted: () => backgroundWarmupsStarted, + setStarted: (started) => { + backgroundWarmupsStarted = started; + }, + isTexthookerOnlyMode: () => input.appState.texthookerOnlyMode, + ensureYomitanExtensionLoaded: () => input.ensureYomitanExtensionLoaded().then(() => {}), + shouldWarmupMecab: () => { + const startupWarmups = input.getResolvedConfig().startupWarmups; + if (startupWarmups.lowPowerMode) { + return false; + } + if (!startupWarmups.mecab) { + return false; + } + return ( + input.getRuntimeBooleanOption( + 'subtitle.annotation.nPlusOne', + input.getResolvedConfig().ankiConnect.knownWords.highlightEnabled, + ) || + input.getRuntimeBooleanOption( + 'subtitle.annotation.jlpt', + input.getResolvedConfig().subtitleStyle.enableJlpt, + ) || + input.getRuntimeBooleanOption( + 'subtitle.annotation.frequency', + input.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, + ) + ); + }, + shouldWarmupYomitanExtension: () => + input.getResolvedConfig().startupWarmups.yomitanExtension, + shouldWarmupSubtitleDictionaries: () => { + const startupWarmups = input.getResolvedConfig().startupWarmups; + if (startupWarmups.lowPowerMode) { + return false; + } + return startupWarmups.subtitleDictionaries; + }, + shouldWarmupJellyfinRemoteSession: () => { + const startupWarmups = input.getResolvedConfig().startupWarmups; + if (startupWarmups.lowPowerMode) { + return false; + } + return startupWarmups.jellyfinRemoteSession; + }, + shouldAutoConnectJellyfinRemote: () => { + const jellyfin = input.getResolvedConfig().jellyfin; + return ( + jellyfin.enabled && jellyfin.remoteControlEnabled && jellyfin.remoteControlAutoConnect + ); + }, + startJellyfinRemoteSession: () => input.jellyfin.startJellyfinRemoteSession(), + logDebug: (message) => input.logger.debug(message), + }, + }, + }); + tokenizeSubtitleDeferred = tokenizeSubtitle; + input.subtitle.setTokenizeSubtitleDeferred(tokenizeSubtitle); + + const createMpvClientRuntimeService = (): MpvIpcClient => { + const client = createMpvClientRuntimeServiceHandler(); + client.on('connection-change', ({ connected }) => { + input.youtube.handleMpvConnectionChange(connected); + }); + return client; + }; + + const shiftSubtitleDelayToAdjacentCue = createShiftSubtitleDelayToAdjacentCueHandler({ + getMpvClient: () => input.appState.mpvClient, + loadSubtitleSourceText: (source) => input.subtitle.loadSubtitleSourceText(source), + sendMpvCommand: (command) => sendMpvCommandRuntime(input.appState.mpvClient, command), + showMpvOsd: (text) => showMpvOsd(text), + }); + + return { + createMpvClientRuntimeService, + updateMpvSubtitleRenderMetrics: (patch) => { + updateMpvSubtitleRenderMetricsHandler(patch); + }, + createMecabTokenizerAndCheck, + prewarmSubtitleDictionaries, + startTokenizationWarmups, + isTokenizationWarmupReady, + startBackgroundWarmups, + showMpvOsd, + flushMpvLog, + cycleSecondarySubMode, + shiftSubtitleDelayToAdjacentCue, + }; +} diff --git a/src/main/overlay-geometry-accessors.ts b/src/main/overlay-geometry-accessors.ts new file mode 100644 index 00000000..9187e1a7 --- /dev/null +++ b/src/main/overlay-geometry-accessors.ts @@ -0,0 +1,63 @@ +import type { WindowGeometry } from '../types'; +import type { OverlayGeometryRuntime } from './overlay-geometry-runtime'; + +export function createOverlayGeometryAccessors(deps: { + getOverlayGeometryRuntime: () => OverlayGeometryRuntime | null; + getWindowTracker: () => { getGeometry?: () => WindowGeometry | null } | null; + screen: { + getCursorScreenPoint: () => { x: number; y: number }; + getDisplayNearestPoint: (point: { x: number; y: number }) => { + workArea: { x: number; y: number; width: number; height: number }; + }; + }; +}) { + const getOverlayGeometryFallback = (): WindowGeometry => { + const runtime = deps.getOverlayGeometryRuntime(); + if (runtime) { + return runtime.getOverlayGeometryFallback(); + } + + const cursorPoint = deps.screen.getCursorScreenPoint(); + const display = deps.screen.getDisplayNearestPoint(cursorPoint); + const bounds = display.workArea; + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }; + }; + + const getCurrentOverlayGeometry = (): WindowGeometry => { + const runtime = deps.getOverlayGeometryRuntime(); + if (runtime) { + return runtime.getCurrentOverlayGeometry(); + } + + const trackerGeometry = deps.getWindowTracker()?.getGeometry?.() ?? null; + if (trackerGeometry) { + return trackerGeometry; + } + + return getOverlayGeometryFallback(); + }; + + const geometryMatches = (a: WindowGeometry | null, b: WindowGeometry | null): boolean => { + const runtime = deps.getOverlayGeometryRuntime(); + if (runtime) { + return runtime.geometryMatches(a, b); + } + + if (!a || !b) { + return false; + } + + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + }; + + return { + getOverlayGeometryFallback, + getCurrentOverlayGeometry, + geometryMatches, + }; +} diff --git a/src/main/overlay-geometry-runtime.test.ts b/src/main/overlay-geometry-runtime.test.ts new file mode 100644 index 00000000..783ec4d6 --- /dev/null +++ b/src/main/overlay-geometry-runtime.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createOverlayGeometryRuntime } from './overlay-geometry-runtime'; + +test('overlay geometry runtime prefers tracker geometry before fallback', () => { + const overlayBounds: unknown[] = []; + const modalBounds: unknown[] = []; + const layerCalls: Array<[unknown, unknown]> = []; + const levelCalls: unknown[] = []; + + const runtime = createOverlayGeometryRuntime({ + screen: { + getCursorScreenPoint: () => ({ x: 1, y: 2 }), + getDisplayNearestPoint: () => ({ + workArea: { x: 10, y: 20, width: 30, height: 40 }, + }), + }, + windowState: { + getMainWindow: () => + ({ + isDestroyed: () => false, + }) as never, + setOverlayWindowBounds: (geometry) => overlayBounds.push(geometry), + setModalWindowBounds: (geometry) => modalBounds.push(geometry), + getVisibleOverlayVisible: () => true, + }, + getWindowTracker: () => ({ + getGeometry: () => ({ x: 100, y: 200, width: 300, height: 400 }), + }), + ensureOverlayWindowLevelCore: (window) => { + levelCalls.push(window); + }, + syncOverlayWindowLayer: (window, layer) => { + layerCalls.push([window, layer]); + }, + enforceOverlayLayerOrderCore: ({ + visibleOverlayVisible, + mainWindow, + ensureOverlayWindowLevel, + }) => { + if (visibleOverlayVisible && mainWindow) { + ensureOverlayWindowLevel(mainWindow); + } + }, + }); + + assert.deepEqual(runtime.getCurrentOverlayGeometry(), { + x: 100, + y: 200, + width: 300, + height: 400, + }); + assert.equal( + runtime.geometryMatches( + { x: 1, y: 2, width: 3, height: 4 }, + { x: 1, y: 2, width: 3, height: 4 }, + ), + true, + ); + assert.equal(runtime.geometryMatches({ x: 1, y: 2, width: 3, height: 4 }, null), false); + + runtime.applyOverlayRegions({ x: 7, y: 8, width: 9, height: 10 }); + assert.deepEqual(overlayBounds, [{ x: 7, y: 8, width: 9, height: 10 }]); + assert.deepEqual(modalBounds, [{ x: 7, y: 8, width: 9, height: 10 }]); + + runtime.syncPrimaryOverlayWindowLayer('visible'); + runtime.ensureOverlayWindowLevel({ + isDestroyed: () => false, + } as never); + runtime.enforceOverlayLayerOrder(); + + assert.equal(layerCalls.length >= 1, true); + assert.equal(levelCalls.length >= 2, true); +}); diff --git a/src/main/overlay-geometry-runtime.ts b/src/main/overlay-geometry-runtime.ts new file mode 100644 index 00000000..81c22756 --- /dev/null +++ b/src/main/overlay-geometry-runtime.ts @@ -0,0 +1,135 @@ +import { + createEnforceOverlayLayerOrderHandler, + createEnsureOverlayWindowLevelHandler, + createUpdateVisibleOverlayBoundsHandler, +} from './runtime/overlay-window-layout'; +import { + createBuildEnforceOverlayLayerOrderMainDepsHandler, + createBuildEnsureOverlayWindowLevelMainDepsHandler, + createBuildUpdateVisibleOverlayBoundsMainDepsHandler, +} from './runtime/overlay-window-layout-main-deps'; +import type { WindowGeometry } from '../types'; + +type BrowserWindowLike = { + isDestroyed: () => boolean; +}; + +type ScreenLike = { + getCursorScreenPoint: () => { x: number; y: number }; + getDisplayNearestPoint: (point: { x: number; y: number }) => { + workArea: { x: number; y: number; width: number; height: number }; + }; +}; + +export interface OverlayGeometryWindowState { + getMainWindow: () => TWindow | null; + setOverlayWindowBounds: (geometry: WindowGeometry) => void; + setModalWindowBounds: (geometry: WindowGeometry) => void; + getVisibleOverlayVisible: () => boolean; +} + +export interface OverlayGeometryInput { + screen: ScreenLike; + windowState: OverlayGeometryWindowState; + getWindowTracker: () => { getGeometry?: () => WindowGeometry | null } | null; + ensureOverlayWindowLevelCore: (window: TWindow) => void; + syncOverlayWindowLayer: (window: TWindow, layer: 'visible') => void; + enforceOverlayLayerOrderCore: (params: { + visibleOverlayVisible: boolean; + mainWindow: TWindow | null; + ensureOverlayWindowLevel: (window: TWindow) => void; + }) => void; +} + +export interface OverlayGeometryRuntime { + getLastOverlayWindowGeometry: () => WindowGeometry | null; + getOverlayGeometryFallback: () => WindowGeometry; + getCurrentOverlayGeometry: () => WindowGeometry; + geometryMatches: (a: WindowGeometry | null, b: WindowGeometry | null) => boolean; + applyOverlayRegions: (geometry: WindowGeometry) => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + ensureOverlayWindowLevel: (window: TWindow) => void; + syncPrimaryOverlayWindowLayer: (layer: 'visible') => void; + enforceOverlayLayerOrder: () => void; +} + +export function createOverlayGeometryRuntime( + input: OverlayGeometryInput, +): OverlayGeometryRuntime { + let lastOverlayWindowGeometry: WindowGeometry | null = null; + + const getOverlayGeometryFallback = (): WindowGeometry => { + const cursorPoint = input.screen.getCursorScreenPoint(); + const display = input.screen.getDisplayNearestPoint(cursorPoint); + const bounds = display.workArea; + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }; + }; + + const getCurrentOverlayGeometry = (): WindowGeometry => { + if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry; + const trackerGeometry = input.getWindowTracker()?.getGeometry?.() ?? null; + if (trackerGeometry) return trackerGeometry; + return getOverlayGeometryFallback(); + }; + + const geometryMatches = (a: WindowGeometry | null, b: WindowGeometry | null): boolean => { + if (!a || !b) return false; + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + }; + + const applyOverlayRegions = (geometry: WindowGeometry): void => { + lastOverlayWindowGeometry = geometry; + input.windowState.setOverlayWindowBounds(geometry); + input.windowState.setModalWindowBounds(geometry); + }; + + const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( + createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ + setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), + })(), + ); + + const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler( + createBuildEnsureOverlayWindowLevelMainDepsHandler({ + ensureOverlayWindowLevelCore: (window) => + input.ensureOverlayWindowLevelCore(window as TWindow), + })(), + ); + + const syncPrimaryOverlayWindowLayer = (layer: 'visible'): void => { + const mainWindow = input.windowState.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + input.syncOverlayWindowLayer(mainWindow, layer); + }; + + const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler( + createBuildEnforceOverlayLayerOrderMainDepsHandler({ + enforceOverlayLayerOrderCore: (params) => + input.enforceOverlayLayerOrderCore({ + visibleOverlayVisible: params.visibleOverlayVisible, + mainWindow: params.mainWindow as TWindow | null, + ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window as TWindow), + }), + getVisibleOverlayVisible: () => input.windowState.getVisibleOverlayVisible(), + getMainWindow: () => input.windowState.getMainWindow(), + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as TWindow), + })(), + ); + + return { + getLastOverlayWindowGeometry: () => lastOverlayWindowGeometry, + getOverlayGeometryFallback, + getCurrentOverlayGeometry, + geometryMatches, + applyOverlayRegions, + updateVisibleOverlayBounds, + ensureOverlayWindowLevel, + syncPrimaryOverlayWindowLayer, + enforceOverlayLayerOrder, + }; +} diff --git a/src/main/overlay-ui-bootstrap-from-main-state.ts b/src/main/overlay-ui-bootstrap-from-main-state.ts new file mode 100644 index 00000000..78b4acfb --- /dev/null +++ b/src/main/overlay-ui-bootstrap-from-main-state.ts @@ -0,0 +1,251 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + Menu, + MenuItem, + nativeImage, + Tray, + type BrowserWindow, + type MenuItemConstructorOptions, +} from 'electron'; + +import type { AnilistRuntime } from './anilist-runtime'; +import type { DictionarySupportRuntime } from './dictionary-support-runtime'; +import type { FirstRunRuntime } from './first-run-runtime'; +import type { JellyfinRuntime } from './jellyfin-runtime'; +import type { MpvRuntime } from './mpv-runtime'; +import type { ResolvedConfig } from '../types'; +import { + broadcastRuntimeOptionsChangedRuntime, + createOverlayWindow as createOverlayWindowCore, + enforceOverlayLayerOrder as enforceOverlayLayerOrderCore, + ensureOverlayWindowLevel as ensureOverlayWindowLevelCore, + initializeOverlayRuntime as initializeOverlayRuntimeCore, + setOverlayDebugVisualizationEnabledRuntime, + syncOverlayWindowLayer, +} from '../core/services'; +import { + buildTrayMenuTemplateRuntime, + resolveTrayIconPathRuntime, +} from './runtime/domains/overlay'; +import { + createOverlayUiBootstrapRuntime, + type OverlayUiBootstrapInput, + type OverlayUiBootstrapRuntime, +} from './overlay-ui-bootstrap-runtime'; +import type { OverlayModalRuntime } from './overlay-runtime'; +import type { ShortcutsRuntimeBootstrap } from './shortcuts-runtime'; +import { createWindowTracker as createWindowTrackerCore } from '../window-trackers'; + +export interface OverlayUiBootstrapFromMainStateInput< + TWindow extends BrowserWindow, + TMenuItem = MenuItemConstructorOptions | MenuItem, +> { + appState: OverlayUiBootstrapInput['appState']; + overlayManager: OverlayUiBootstrapInput['overlayManager']; + overlayModalInputState: OverlayUiBootstrapInput['overlayModalInputState']; + overlayModalRuntime: OverlayModalRuntime; + overlayShortcutsRuntime: ShortcutsRuntimeBootstrap['overlayShortcutsRuntime']; + runtimes: { + dictionarySupport: Pick; + firstRun: Pick; + yomitan: { + openYomitanSettings: () => boolean; + }; + jellyfin: Pick; + anilist: Pick; + shortcuts: Pick; + mpvRuntime: Pick; + }; + electron: OverlayUiBootstrapInput['electron'] & { + buildMenuFromTemplate: (template: TMenuItem[]) => unknown; + createTray: ( + icon: ReturnType['electron']['createEmptyImage']>, + ) => Tray; + }; + windowing: OverlayUiBootstrapInput['windowing']; + actions: Omit< + OverlayUiBootstrapInput['actions'], + 'registerGlobalShortcuts' | 'startBackgroundWarmups' + >; + trayState: OverlayUiBootstrapInput['trayState']; + startup: OverlayUiBootstrapInput['startup']; +} + +export function createOverlayUiBootstrapFromMainState( + input: OverlayUiBootstrapFromMainStateInput, +): OverlayUiBootstrapRuntime { + return createOverlayUiBootstrapRuntime({ + appState: input.appState, + overlayManager: input.overlayManager, + overlayModalInputState: input.overlayModalInputState, + overlayModalRuntime: input.overlayModalRuntime, + overlayShortcutsRuntime: input.overlayShortcutsRuntime, + dictionarySupport: { + createFieldGroupingCallback: () => + input.runtimes.dictionarySupport.createFieldGroupingCallback(), + }, + firstRun: { + isSetupCompleted: () => input.runtimes.firstRun.isSetupCompleted(), + openFirstRunSetupWindow: () => input.runtimes.firstRun.openFirstRunSetupWindow(), + }, + yomitan: { + openYomitanSettings: () => { + input.runtimes.yomitan.openYomitanSettings(); + }, + }, + jellyfin: { + openJellyfinSetupWindow: () => input.runtimes.jellyfin.openJellyfinSetupWindow(), + }, + anilist: { + openAnilistSetupWindow: () => input.runtimes.anilist.openAnilistSetupWindow(), + }, + electron: input.electron, + windowing: input.windowing, + actions: { + ...input.actions, + registerGlobalShortcuts: () => input.runtimes.shortcuts.registerGlobalShortcuts(), + startBackgroundWarmups: () => input.runtimes.mpvRuntime.startBackgroundWarmups(), + }, + trayState: input.trayState, + startup: input.startup, + }); +} + +export interface OverlayUiBootstrapCoordinatorInput { + appState: OverlayUiBootstrapFromMainStateInput['appState']; + overlayManager: OverlayUiBootstrapFromMainStateInput['overlayManager']; + overlayModalInputState: OverlayUiBootstrapFromMainStateInput['overlayModalInputState']; + overlayModalRuntime: OverlayUiBootstrapFromMainStateInput['overlayModalRuntime']; + overlayShortcutsRuntime: OverlayUiBootstrapFromMainStateInput['overlayShortcutsRuntime']; + runtimes: OverlayUiBootstrapFromMainStateInput['runtimes']; + env: { + screen: OverlayUiBootstrapFromMainStateInput['electron']['screen']; + appPath: string; + resourcesPath: string; + dirname: string; + platform: NodeJS.Platform; + }; + windowing: OverlayUiBootstrapFromMainStateInput['windowing']; + actions: Omit< + OverlayUiBootstrapFromMainStateInput['actions'], + | 'resolveTrayIconPathRuntime' + | 'buildTrayMenuTemplateRuntime' + | 'broadcastRuntimeOptionsChangedRuntime' + | 'setOverlayDebugVisualizationEnabledRuntime' + | 'initializeOverlayRuntimeCore' + > & + Pick< + OverlayUiBootstrapFromMainStateInput['actions'], + | 'resolveTrayIconPathRuntime' + | 'buildTrayMenuTemplateRuntime' + | 'broadcastRuntimeOptionsChangedRuntime' + | 'setOverlayDebugVisualizationEnabledRuntime' + | 'initializeOverlayRuntimeCore' + >; + trayState: OverlayUiBootstrapFromMainStateInput['trayState']; + startup: OverlayUiBootstrapFromMainStateInput['startup']; +} + +export function createOverlayUiBootstrapCoordinator( + input: OverlayUiBootstrapCoordinatorInput, +): OverlayUiBootstrapRuntime { + return createOverlayUiBootstrapFromMainState({ + appState: input.appState, + overlayManager: input.overlayManager, + overlayModalInputState: input.overlayModalInputState, + overlayModalRuntime: input.overlayModalRuntime, + overlayShortcutsRuntime: input.overlayShortcutsRuntime, + runtimes: input.runtimes, + electron: { + screen: input.env.screen, + appPath: input.env.appPath, + resourcesPath: input.env.resourcesPath, + dirname: input.env.dirname, + platform: input.env.platform, + joinPath: (...parts) => path.join(...parts), + fileExists: (candidate) => fs.existsSync(candidate), + createImageFromPath: (iconPath) => nativeImage.createFromPath(iconPath), + createEmptyImage: () => nativeImage.createEmpty(), + createTray: (icon) => new Tray(icon as ConstructorParameters[0]), + buildMenuFromTemplate: (template) => + Menu.buildFromTemplate(template as (MenuItemConstructorOptions | MenuItem)[]), + }, + windowing: input.windowing, + actions: input.actions, + trayState: input.trayState, + startup: input.startup, + }); +} + +export interface OverlayUiBootstrapFromProcessStateInput { + appState: OverlayUiBootstrapCoordinatorInput['appState']; + overlayManager: OverlayUiBootstrapCoordinatorInput['overlayManager']; + overlayModalInputState: OverlayUiBootstrapCoordinatorInput['overlayModalInputState']; + overlayModalRuntime: OverlayUiBootstrapCoordinatorInput['overlayModalRuntime']; + overlayShortcutsRuntime: OverlayUiBootstrapCoordinatorInput['overlayShortcutsRuntime']; + runtimes: OverlayUiBootstrapCoordinatorInput['runtimes']; + env: OverlayUiBootstrapCoordinatorInput['env'] & { + isDev: boolean; + }; + actions: { + showMpvOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body?: string }) => void; + sendMpvCommand: (command: (string | number)[]) => void; + ensureOverlayMpvSubtitlesHidden: () => Promise; + syncOverlayMpvSubtitleSuppression: () => void; + getResolvedConfig: () => ResolvedConfig; + requestAppQuit: () => void; + }; + trayState: OverlayUiBootstrapCoordinatorInput['trayState']; + startup: OverlayUiBootstrapCoordinatorInput['startup']; +} + +export function createOverlayUiBootstrapFromProcessState( + input: OverlayUiBootstrapFromProcessStateInput, +): OverlayUiBootstrapRuntime { + return createOverlayUiBootstrapCoordinator({ + appState: input.appState, + overlayManager: input.overlayManager, + overlayModalInputState: input.overlayModalInputState, + overlayModalRuntime: input.overlayModalRuntime, + overlayShortcutsRuntime: input.overlayShortcutsRuntime, + runtimes: input.runtimes, + env: input.env, + windowing: { + isDev: input.env.isDev, + createOverlayWindowCore: (kind, options) => + createOverlayWindowCore(kind, options as never) as TWindow, + ensureOverlayWindowLevelCore: (window) => + ensureOverlayWindowLevelCore(window as BrowserWindow), + syncOverlayWindowLayer: (window, layer) => + syncOverlayWindowLayer(window as BrowserWindow, layer), + enforceOverlayLayerOrderCore: (params) => + enforceOverlayLayerOrderCore({ + ...params, + mainWindow: params.mainWindow as BrowserWindow | null, + ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window as TWindow), + }), + createWindowTrackerCore: (override, targetMpvSocketPath) => + createWindowTrackerCore(override, targetMpvSocketPath), + }, + actions: { + showMpvOsd: (message) => input.actions.showMpvOsd(message), + showDesktopNotification: (title, options) => + input.actions.showDesktopNotification(title, options), + sendMpvCommand: (command) => input.actions.sendMpvCommand(command), + broadcastRuntimeOptionsChangedRuntime, + setOverlayDebugVisualizationEnabledRuntime, + resolveTrayIconPathRuntime, + buildTrayMenuTemplateRuntime, + initializeOverlayRuntimeCore: (options) => initializeOverlayRuntimeCore(options as never), + ensureOverlayMpvSubtitlesHidden: () => input.actions.ensureOverlayMpvSubtitlesHidden(), + syncOverlayMpvSubtitleSuppression: () => input.actions.syncOverlayMpvSubtitleSuppression(), + getResolvedConfig: () => input.actions.getResolvedConfig(), + requestAppQuit: input.actions.requestAppQuit, + }, + trayState: input.trayState, + startup: input.startup, + }); +} diff --git a/src/main/overlay-ui-bootstrap-runtime-input.test.ts b/src/main/overlay-ui-bootstrap-runtime-input.test.ts new file mode 100644 index 00000000..d3dbbb76 --- /dev/null +++ b/src/main/overlay-ui-bootstrap-runtime-input.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { OverlayHostedModal } from '../shared/ipc/contracts'; + +import { createOverlayUiBootstrapRuntimeInput } from './overlay-ui-bootstrap-runtime-input'; + +test('overlay ui bootstrap runtime input builder preserves grouped wiring', () => { + const input = createOverlayUiBootstrapRuntimeInput({ + windows: { + state: { + getMainWindow: () => null, + setMainWindow: () => {}, + getModalWindow: () => null, + setModalWindow: () => {}, + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getOverlayDebugVisualizationEnabled: () => false, + setOverlayDebugVisualizationEnabled: () => {}, + }, + geometry: { + getCurrentOverlayGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }), + }, + modal: { + setModalWindowBounds: () => {}, + onModalStateChange: () => {}, + }, + modalRuntime: { + handleOverlayModalClosed: (_modal: OverlayHostedModal) => {}, + notifyOverlayModalOpened: (_modal: OverlayHostedModal) => {}, + waitForModalOpen: async () => true, + getRestoreVisibleOverlayOnModalClose: () => new Set(), + openRuntimeOptionsPalette: () => {}, + sendToActiveOverlayWindow: () => false, + }, + visibility: { + service: { + getModalActive: () => false, + getForceMousePassthrough: () => false, + getWindowTracker: () => null, + getTrackerNotReadyWarningShown: () => false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => {}, + ensureOverlayWindowLevel: () => {}, + syncPrimaryOverlayWindowLayer: () => {}, + enforceOverlayLayerOrder: () => {}, + syncOverlayShortcuts: () => {}, + isMacOSPlatform: () => false, + isWindowsPlatform: () => false, + showOverlayLoadingOsd: () => {}, + resolveFallbackBounds: () => ({ x: 5, y: 6, width: 7, height: 8 }), + }, + overlayWindows: { + createOverlayWindowCore: () => ({ isDestroyed: () => false }), + isDev: false, + ensureOverlayWindowLevel: () => {}, + onRuntimeOptionsChanged: () => {}, + setOverlayDebugVisualizationEnabled: () => {}, + isOverlayVisible: () => false, + getYomitanSession: () => null, + tryHandleOverlayShortcutLocalFallback: () => false, + forwardTabToMpv: () => {}, + onWindowClosed: () => {}, + }, + actions: { + setVisibleOverlayVisibleCore: () => {}, + }, + }, + }, + overlayActions: { + getRuntimeOptionsManager: () => null, + getMpvClient: () => null, + broadcastRuntimeOptionsChangedRuntime: () => {}, + broadcastToOverlayWindows: () => {}, + setOverlayDebugVisualizationEnabledRuntime: () => {}, + }, + tray: null, + bootstrap: { + initializeOverlayRuntimeMainDeps: { + appState: { + backendOverride: null, + windowTracker: null, + subtitleTimingTracker: null, + mpvClient: null, + mpvSocketPath: '/tmp/mpv.sock', + runtimeOptionsManager: null, + ankiIntegration: null, + }, + overlayManager: { + getVisibleOverlayVisible: () => false, + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => {}, + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => {}, + }, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + getOverlayWindows: () => [], + getResolvedConfig: () => ({}) as never, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => () => Promise.resolve({} as never), + getKnownWordCacheStatePath: () => '/tmp/known.json', + shouldStartAnkiIntegration: () => false, + }, + initializeOverlayRuntimeBootstrapDeps: { + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntimeCore: () => {}, + setOverlayRuntimeInitialized: () => {}, + startBackgroundWarmups: () => {}, + }, + onInitialized: () => {}, + }, + runtimeState: { + isOverlayRuntimeInitialized: () => false, + setOverlayRuntimeInitialized: () => {}, + }, + mpvSubtitle: { + ensureOverlayMpvSubtitlesHidden: () => {}, + syncOverlayMpvSubtitleSuppression: () => {}, + }, + }); + + assert.equal(input.tray, null); + assert.equal(input.windows.windowState.getMainWindow(), null); + assert.equal(input.windows.geometry.getCurrentOverlayGeometry().width, 3); + assert.equal(input.windows.visibilityService.resolveFallbackBounds().height, 8); + assert.equal( + input.bootstrap.initializeOverlayRuntimeBootstrapDeps.isOverlayRuntimeInitialized(), + false, + ); +}); diff --git a/src/main/overlay-ui-bootstrap-runtime-input.ts b/src/main/overlay-ui-bootstrap-runtime-input.ts new file mode 100644 index 00000000..d314ec12 --- /dev/null +++ b/src/main/overlay-ui-bootstrap-runtime-input.ts @@ -0,0 +1,87 @@ +import type { OverlayHostedModal } from '../shared/ipc/contracts'; +import type { + OverlayUiActionsInput, + OverlayUiBootstrapInput, + OverlayUiGeometryInput, + OverlayUiModalInput, + OverlayUiMpvSubtitleInput, + OverlayUiRuntimeStateInput, + OverlayUiTrayInput, + OverlayUiVisibilityActionsInput, + OverlayUiVisibilityServiceInput, + OverlayUiWindowState, + OverlayUiWindowsInput, +} from './overlay-ui-runtime'; +import type { OverlayUiRuntimeGroupedInput } from './overlay-ui-runtime-input'; + +type WindowLike = { + isDestroyed: () => boolean; +}; + +export interface OverlayUiBootstrapRuntimeWindowsInput { + state: OverlayUiWindowState; + geometry: OverlayUiGeometryInput; + modal: OverlayUiModalInput; + modalRuntime: { + handleOverlayModalClosed: (modal: OverlayHostedModal) => void; + notifyOverlayModalOpened: (modal: OverlayHostedModal) => void; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + getRestoreVisibleOverlayOnModalClose: () => Set; + openRuntimeOptionsPalette: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + }; + visibility: { + service: OverlayUiVisibilityServiceInput; + overlayWindows: OverlayUiWindowsInput; + actions: OverlayUiVisibilityActionsInput; + }; +} + +export interface OverlayUiBootstrapRuntimeInput { + windows: OverlayUiBootstrapRuntimeWindowsInput; + overlayActions: OverlayUiActionsInput; + tray: OverlayUiTrayInput | null; + bootstrap: OverlayUiBootstrapInput; + runtimeState: OverlayUiRuntimeStateInput; + mpvSubtitle: OverlayUiMpvSubtitleInput; +} + +export function createOverlayUiBootstrapRuntimeInput( + input: OverlayUiBootstrapRuntimeInput, +): OverlayUiRuntimeGroupedInput { + return { + windows: { + windowState: input.windows.state, + geometry: input.windows.geometry, + modal: input.windows.modal, + modalRuntime: { + handleOverlayModalClosed: (modal) => + input.windows.modalRuntime.handleOverlayModalClosed(modal), + notifyOverlayModalOpened: (modal) => + input.windows.modalRuntime.notifyOverlayModalOpened(modal), + waitForModalOpen: (modal, timeoutMs) => + input.windows.modalRuntime.waitForModalOpen(modal, timeoutMs), + getRestoreVisibleOverlayOnModalClose: () => + input.windows.modalRuntime.getRestoreVisibleOverlayOnModalClose(), + openRuntimeOptionsPalette: () => input.windows.modalRuntime.openRuntimeOptionsPalette(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => + input.windows.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + }, + visibilityService: input.windows.visibility.service, + overlayWindows: input.windows.visibility.overlayWindows, + visibilityActions: input.windows.visibility.actions, + }, + overlayActions: input.overlayActions, + tray: input.tray, + bootstrap: input.bootstrap, + runtimeState: input.runtimeState, + mpvSubtitle: input.mpvSubtitle, + }; +} diff --git a/src/main/overlay-ui-bootstrap-runtime.ts b/src/main/overlay-ui-bootstrap-runtime.ts new file mode 100644 index 00000000..e56a6b5f --- /dev/null +++ b/src/main/overlay-ui-bootstrap-runtime.ts @@ -0,0 +1,503 @@ +import type { BrowserWindow, Session } from 'electron'; +import type { + AnkiConnectConfig, + KikuFieldGroupingChoice, + KikuFieldGroupingRequestData, + RuntimeOptionState, + WindowGeometry, +} from '../types'; +import type { BaseWindowTracker } from '../window-trackers'; +import { + createOverlayGeometryRuntime, + type OverlayGeometryRuntime, +} from './overlay-geometry-runtime'; +import { createOverlayUiBootstrapRuntimeInput } from './overlay-ui-bootstrap-runtime-input'; +import type { OverlayModalRuntime } from './overlay-runtime'; +import { createOverlayUiRuntime, type OverlayUiRuntime } from './overlay-ui-runtime'; + +type WindowLike = { + isDestroyed: () => boolean; +}; + +type OverlayWindowKind = 'visible' | 'modal'; + +type ScreenLike = { + getCursorScreenPoint: () => { x: number; y: number }; + getDisplayNearestPoint: (point: { x: number; y: number }) => { + workArea: { x: number; y: number; width: number; height: number }; + }; +}; + +type OverlayWindowTrackerLike = BaseWindowTracker | null; + +type OverlayRuntimeOptionsManagerLike = { + listOptions: () => RuntimeOptionState[]; + getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; +} | null; + +type OverlayMpvClientLike = { + connected: boolean; + restorePreviousSecondarySubVisibility: () => void; + send?: (payload: { command: string[] }) => void; +} | null; + +type OverlayManagerLike = { + getMainWindow: () => TWindow | null; + setMainWindow: (window: TWindow | null) => void; + getModalWindow: () => TWindow | null; + setModalWindow: (window: TWindow | null) => void; + getVisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + setOverlayWindowBounds: (geometry: WindowGeometry) => void; + setModalWindowBounds: (geometry: WindowGeometry) => void; + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; + getOverlayWindows: () => BrowserWindow[]; +}; + +type OverlayModalInputStateLike = { + getModalInputExclusive: () => boolean; + handleModalInputStateChange: (active: boolean) => void; +}; + +type OverlayShortcutsRuntimeLike = { + syncOverlayShortcuts: () => void; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; +}; + +type DictionarySupportLike = { + createFieldGroupingCallback: () => ( + data: KikuFieldGroupingRequestData, + ) => Promise; +}; + +type FirstRunLike = { + isSetupCompleted: () => boolean; + openFirstRunSetupWindow: () => void; +}; + +type YomitanLike = { + openYomitanSettings: () => void; +}; + +type JellyfinLike = { + openJellyfinSetupWindow: () => void; +}; + +type AnilistLike = { + openAnilistSetupWindow: () => void; +}; + +type BootstrapTrayIconLike = { + isEmpty: () => boolean; + resize: (options: { + width: number; + height: number; + quality?: 'best' | 'better' | 'good'; + }) => BootstrapTrayIconLike; + setTemplateImage: (enabled: boolean) => void; +}; + +type BootstrapTrayLike = { + setContextMenu: (menu: any) => void; + setToolTip: (tooltip: string) => void; + on: (event: 'click', handler: () => void) => void; + destroy: () => void; +}; + +export interface OverlayUiBootstrapAppStateInput { + backendOverride: string | null; + windowTracker: OverlayWindowTrackerLike; + subtitleTimingTracker: unknown; + mpvClient: OverlayMpvClientLike; + mpvSocketPath: string; + runtimeOptionsManager: OverlayRuntimeOptionsManagerLike; + ankiIntegration: unknown; + overlayRuntimeInitialized: boolean; + overlayDebugVisualizationEnabled: boolean; + statsOverlayVisible: boolean; + trackerNotReadyWarningShown: boolean; + yomitanSession: Session | null; +} + +export interface OverlayUiBootstrapElectronInput< + TWindow extends WindowLike, + TMenuItem = unknown, + TMenu = unknown, +> { + screen: ScreenLike; + appPath: string; + resourcesPath: string; + dirname: string; + platform: NodeJS.Platform; + joinPath: (...parts: string[]) => string; + fileExists: (candidate: string) => boolean; + createImageFromPath: (iconPath: string) => BootstrapTrayIconLike; + createEmptyImage: () => BootstrapTrayIconLike; + createTray: (icon: BootstrapTrayIconLike) => BootstrapTrayLike; + buildMenuFromTemplate: (template: TMenuItem[]) => TMenu; +} + +export interface OverlayUiBootstrapInput { + appState: OverlayUiBootstrapAppStateInput; + overlayManager: OverlayManagerLike; + overlayModalInputState: OverlayModalInputStateLike; + overlayModalRuntime: OverlayModalRuntime; + overlayShortcutsRuntime: OverlayShortcutsRuntimeLike; + dictionarySupport: DictionarySupportLike; + firstRun: FirstRunLike; + yomitan: YomitanLike; + jellyfin: JellyfinLike; + anilist: AnilistLike; + electron: OverlayUiBootstrapElectronInput; + windowing: { + isDev: boolean; + createOverlayWindowCore: ( + kind: OverlayWindowKind, + options: { + isDev: boolean; + ensureOverlayWindowLevel: (window: TWindow) => void; + onRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + forwardTabToMpv: () => void; + onWindowClosed: (windowKind: OverlayWindowKind) => void; + yomitanSession?: Electron.Session | null; + }, + ) => TWindow; + ensureOverlayWindowLevelCore: (window: TWindow) => void; + syncOverlayWindowLayer: (window: TWindow, layer: 'visible') => void; + enforceOverlayLayerOrderCore: (params: { + visibleOverlayVisible: boolean; + mainWindow: TWindow | null; + ensureOverlayWindowLevel: (window: TWindow) => void; + }) => void; + createWindowTrackerCore: ( + override?: string | null, + targetMpvSocketPath?: string | null, + ) => BaseWindowTracker | null; + }; + actions: { + showMpvOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + sendMpvCommand: (command: (string | number)[]) => void; + broadcastRuntimeOptionsChangedRuntime: ( + getRuntimeOptionsState: () => RuntimeOptionState[], + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, + ) => void; + setOverlayDebugVisualizationEnabledRuntime: ( + currentEnabled: boolean, + nextEnabled: boolean, + setCurrentEnabled: (enabled: boolean) => void, + ) => void; + resolveTrayIconPathRuntime: (options: { + platform: string; + resourcesPath: string; + appPath: string; + dirname: string; + joinPath: (...parts: string[]) => string; + fileExists: (path: string) => boolean; + }) => string | null; + buildTrayMenuTemplateRuntime: (handlers: { + openOverlay: () => void; + openFirstRunSetup: () => void; + showFirstRunSetup: boolean; + openWindowsMpvLauncherSetup: () => void; + showWindowsMpvLauncherSetup: boolean; + openYomitanSettings: () => void; + openRuntimeOptions: () => void; + openJellyfinSetup: () => void; + openAnilistSetup: () => void; + quitApp: () => void; + }) => unknown[]; + initializeOverlayRuntimeCore: (options: unknown) => void; + ensureOverlayMpvSubtitlesHidden: () => Promise | void; + syncOverlayMpvSubtitleSuppression: () => void; + registerGlobalShortcuts: () => void; + startBackgroundWarmups: () => void; + getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; + requestAppQuit: () => void; + }; + trayState: { + getTray: () => BootstrapTrayLike | null; + setTray: (tray: BootstrapTrayLike | null) => void; + trayTooltip: string; + logWarn: (message: string) => void; + }; + startup: { + shouldSkipHeadlessOverlayBootstrap: () => boolean; + getKnownWordCacheStatePath: () => string; + onInitialized?: () => void; + }; +} + +export interface OverlayUiBootstrapRuntime { + overlayGeometry: OverlayGeometryRuntime; + overlayUi: OverlayUiRuntime; + syncOverlayVisibilityForModal: () => void; +} + +export function createOverlayUiBootstrapRuntime( + input: OverlayUiBootstrapInput, +): OverlayUiBootstrapRuntime { + const overlayGeometry = createOverlayGeometryRuntime({ + screen: input.electron.screen, + windowState: { + getMainWindow: () => input.overlayManager.getMainWindow(), + setOverlayWindowBounds: (geometry) => input.overlayManager.setOverlayWindowBounds(geometry), + setModalWindowBounds: (geometry) => input.overlayManager.setModalWindowBounds(geometry), + getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(), + }, + getWindowTracker: () => input.appState.windowTracker, + ensureOverlayWindowLevelCore: (window) => input.windowing.ensureOverlayWindowLevelCore(window), + syncOverlayWindowLayer: (window, layer) => + input.windowing.syncOverlayWindowLayer(window, layer), + enforceOverlayLayerOrderCore: (params) => input.windowing.enforceOverlayLayerOrderCore(params), + }); + + let overlayUi: OverlayUiRuntime | undefined; + + overlayUi = createOverlayUiRuntime( + createOverlayUiBootstrapRuntimeInput({ + windows: { + state: { + getMainWindow: () => input.overlayManager.getMainWindow(), + setMainWindow: (window) => input.overlayManager.setMainWindow(window), + getModalWindow: () => input.overlayManager.getModalWindow(), + setModalWindow: (window) => input.overlayManager.setModalWindow(window), + getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(), + setVisibleOverlayVisible: (visible) => + input.overlayManager.setVisibleOverlayVisible(visible), + getOverlayDebugVisualizationEnabled: () => + input.appState.overlayDebugVisualizationEnabled, + setOverlayDebugVisualizationEnabled: (enabled) => { + input.appState.overlayDebugVisualizationEnabled = enabled; + }, + }, + geometry: { + getCurrentOverlayGeometry: () => overlayGeometry.getCurrentOverlayGeometry(), + }, + modal: { + setModalWindowBounds: (geometry) => input.overlayManager.setModalWindowBounds(geometry), + onModalStateChange: (active) => { + input.overlayModalInputState.handleModalInputStateChange(active); + }, + }, + modalRuntime: input.overlayModalRuntime as never, + visibility: { + service: { + getModalActive: () => input.overlayModalInputState.getModalInputExclusive(), + getForceMousePassthrough: () => input.appState.statsOverlayVisible, + getWindowTracker: () => input.appState.windowTracker, + getTrackerNotReadyWarningShown: () => input.appState.trackerNotReadyWarningShown, + setTrackerNotReadyWarningShown: (shown) => { + input.appState.trackerNotReadyWarningShown = shown; + }, + updateVisibleOverlayBounds: (geometry) => + overlayGeometry.updateVisibleOverlayBounds(geometry), + ensureOverlayWindowLevel: (window) => overlayGeometry.ensureOverlayWindowLevel(window), + syncPrimaryOverlayWindowLayer: (layer) => + overlayGeometry.syncPrimaryOverlayWindowLayer(layer), + enforceOverlayLayerOrder: () => overlayGeometry.enforceOverlayLayerOrder(), + syncOverlayShortcuts: () => input.overlayShortcutsRuntime.syncOverlayShortcuts(), + isMacOSPlatform: () => input.electron.platform === 'darwin', + isWindowsPlatform: () => input.electron.platform === 'win32', + showOverlayLoadingOsd: (message) => input.actions.showMpvOsd(message), + resolveFallbackBounds: () => overlayGeometry.getOverlayGeometryFallback(), + }, + overlayWindows: { + createOverlayWindowCore: (kind, options) => + input.windowing.createOverlayWindowCore(kind, options), + isDev: input.windowing.isDev, + ensureOverlayWindowLevel: (window) => overlayGeometry.ensureOverlayWindowLevel(window), + onRuntimeOptionsChanged: () => { + overlayUi?.broadcastRuntimeOptionsChanged(); + }, + setOverlayDebugVisualizationEnabled: (enabled) => { + overlayUi?.setOverlayDebugVisualizationEnabled(enabled); + }, + isOverlayVisible: (windowKind) => + windowKind === 'visible' ? input.overlayManager.getVisibleOverlayVisible() : false, + getYomitanSession: () => input.appState.yomitanSession, + tryHandleOverlayShortcutLocalFallback: (overlayInput) => + input.overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(overlayInput), + forwardTabToMpv: () => input.actions.sendMpvCommand(['keypress', 'TAB']), + onWindowClosed: (windowKind) => { + if (windowKind === 'visible') { + input.overlayManager.setMainWindow(null); + return; + } + input.overlayManager.setModalWindow(null); + }, + }, + actions: { + setVisibleOverlayVisibleCore: ({ + visible, + setVisibleOverlayVisibleState, + updateVisibleOverlayVisibility, + }) => { + setVisibleOverlayVisibleState(visible); + updateVisibleOverlayVisibility(); + }, + }, + }, + }, + overlayActions: { + getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager, + getMpvClient: () => input.appState.mpvClient, + broadcastRuntimeOptionsChangedRuntime: ( + getRuntimeOptionsState, + broadcastToOverlayWindows, + ) => + input.actions.broadcastRuntimeOptionsChangedRuntime( + getRuntimeOptionsState, + broadcastToOverlayWindows, + ), + broadcastToOverlayWindows: (channel, ...args) => + input.overlayManager.broadcastToOverlayWindows(channel, ...args), + setOverlayDebugVisualizationEnabledRuntime: ( + currentEnabled, + nextEnabled, + setCurrentEnabled, + ) => + input.actions.setOverlayDebugVisualizationEnabledRuntime( + currentEnabled, + nextEnabled, + setCurrentEnabled, + ), + }, + tray: { + resolveTrayIconPathDeps: { + resolveTrayIconPathRuntime: input.actions.resolveTrayIconPathRuntime, + platform: input.electron.platform, + resourcesPath: input.electron.resourcesPath, + appPath: input.electron.appPath, + dirname: input.electron.dirname, + joinPath: (...parts) => input.electron.joinPath(...parts), + fileExists: (candidate) => input.electron.fileExists(candidate), + }, + buildTrayMenuTemplateDeps: { + buildTrayMenuTemplateRuntime: input.actions.buildTrayMenuTemplateRuntime, + initializeOverlayRuntime: () => { + overlayUi?.initializeOverlayRuntime(); + }, + isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized, + setVisibleOverlayVisible: (visible) => { + overlayUi?.setVisibleOverlayVisible(visible); + }, + showFirstRunSetup: () => !input.firstRun.isSetupCompleted(), + openFirstRunSetupWindow: () => input.firstRun.openFirstRunSetupWindow(), + showWindowsMpvLauncherSetup: () => input.electron.platform === 'win32', + openYomitanSettings: () => input.yomitan.openYomitanSettings(), + openRuntimeOptionsPalette: () => { + overlayUi?.openRuntimeOptionsPalette(); + }, + openJellyfinSetupWindow: () => input.jellyfin.openJellyfinSetupWindow(), + openAnilistSetupWindow: () => input.anilist.openAnilistSetupWindow(), + quitApp: () => input.actions.requestAppQuit(), + }, + ensureTrayDeps: { + getTray: () => input.trayState.getTray(), + setTray: (tray) => input.trayState.setTray(tray), + createImageFromPath: (iconPath) => input.electron.createImageFromPath(iconPath), + createEmptyImage: () => input.electron.createEmptyImage(), + createTray: (icon) => input.electron.createTray(icon), + trayTooltip: input.trayState.trayTooltip, + platform: input.electron.platform, + logWarn: (message) => input.trayState.logWarn(message), + initializeOverlayRuntime: () => { + overlayUi?.initializeOverlayRuntime(); + }, + isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized, + setVisibleOverlayVisible: (visible) => { + overlayUi?.setVisibleOverlayVisible(visible); + }, + }, + destroyTrayDeps: { + getTray: () => input.trayState.getTray(), + setTray: (tray) => input.trayState.setTray(tray), + }, + buildMenuFromTemplate: (template) => input.electron.buildMenuFromTemplate(template), + }, + bootstrap: { + initializeOverlayRuntimeMainDeps: { + appState: input.appState, + overlayManager: { + getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(), + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => { + overlayUi?.updateVisibleOverlayVisibility(); + }, + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => input.overlayShortcutsRuntime.syncOverlayShortcuts(), + }, + createMainWindow: () => { + if (input.startup.shouldSkipHeadlessOverlayBootstrap()) { + return; + } + overlayUi?.createMainWindow(); + }, + registerGlobalShortcuts: () => { + if (input.startup.shouldSkipHeadlessOverlayBootstrap()) { + return; + } + input.actions.registerGlobalShortcuts(); + }, + createWindowTracker: (override, targetMpvSocketPath) => { + if (input.startup.shouldSkipHeadlessOverlayBootstrap()) { + return null; + } + return input.windowing.createWindowTrackerCore( + override as string | null | undefined, + targetMpvSocketPath as string | null | undefined, + ); + }, + updateVisibleOverlayBounds: (geometry) => + overlayGeometry.updateVisibleOverlayBounds(geometry), + getOverlayWindows: () => input.overlayManager.getOverlayWindows(), + getResolvedConfig: () => input.actions.getResolvedConfig(), + showDesktopNotification: (title, options) => + input.actions.showDesktopNotification(title, options), + createFieldGroupingCallback: () => input.dictionarySupport.createFieldGroupingCallback(), + getKnownWordCacheStatePath: () => input.startup.getKnownWordCacheStatePath(), + shouldStartAnkiIntegration: () => !input.startup.shouldSkipHeadlessOverlayBootstrap(), + }, + initializeOverlayRuntimeBootstrapDeps: { + isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized, + initializeOverlayRuntimeCore: (options) => + input.actions.initializeOverlayRuntimeCore(options), + setOverlayRuntimeInitialized: (initialized) => { + input.appState.overlayRuntimeInitialized = initialized; + }, + startBackgroundWarmups: () => { + if (input.startup.shouldSkipHeadlessOverlayBootstrap()) { + return; + } + input.actions.startBackgroundWarmups(); + }, + }, + onInitialized: input.startup.onInitialized, + }, + runtimeState: { + isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized, + setOverlayRuntimeInitialized: (initialized) => { + input.appState.overlayRuntimeInitialized = initialized; + }, + }, + mpvSubtitle: { + ensureOverlayMpvSubtitlesHidden: () => input.actions.ensureOverlayMpvSubtitlesHidden(), + syncOverlayMpvSubtitleSuppression: () => input.actions.syncOverlayMpvSubtitleSuppression(), + }, + }), + ); + + return { + overlayGeometry, + overlayUi, + syncOverlayVisibilityForModal: () => { + overlayUi.updateVisibleOverlayVisibility(); + }, + }; +} diff --git a/src/main/overlay-ui-runtime-input.ts b/src/main/overlay-ui-runtime-input.ts new file mode 100644 index 00000000..899f13f8 --- /dev/null +++ b/src/main/overlay-ui-runtime-input.ts @@ -0,0 +1,92 @@ +import type { OverlayHostedModal } from '../shared/ipc/contracts'; +import type { WindowGeometry } from '../types'; +import type { + OverlayUiActionsInput, + OverlayUiBootstrapInput, + OverlayUiGeometryInput, + OverlayUiModalInput, + OverlayUiMpvSubtitleInput, + OverlayUiRuntimeInput, + OverlayUiRuntimeStateInput, + OverlayUiTrayInput, + OverlayUiVisibilityActionsInput, + OverlayUiVisibilityServiceInput, + OverlayUiWindowState, + OverlayUiWindowsInput, +} from './overlay-ui-runtime'; + +type WindowLike = { + isDestroyed: () => boolean; +}; + +export interface OverlayUiRuntimeWindowsInput { + windowState: OverlayUiWindowState; + geometry: OverlayUiGeometryInput; + modal: OverlayUiModalInput; + modalRuntime: { + handleOverlayModalClosed: (modal: OverlayHostedModal) => void; + notifyOverlayModalOpened: (modal: OverlayHostedModal) => void; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + getRestoreVisibleOverlayOnModalClose: () => Set; + openRuntimeOptionsPalette: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + }; + visibilityService: OverlayUiVisibilityServiceInput; + overlayWindows: OverlayUiWindowsInput; + visibilityActions: OverlayUiVisibilityActionsInput; +} + +export interface OverlayUiRuntimeGroupedInput { + windows: OverlayUiRuntimeWindowsInput; + overlayActions: OverlayUiActionsInput; + tray: OverlayUiTrayInput | null; + bootstrap: OverlayUiBootstrapInput; + runtimeState: OverlayUiRuntimeStateInput; + mpvSubtitle: OverlayUiMpvSubtitleInput; +} + +export type OverlayUiRuntimeInputLike = + | OverlayUiRuntimeInput + | OverlayUiRuntimeGroupedInput; + +export function normalizeOverlayUiRuntimeInput( + input: OverlayUiRuntimeInputLike, +): OverlayUiRuntimeInput { + if (!('windows' in input)) { + return input; + } + + return { + windowState: input.windows.windowState, + geometry: input.windows.geometry, + modal: input.windows.modal, + modalRuntime: { + handleOverlayModalClosed: (modal) => + input.windows.modalRuntime.handleOverlayModalClosed(modal), + notifyOverlayModalOpened: (modal) => + input.windows.modalRuntime.notifyOverlayModalOpened(modal), + waitForModalOpen: (modal, timeoutMs) => + input.windows.modalRuntime.waitForModalOpen(modal, timeoutMs), + getRestoreVisibleOverlayOnModalClose: () => + input.windows.modalRuntime.getRestoreVisibleOverlayOnModalClose(), + openRuntimeOptionsPalette: () => input.windows.modalRuntime.openRuntimeOptionsPalette(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => + input.windows.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + }, + visibilityService: input.windows.visibilityService, + overlayWindows: input.windows.overlayWindows, + visibilityActions: input.windows.visibilityActions, + overlayActions: input.overlayActions, + tray: input.tray, + bootstrap: input.bootstrap, + runtimeState: input.runtimeState, + mpvSubtitle: input.mpvSubtitle, + }; +} diff --git a/src/main/overlay-ui-runtime.test.ts b/src/main/overlay-ui-runtime.test.ts new file mode 100644 index 00000000..f5f7ca29 --- /dev/null +++ b/src/main/overlay-ui-runtime.test.ts @@ -0,0 +1,461 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { OverlayHostedModal } from '../shared/ipc/contracts'; + +import { createOverlayUiRuntime } from './overlay-ui-runtime'; + +type MockWindow = { + destroyed: boolean; + isDestroyed: () => boolean; +}; + +function createWindow(): MockWindow { + return { + destroyed: false, + isDestroyed() { + return this.destroyed; + }, + }; +} + +test('overlay ui runtime lazy-creates main window for toggle visibility actions', async () => { + const calls: string[] = []; + let mainWindow: MockWindow | null = null; + const createdWindow = createWindow(); + let visibleOverlayVisible = false; + + const overlayUi = createOverlayUiRuntime({ + windows: { + windowState: { + getMainWindow: () => mainWindow, + setMainWindow: (window) => { + mainWindow = window; + }, + getModalWindow: () => null, + setModalWindow: () => {}, + getVisibleOverlayVisible: () => visibleOverlayVisible, + setVisibleOverlayVisible: (visible) => { + visibleOverlayVisible = visible; + }, + getOverlayDebugVisualizationEnabled: () => false, + setOverlayDebugVisualizationEnabled: () => {}, + }, + geometry: { + getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }), + }, + modal: { + onModalStateChange: () => {}, + }, + modalRuntime: { + handleOverlayModalClosed: () => {}, + notifyOverlayModalOpened: () => {}, + waitForModalOpen: async () => false, + getRestoreVisibleOverlayOnModalClose: () => new Set(), + openRuntimeOptionsPalette: () => {}, + sendToActiveOverlayWindow: () => false, + }, + visibilityService: { + getModalActive: () => false, + getForceMousePassthrough: () => false, + getWindowTracker: () => null, + getTrackerNotReadyWarningShown: () => false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => {}, + ensureOverlayWindowLevel: () => {}, + syncPrimaryOverlayWindowLayer: () => {}, + enforceOverlayLayerOrder: () => {}, + syncOverlayShortcuts: () => {}, + isMacOSPlatform: () => false, + isWindowsPlatform: () => false, + showOverlayLoadingOsd: () => {}, + resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }), + }, + overlayWindows: { + createOverlayWindowCore: () => createdWindow, + isDev: false, + ensureOverlayWindowLevel: () => {}, + onRuntimeOptionsChanged: () => {}, + setOverlayDebugVisualizationEnabled: () => {}, + isOverlayVisible: () => visibleOverlayVisible, + getYomitanSession: () => null, + tryHandleOverlayShortcutLocalFallback: () => false, + forwardTabToMpv: () => {}, + onWindowClosed: () => {}, + }, + visibilityActions: { + setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => { + calls.push(`setVisible:${visible}`); + setVisibleOverlayVisibleState(visible); + }, + }, + }, + overlayActions: { + getRuntimeOptionsManager: () => null, + getMpvClient: () => null, + broadcastRuntimeOptionsChangedRuntime: () => {}, + broadcastToOverlayWindows: () => {}, + setOverlayDebugVisualizationEnabledRuntime: () => {}, + }, + tray: null, + bootstrap: { + initializeOverlayRuntimeMainDeps: { + appState: { + backendOverride: null, + windowTracker: null, + subtitleTimingTracker: null, + mpvClient: null, + mpvSocketPath: '/tmp/mpv.sock', + runtimeOptionsManager: null, + ankiIntegration: null, + }, + overlayManager: { + getVisibleOverlayVisible: () => visibleOverlayVisible, + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => {}, + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => {}, + }, + createMainWindow: () => { + calls.push('bootstrapCreateMainWindow'); + }, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + getOverlayWindows: () => [], + getResolvedConfig: () => ({ ankiConnect: {} }) as never, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => () => Promise.resolve({} as never), + getKnownWordCacheStatePath: () => '/tmp/known.json', + shouldStartAnkiIntegration: () => false, + }, + initializeOverlayRuntimeBootstrapDeps: { + isOverlayRuntimeInitialized: () => true, + initializeOverlayRuntimeCore: () => {}, + setOverlayRuntimeInitialized: () => {}, + startBackgroundWarmups: () => {}, + }, + onInitialized: () => {}, + }, + runtimeState: { + isOverlayRuntimeInitialized: () => true, + setOverlayRuntimeInitialized: () => {}, + }, + mpvSubtitle: { + ensureOverlayMpvSubtitlesHidden: async () => { + calls.push('hideMpvSubs'); + }, + syncOverlayMpvSubtitleSuppression: () => { + calls.push('syncMpvSubs'); + }, + }, + }); + + overlayUi.toggleVisibleOverlay(); + + assert.equal(mainWindow, createdWindow); + assert.deepEqual(calls, ['hideMpvSubs', 'setVisible:true', 'syncMpvSubs']); +}); + +test('overlay ui runtime initializes overlay runtime before visible action when needed', async () => { + const calls: string[] = []; + let visibleOverlayVisible = false; + let overlayRuntimeInitialized = false; + + const overlayUi = createOverlayUiRuntime({ + windowState: { + getMainWindow: () => null, + setMainWindow: () => {}, + getModalWindow: () => null, + setModalWindow: () => {}, + getVisibleOverlayVisible: () => visibleOverlayVisible, + setVisibleOverlayVisible: (visible) => { + visibleOverlayVisible = visible; + }, + getOverlayDebugVisualizationEnabled: () => false, + setOverlayDebugVisualizationEnabled: () => {}, + }, + geometry: { + getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }), + }, + modal: { + onModalStateChange: () => {}, + }, + modalRuntime: { + handleOverlayModalClosed: () => {}, + notifyOverlayModalOpened: () => {}, + waitForModalOpen: async () => false, + getRestoreVisibleOverlayOnModalClose: () => new Set(), + openRuntimeOptionsPalette: () => {}, + sendToActiveOverlayWindow: () => false, + }, + visibilityService: { + getModalActive: () => false, + getForceMousePassthrough: () => false, + getWindowTracker: () => null, + getTrackerNotReadyWarningShown: () => false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => {}, + ensureOverlayWindowLevel: () => {}, + syncPrimaryOverlayWindowLayer: () => {}, + enforceOverlayLayerOrder: () => {}, + syncOverlayShortcuts: () => {}, + isMacOSPlatform: () => false, + isWindowsPlatform: () => false, + showOverlayLoadingOsd: () => {}, + resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }), + }, + overlayWindows: { + createOverlayWindowCore: () => createWindow(), + isDev: false, + ensureOverlayWindowLevel: () => {}, + onRuntimeOptionsChanged: () => {}, + setOverlayDebugVisualizationEnabled: () => {}, + isOverlayVisible: () => visibleOverlayVisible, + getYomitanSession: () => null, + tryHandleOverlayShortcutLocalFallback: () => false, + forwardTabToMpv: () => {}, + onWindowClosed: () => {}, + }, + visibilityActions: { + setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => { + calls.push(`setVisible:${visible}`); + setVisibleOverlayVisibleState(visible); + }, + }, + overlayActions: { + getRuntimeOptionsManager: () => null, + getMpvClient: () => null, + broadcastRuntimeOptionsChangedRuntime: () => {}, + broadcastToOverlayWindows: () => {}, + setOverlayDebugVisualizationEnabledRuntime: () => {}, + }, + tray: null, + bootstrap: { + initializeOverlayRuntimeMainDeps: { + appState: { + backendOverride: null, + windowTracker: null, + subtitleTimingTracker: null, + mpvClient: null, + mpvSocketPath: '/tmp/mpv.sock', + runtimeOptionsManager: null, + ankiIntegration: null, + }, + overlayManager: { + getVisibleOverlayVisible: () => visibleOverlayVisible, + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => {}, + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => {}, + }, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + getOverlayWindows: () => [], + getResolvedConfig: () => ({ ankiConnect: {} }) as never, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => () => Promise.resolve({} as never), + getKnownWordCacheStatePath: () => '/tmp/known.json', + shouldStartAnkiIntegration: () => false, + }, + initializeOverlayRuntimeBootstrapDeps: { + isOverlayRuntimeInitialized: () => overlayRuntimeInitialized, + initializeOverlayRuntimeCore: () => { + calls.push('initializeOverlayRuntimeCore'); + }, + setOverlayRuntimeInitialized: (initialized) => { + overlayRuntimeInitialized = initialized; + calls.push(`setInitialized:${initialized}`); + }, + startBackgroundWarmups: () => { + calls.push('startBackgroundWarmups'); + }, + }, + onInitialized: () => { + calls.push('onInitialized'); + }, + }, + runtimeState: { + isOverlayRuntimeInitialized: () => overlayRuntimeInitialized, + setOverlayRuntimeInitialized: (initialized) => { + overlayRuntimeInitialized = initialized; + }, + }, + mpvSubtitle: { + ensureOverlayMpvSubtitlesHidden: async () => { + calls.push('hideMpvSubs'); + }, + syncOverlayMpvSubtitleSuppression: () => { + calls.push('syncMpvSubs'); + }, + }, + }); + + overlayUi.setVisibleOverlayVisible(true); + + assert.deepEqual(calls, [ + 'setInitialized:true', + 'initializeOverlayRuntimeCore', + 'startBackgroundWarmups', + 'onInitialized', + 'syncMpvSubs', + 'hideMpvSubs', + 'setVisible:true', + 'syncMpvSubs', + ]); +}); + +test('overlay ui runtime delegates modal actions to injected modal runtime', async () => { + const calls: string[] = []; + const restoreOnClose = new Set(); + + const overlayUi = createOverlayUiRuntime({ + windowState: { + getMainWindow: () => null, + setMainWindow: () => {}, + getModalWindow: () => null, + setModalWindow: () => {}, + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getOverlayDebugVisualizationEnabled: () => false, + setOverlayDebugVisualizationEnabled: () => {}, + }, + geometry: { + getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }), + }, + modal: { + onModalStateChange: () => {}, + }, + visibilityService: { + getModalActive: () => false, + getForceMousePassthrough: () => false, + getWindowTracker: () => null, + getTrackerNotReadyWarningShown: () => false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => {}, + ensureOverlayWindowLevel: () => {}, + syncPrimaryOverlayWindowLayer: () => {}, + enforceOverlayLayerOrder: () => {}, + syncOverlayShortcuts: () => {}, + isMacOSPlatform: () => false, + isWindowsPlatform: () => false, + showOverlayLoadingOsd: () => {}, + resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }), + }, + overlayWindows: { + createOverlayWindowCore: () => createWindow(), + isDev: false, + ensureOverlayWindowLevel: () => {}, + onRuntimeOptionsChanged: () => {}, + setOverlayDebugVisualizationEnabled: () => {}, + isOverlayVisible: () => false, + getYomitanSession: () => null, + tryHandleOverlayShortcutLocalFallback: () => false, + forwardTabToMpv: () => {}, + onWindowClosed: () => {}, + }, + visibilityActions: { + setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => { + setVisibleOverlayVisibleState(visible); + }, + }, + overlayActions: { + getRuntimeOptionsManager: () => null, + getMpvClient: () => null, + broadcastRuntimeOptionsChangedRuntime: () => {}, + broadcastToOverlayWindows: () => {}, + setOverlayDebugVisualizationEnabledRuntime: () => {}, + }, + modalRuntime: { + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => { + calls.push(`send:${channel}:${String(payload)}`); + if (runtimeOptions?.restoreOnModalClose) { + restoreOnClose.add(runtimeOptions.restoreOnModalClose); + } + return true; + }, + openRuntimeOptionsPalette: () => { + calls.push('openRuntimeOptionsPalette'); + }, + handleOverlayModalClosed: (modal) => { + calls.push(`closed:${modal}`); + }, + notifyOverlayModalOpened: (modal) => { + calls.push(`opened:${modal}`); + }, + waitForModalOpen: async (modal, timeoutMs) => { + calls.push(`wait:${modal}:${timeoutMs}`); + return true; + }, + getRestoreVisibleOverlayOnModalClose: () => restoreOnClose, + }, + tray: null, + bootstrap: { + initializeOverlayRuntimeMainDeps: { + appState: { + backendOverride: null, + windowTracker: null, + subtitleTimingTracker: null, + mpvClient: null, + mpvSocketPath: '/tmp/mpv.sock', + runtimeOptionsManager: null, + ankiIntegration: null, + }, + overlayManager: { + getVisibleOverlayVisible: () => false, + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => {}, + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => {}, + }, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + getOverlayWindows: () => [], + getResolvedConfig: () => ({ ankiConnect: {} }) as never, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => () => Promise.resolve({} as never), + getKnownWordCacheStatePath: () => '/tmp/known.json', + shouldStartAnkiIntegration: () => false, + }, + initializeOverlayRuntimeBootstrapDeps: { + isOverlayRuntimeInitialized: () => true, + initializeOverlayRuntimeCore: () => {}, + setOverlayRuntimeInitialized: () => {}, + startBackgroundWarmups: () => {}, + }, + }, + runtimeState: { + isOverlayRuntimeInitialized: () => true, + setOverlayRuntimeInitialized: () => {}, + }, + mpvSubtitle: { + ensureOverlayMpvSubtitlesHidden: async () => {}, + syncOverlayMpvSubtitleSuppression: () => {}, + }, + }); + + assert.equal( + overlayUi.sendToActiveOverlayWindow('jimaku:open', 'payload', { + restoreOnModalClose: 'jimaku', + }), + true, + ); + overlayUi.openRuntimeOptionsPalette(); + overlayUi.notifyOverlayModalOpened('runtime-options'); + overlayUi.handleOverlayModalClosed('runtime-options'); + assert.equal(await overlayUi.waitForModalOpen('youtube-track-picker', 50), true); + assert.equal(overlayUi.getRestoreVisibleOverlayOnModalClose(), restoreOnClose); + assert.deepEqual(calls, [ + 'send:jimaku:open:payload', + 'openRuntimeOptionsPalette', + 'opened:runtime-options', + 'closed:runtime-options', + 'wait:youtube-track-picker:50', + ]); +}); diff --git a/src/main/overlay-ui-runtime.ts b/src/main/overlay-ui-runtime.ts new file mode 100644 index 00000000..4af71faf --- /dev/null +++ b/src/main/overlay-ui-runtime.ts @@ -0,0 +1,408 @@ +import type { Session } from 'electron'; +import type { OverlayHostedModal } from '../shared/ipc/contracts'; +import type { RuntimeOptionState, WindowGeometry } from '../types'; +import type { OverlayModalRuntime } from './overlay-runtime'; +import { + normalizeOverlayUiRuntimeInput, + type OverlayUiRuntimeInputLike, +} from './overlay-ui-runtime-input'; +import { + createOverlayVisibilityRuntimeBridge, + type OverlayUiVisibilityBridgeWindowLike, +} from './overlay-ui-visibility'; +import { createOverlayVisibilityRuntime } from './runtime/overlay-visibility-runtime'; +import { createOverlayWindowRuntimeHandlers } from './runtime/overlay-window-runtime-handlers'; +import { createTrayRuntimeHandlers } from './runtime/tray-runtime-handlers'; +import { createOverlayRuntimeBootstrapHandlers } from './runtime/overlay-runtime-bootstrap-handlers'; +import { composeOverlayVisibilityRuntime } from './runtime/composers/overlay-visibility-runtime-composer'; +import { + createBuildBroadcastRuntimeOptionsChangedMainDepsHandler, + createBuildGetRuntimeOptionsStateMainDepsHandler, + createBuildOpenRuntimeOptionsPaletteMainDepsHandler, + createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler, + createBuildSendToActiveOverlayWindowMainDepsHandler, + createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler, +} from './runtime/overlay-runtime-main-actions-main-deps'; +import { createGetRuntimeOptionsStateHandler } from './runtime/overlay-runtime-main-actions'; + +type OverlayWindowKind = 'visible' | 'modal'; + +type WindowLike = OverlayUiVisibilityBridgeWindowLike; + +type RuntimeOptionsManagerLike = { + listOptions: () => RuntimeOptionState[]; +}; + +type MpvClientLike = { + connected: boolean; + restorePreviousSecondarySubVisibility: () => void; +}; + +type TrayHandlersDeps = Parameters[0]; +type BootstrapHandlersDeps = Parameters[0]; + +type OverlayWindowCreateOptions = { + isDev: boolean; + ensureOverlayWindowLevel: (window: TWindow) => void; + onRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + forwardTabToMpv: () => void; + onWindowClosed: (windowKind: OverlayWindowKind) => void; + yomitanSession?: Electron.Session | null; +}; + +export interface OverlayUiWindowState { + getMainWindow: () => TWindow | null; + setMainWindow: (window: TWindow | null) => void; + getModalWindow: () => TWindow | null; + setModalWindow: (window: TWindow | null) => void; + getVisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + getOverlayDebugVisualizationEnabled: () => boolean; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; +} + +export interface OverlayUiGeometryInput { + getCurrentOverlayGeometry: () => WindowGeometry; +} + +export interface OverlayUiModalInput { + setModalWindowBounds?: (geometry: WindowGeometry) => void; + onModalStateChange?: (active: boolean) => void; +} + +export interface OverlayUiVisibilityServiceInput { + getModalActive: () => boolean; + getForceMousePassthrough: () => boolean; + getWindowTracker: () => unknown; + getTrackerNotReadyWarningShown: () => boolean; + setTrackerNotReadyWarningShown: (shown: boolean) => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + ensureOverlayWindowLevel: (window: TWindow) => void; + syncPrimaryOverlayWindowLayer: (layer: 'visible') => void; + enforceOverlayLayerOrder: () => void; + syncOverlayShortcuts: () => void; + isMacOSPlatform: () => boolean; + isWindowsPlatform: () => boolean; + showOverlayLoadingOsd: (message: string) => void; + resolveFallbackBounds: () => WindowGeometry; +} + +export interface OverlayUiWindowsInput { + createOverlayWindowCore: ( + kind: OverlayWindowKind, + options: OverlayWindowCreateOptions, + ) => TWindow; + isDev: boolean; + ensureOverlayWindowLevel: (window: TWindow) => void; + onRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; + getYomitanSession: () => Session | null; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + forwardTabToMpv: () => void; + onWindowClosed: (windowKind: OverlayWindowKind) => void; +} + +export interface OverlayUiVisibilityActionsInput { + setVisibleOverlayVisibleCore: (options: { + visible: boolean; + setVisibleOverlayVisibleState: (visible: boolean) => void; + updateVisibleOverlayVisibility: () => void; + }) => void; +} + +export interface OverlayUiActionsInput { + getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; + getMpvClient: () => MpvClientLike | null; + + broadcastRuntimeOptionsChangedRuntime: ( + getRuntimeOptionsState: () => RuntimeOptionState[], + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, + ) => void; + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; + setOverlayDebugVisualizationEnabledRuntime: ( + currentEnabled: boolean, + nextEnabled: boolean, + setCurrentEnabled: (enabled: boolean) => void, + ) => void; +} + +export interface OverlayUiTrayInput { + resolveTrayIconPathDeps: TrayHandlersDeps['resolveTrayIconPathDeps']; + buildTrayMenuTemplateDeps: TrayHandlersDeps['buildTrayMenuTemplateDeps']; + ensureTrayDeps: TrayHandlersDeps['ensureTrayDeps']; + destroyTrayDeps: TrayHandlersDeps['destroyTrayDeps']; + buildMenuFromTemplate: TrayHandlersDeps['buildMenuFromTemplate']; +} + +export interface OverlayUiBootstrapInput { + initializeOverlayRuntimeMainDeps: BootstrapHandlersDeps['initializeOverlayRuntimeMainDeps']; + initializeOverlayRuntimeBootstrapDeps: BootstrapHandlersDeps['initializeOverlayRuntimeBootstrapDeps']; + onInitialized?: () => void; +} + +export interface OverlayUiRuntimeStateInput { + isOverlayRuntimeInitialized: () => boolean; + setOverlayRuntimeInitialized: (initialized: boolean) => void; +} + +export interface OverlayUiMpvSubtitleInput { + ensureOverlayMpvSubtitlesHidden: () => Promise | void; + syncOverlayMpvSubtitleSuppression: () => void; +} + +export interface OverlayUiRuntimeInput { + windowState: OverlayUiWindowState; + geometry: OverlayUiGeometryInput; + modal: OverlayUiModalInput; + modalRuntime: OverlayModalRuntime; + visibilityService: OverlayUiVisibilityServiceInput; + overlayWindows: OverlayUiWindowsInput; + visibilityActions: OverlayUiVisibilityActionsInput; + overlayActions: OverlayUiActionsInput; + tray: OverlayUiTrayInput | null; + bootstrap: OverlayUiBootstrapInput; + runtimeState: OverlayUiRuntimeStateInput; + mpvSubtitle: OverlayUiMpvSubtitleInput; +} + +export interface OverlayUiRuntime { + createMainWindow: () => TWindow; + createModalWindow: () => TWindow; + ensureTray: () => void; + destroyTray: () => void; + initializeOverlayRuntime: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + setVisibleOverlayVisible: (visible: boolean) => void; + toggleVisibleOverlay: () => void; + setOverlayVisible: (visible: boolean) => void; + handleOverlayModalClosed: (modal: OverlayHostedModal) => void; + notifyOverlayModalOpened: (modal: OverlayHostedModal) => void; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + getRestoreVisibleOverlayOnModalClose: () => Set; + updateVisibleOverlayVisibility: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + openRuntimeOptionsPalette: () => void; + broadcastRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + restorePreviousSecondarySubVisibility: () => void; +} + +export function createOverlayUiRuntime( + input: OverlayUiRuntimeInputLike, +): OverlayUiRuntime { + const runtimeInput = normalizeOverlayUiRuntimeInput(input); + const overlayVisibilityRuntime = createOverlayVisibilityRuntimeBridge({ + getMainWindow: () => runtimeInput.windowState.getMainWindow(), + getVisibleOverlayVisible: () => runtimeInput.windowState.getVisibleOverlayVisible(), + getModalActive: () => runtimeInput.visibilityService.getModalActive(), + getForceMousePassthrough: () => runtimeInput.visibilityService.getForceMousePassthrough(), + getWindowTracker: () => runtimeInput.visibilityService.getWindowTracker(), + getTrackerNotReadyWarningShown: () => + runtimeInput.visibilityService.getTrackerNotReadyWarningShown(), + setTrackerNotReadyWarningShown: (shown) => + runtimeInput.visibilityService.setTrackerNotReadyWarningShown(shown), + updateVisibleOverlayBounds: (geometry) => + runtimeInput.visibilityService.updateVisibleOverlayBounds(geometry), + ensureOverlayWindowLevel: (window) => + runtimeInput.visibilityService.ensureOverlayWindowLevel(window), + syncPrimaryOverlayWindowLayer: (layer) => + runtimeInput.visibilityService.syncPrimaryOverlayWindowLayer(layer), + enforceOverlayLayerOrder: () => runtimeInput.visibilityService.enforceOverlayLayerOrder(), + syncOverlayShortcuts: () => runtimeInput.visibilityService.syncOverlayShortcuts(), + isMacOSPlatform: () => runtimeInput.visibilityService.isMacOSPlatform(), + isWindowsPlatform: () => runtimeInput.visibilityService.isWindowsPlatform(), + showOverlayLoadingOsd: (message) => + runtimeInput.visibilityService.showOverlayLoadingOsd(message), + }); + + const overlayWindowHandlers = createOverlayWindowRuntimeHandlers({ + createOverlayWindowDeps: { + createOverlayWindowCore: (kind, options) => + runtimeInput.overlayWindows.createOverlayWindowCore(kind, options), + isDev: runtimeInput.overlayWindows.isDev, + ensureOverlayWindowLevel: (window) => + runtimeInput.overlayWindows.ensureOverlayWindowLevel(window), + onRuntimeOptionsChanged: () => runtimeInput.overlayWindows.onRuntimeOptionsChanged(), + setOverlayDebugVisualizationEnabled: (enabled) => + runtimeInput.overlayWindows.setOverlayDebugVisualizationEnabled(enabled), + isOverlayVisible: (windowKind) => runtimeInput.overlayWindows.isOverlayVisible(windowKind), + getYomitanSession: () => runtimeInput.overlayWindows.getYomitanSession(), + tryHandleOverlayShortcutLocalFallback: (overlayInput) => + runtimeInput.overlayWindows.tryHandleOverlayShortcutLocalFallback(overlayInput), + forwardTabToMpv: () => runtimeInput.overlayWindows.forwardTabToMpv(), + onWindowClosed: (windowKind) => runtimeInput.overlayWindows.onWindowClosed(windowKind), + }, + setMainWindow: (window) => runtimeInput.windowState.setMainWindow(window), + setModalWindow: (window) => runtimeInput.windowState.setModalWindow(window), + }); + + const visibilityActions = createOverlayVisibilityRuntime({ + setVisibleOverlayVisibleDeps: { + setVisibleOverlayVisibleCore: (options) => + runtimeInput.visibilityActions.setVisibleOverlayVisibleCore(options), + setVisibleOverlayVisibleState: (visible) => + runtimeInput.windowState.setVisibleOverlayVisible(visible), + updateVisibleOverlayVisibility: () => + overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + }, + getVisibleOverlayVisible: () => runtimeInput.windowState.getVisibleOverlayVisible(), + }); + + const getRuntimeOptionsState = createGetRuntimeOptionsStateHandler( + createBuildGetRuntimeOptionsStateMainDepsHandler({ + getRuntimeOptionsManager: () => runtimeInput.overlayActions.getRuntimeOptionsManager(), + })(), + ); + + const overlayActions = composeOverlayVisibilityRuntime({ + overlayVisibilityRuntime, + restorePreviousSecondarySubVisibilityMainDeps: + createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({ + getMpvClient: () => runtimeInput.overlayActions.getMpvClient(), + })(), + broadcastRuntimeOptionsChangedMainDeps: + createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({ + broadcastRuntimeOptionsChangedRuntime: (getState, broadcast) => + runtimeInput.overlayActions.broadcastRuntimeOptionsChangedRuntime(getState, broadcast), + getRuntimeOptionsState: () => getRuntimeOptionsState(), + broadcastToOverlayWindows: (channel, ...args) => + runtimeInput.overlayActions.broadcastToOverlayWindows(channel, ...args), + })(), + sendToActiveOverlayWindowMainDeps: createBuildSendToActiveOverlayWindowMainDepsHandler({ + sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) => + runtimeInput.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + })(), + setOverlayDebugVisualizationEnabledMainDeps: + createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler({ + setOverlayDebugVisualizationEnabledRuntime: (currentEnabled, nextEnabled, setCurrent) => + runtimeInput.overlayActions.setOverlayDebugVisualizationEnabledRuntime( + currentEnabled, + nextEnabled, + setCurrent, + ), + getCurrentEnabled: () => runtimeInput.windowState.getOverlayDebugVisualizationEnabled(), + setCurrentEnabled: (enabled) => + runtimeInput.windowState.setOverlayDebugVisualizationEnabled(enabled), + })(), + openRuntimeOptionsPaletteMainDeps: createBuildOpenRuntimeOptionsPaletteMainDepsHandler({ + openRuntimeOptionsPaletteRuntime: () => runtimeInput.modalRuntime.openRuntimeOptionsPalette(), + })(), + }); + + const trayHandlers = runtimeInput.tray + ? createTrayRuntimeHandlers({ + resolveTrayIconPathDeps: runtimeInput.tray.resolveTrayIconPathDeps, + buildTrayMenuTemplateDeps: runtimeInput.tray.buildTrayMenuTemplateDeps, + ensureTrayDeps: runtimeInput.tray.ensureTrayDeps, + destroyTrayDeps: runtimeInput.tray.destroyTrayDeps, + buildMenuFromTemplate: (template) => runtimeInput.tray!.buildMenuFromTemplate(template), + }) + : null; + + const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = + createOverlayRuntimeBootstrapHandlers({ + initializeOverlayRuntimeMainDeps: runtimeInput.bootstrap.initializeOverlayRuntimeMainDeps, + initializeOverlayRuntimeBootstrapDeps: + runtimeInput.bootstrap.initializeOverlayRuntimeBootstrapDeps, + }); + + function createMainWindow(): TWindow { + return overlayWindowHandlers.createMainWindow(); + } + + function createModalWindow(): TWindow { + const existingWindow = runtimeInput.windowState.getModalWindow(); + if (existingWindow && !existingWindow.isDestroyed()) { + return existingWindow; + } + const window = overlayWindowHandlers.createModalWindow(); + runtimeInput.modal.setModalWindowBounds?.(runtimeInput.geometry.getCurrentOverlayGeometry()); + return window; + } + + function initializeOverlayRuntime(): void { + initializeOverlayRuntimeHandler(); + runtimeInput.bootstrap.onInitialized?.(); + runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression(); + } + + function ensureOverlayWindowsReadyForVisibilityActions(): void { + if (!runtimeInput.runtimeState.isOverlayRuntimeInitialized()) { + initializeOverlayRuntime(); + return; + } + + const mainWindow = runtimeInput.windowState.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + createMainWindow(); + } + } + + function setVisibleOverlayVisible(visible: boolean): void { + ensureOverlayWindowsReadyForVisibilityActions(); + if (visible) { + void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden(); + } + visibilityActions.setVisibleOverlayVisible(visible); + runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression(); + } + + function toggleVisibleOverlay(): void { + ensureOverlayWindowsReadyForVisibilityActions(); + if (!runtimeInput.windowState.getVisibleOverlayVisible()) { + void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden(); + } + visibilityActions.toggleVisibleOverlay(); + runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression(); + } + + function setOverlayVisible(visible: boolean): void { + if (visible) { + void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden(); + } + visibilityActions.setOverlayVisible(visible); + runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression(); + } + + return { + createMainWindow, + createModalWindow, + ensureTray: () => { + trayHandlers?.ensureTray(); + }, + destroyTray: () => { + trayHandlers?.destroyTray(); + }, + initializeOverlayRuntime, + ensureOverlayWindowsReadyForVisibilityActions, + setVisibleOverlayVisible, + toggleVisibleOverlay, + setOverlayVisible, + handleOverlayModalClosed: (modal) => runtimeInput.modalRuntime.handleOverlayModalClosed(modal), + notifyOverlayModalOpened: (modal) => runtimeInput.modalRuntime.notifyOverlayModalOpened(modal), + waitForModalOpen: (modal, timeoutMs) => + runtimeInput.modalRuntime.waitForModalOpen(modal, timeoutMs), + getRestoreVisibleOverlayOnModalClose: () => + runtimeInput.modalRuntime.getRestoreVisibleOverlayOnModalClose(), + updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => + overlayActions.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + openRuntimeOptionsPalette: () => overlayActions.openRuntimeOptionsPalette(), + broadcastRuntimeOptionsChanged: () => overlayActions.broadcastRuntimeOptionsChanged(), + setOverlayDebugVisualizationEnabled: (enabled) => + overlayActions.setOverlayDebugVisualizationEnabled(enabled), + restorePreviousSecondarySubVisibility: () => + overlayActions.restorePreviousSecondarySubVisibility(), + }; +} diff --git a/src/main/overlay-ui-visibility.ts b/src/main/overlay-ui-visibility.ts new file mode 100644 index 00000000..351c8305 --- /dev/null +++ b/src/main/overlay-ui-visibility.ts @@ -0,0 +1,128 @@ +import type { WindowGeometry } from '../types'; + +export type OverlayUiVisibilityBridgeWindowLike = { + isDestroyed: () => boolean; + hide?: () => void; + show?: () => void; + focus?: () => void; + setIgnoreMouseEvents?: (ignore: boolean, options?: { forward?: boolean }) => void; +}; + +export interface OverlayUiVisibilityBridgeInput< + TWindow extends OverlayUiVisibilityBridgeWindowLike = OverlayUiVisibilityBridgeWindowLike, +> { + getMainWindow: () => TWindow | null; + getVisibleOverlayVisible: () => boolean; + getModalActive: () => boolean; + getForceMousePassthrough: () => boolean; + getWindowTracker: () => unknown; + getTrackerNotReadyWarningShown: () => boolean; + setTrackerNotReadyWarningShown: (shown: boolean) => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + ensureOverlayWindowLevel: (window: TWindow) => void; + syncPrimaryOverlayWindowLayer: (layer: 'visible') => void; + enforceOverlayLayerOrder: () => void; + syncOverlayShortcuts: () => void; + isMacOSPlatform: () => boolean; + isWindowsPlatform: () => boolean; + showOverlayLoadingOsd: (message: string) => void; +} + +export function createOverlayVisibilityRuntimeBridge< + TWindow extends OverlayUiVisibilityBridgeWindowLike, +>(input: OverlayUiVisibilityBridgeInput) { + let lastOverlayLoadingOsdAtMs: number | null = null; + + return { + updateVisibleOverlayVisibility(): void { + const mainWindow = input.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + + if (input.getModalActive()) { + mainWindow.hide?.(); + input.syncOverlayShortcuts(); + return; + } + + const showPassiveVisibleOverlay = (): void => { + const forceMousePassthrough = input.getForceMousePassthrough() === true; + if (input.isWindowsPlatform() || forceMousePassthrough) { + mainWindow.setIgnoreMouseEvents?.(true, { forward: true }); + } else { + mainWindow.setIgnoreMouseEvents?.(false); + } + input.ensureOverlayWindowLevel(mainWindow); + mainWindow.show?.(); + if (!input.isWindowsPlatform() && !input.isMacOSPlatform() && !forceMousePassthrough) { + mainWindow.focus?.(); + } + }; + + const maybeShowOverlayLoadingOsd = (): void => { + if (!input.isMacOSPlatform()) { + return; + } + if (lastOverlayLoadingOsdAtMs !== null && Date.now() - lastOverlayLoadingOsdAtMs < 30_000) { + return; + } + input.showOverlayLoadingOsd('Overlay loading...'); + lastOverlayLoadingOsdAtMs = Date.now(); + }; + + if (!input.getVisibleOverlayVisible()) { + input.setTrackerNotReadyWarningShown(false); + lastOverlayLoadingOsdAtMs = null; + mainWindow.hide?.(); + input.syncOverlayShortcuts(); + return; + } + + const windowTracker = input.getWindowTracker() as { + isTracking: () => boolean; + getGeometry: () => WindowGeometry | null; + } | null; + + if (windowTracker && windowTracker.isTracking()) { + input.setTrackerNotReadyWarningShown(false); + const geometry = windowTracker.getGeometry(); + if (geometry) { + input.updateVisibleOverlayBounds(geometry); + } + input.syncPrimaryOverlayWindowLayer('visible'); + showPassiveVisibleOverlay(); + input.enforceOverlayLayerOrder(); + input.syncOverlayShortcuts(); + return; + } + + if (!windowTracker) { + if (input.isMacOSPlatform() || input.isWindowsPlatform()) { + if (!input.getTrackerNotReadyWarningShown()) { + input.setTrackerNotReadyWarningShown(true); + maybeShowOverlayLoadingOsd(); + } + mainWindow.hide?.(); + input.syncOverlayShortcuts(); + return; + } + + input.setTrackerNotReadyWarningShown(false); + input.syncPrimaryOverlayWindowLayer('visible'); + showPassiveVisibleOverlay(); + input.enforceOverlayLayerOrder(); + input.syncOverlayShortcuts(); + return; + } + + if (!input.getTrackerNotReadyWarningShown()) { + input.setTrackerNotReadyWarningShown(true); + maybeShowOverlayLoadingOsd(); + } + + mainWindow.hide?.(); + input.syncOverlayShortcuts(); + }, + }; +} diff --git a/src/main/runtime-option-helpers.ts b/src/main/runtime-option-helpers.ts new file mode 100644 index 00000000..87389818 --- /dev/null +++ b/src/main/runtime-option-helpers.ts @@ -0,0 +1,41 @@ +import type { ResolvedConfig } from '../types'; + +export function getRuntimeBooleanOption( + getOptionValue: ( + id: + | 'subtitle.annotation.nPlusOne' + | 'subtitle.annotation.jlpt' + | 'subtitle.annotation.frequency', + ) => unknown, + id: 'subtitle.annotation.nPlusOne' | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency', + fallback: boolean, +): boolean { + const value = getOptionValue(id); + return typeof value === 'boolean' ? value : fallback; +} + +export function shouldInitializeMecabForAnnotations(input: { + getResolvedConfig: () => ResolvedConfig; + getRuntimeBooleanOption: ( + id: + | 'subtitle.annotation.nPlusOne' + | 'subtitle.annotation.jlpt' + | 'subtitle.annotation.frequency', + fallback: boolean, + ) => boolean; +}): boolean { + const config = input.getResolvedConfig(); + const nPlusOneEnabled = input.getRuntimeBooleanOption( + 'subtitle.annotation.nPlusOne', + config.ankiConnect.knownWords.highlightEnabled, + ); + const jlptEnabled = input.getRuntimeBooleanOption( + 'subtitle.annotation.jlpt', + config.subtitleStyle.enableJlpt, + ); + const frequencyEnabled = input.getRuntimeBooleanOption( + 'subtitle.annotation.frequency', + config.subtitleStyle.frequencyDictionary.enabled, + ); + return nPlusOneEnabled || jlptEnabled || frequencyEnabled; +} diff --git a/src/main/runtime/discord-presence-runtime.ts b/src/main/runtime/discord-presence-runtime.ts index fcb6704e..da200c49 100644 --- a/src/main/runtime/discord-presence-runtime.ts +++ b/src/main/runtime/discord-presence-runtime.ts @@ -1,3 +1,7 @@ +import { createDiscordPresenceService } from '../../core/services'; +import type { ResolvedConfig } from '../../types'; +import { createDiscordRpcClient } from './discord-rpc-client.js'; + type DiscordPresenceServiceLike = { publish: (snapshot: { mediaTitle: string | null; @@ -72,3 +76,59 @@ export function createDiscordPresenceRuntime(deps: DiscordPresenceRuntimeDeps) { publishDiscordPresence, }; } + +export function createDiscordPresenceRuntimeFromMainState(input: { + appId: string; + appState: { + discordPresenceService: ReturnType | null; + mpvClient: MpvClientLike | null; + currentMediaTitle: string | null; + currentMediaPath: string | null; + currentSubText: string; + playbackPaused: boolean | null; + }; + getResolvedConfig: () => ResolvedConfig; + getFallbackMediaDurationSec: () => number | null; + logger: { + debug: (message: string, meta?: unknown) => void; + }; +}) { + const sessionStartedAtMs = Date.now(); + let mediaDurationSec: number | null = null; + + const discordPresenceRuntime = createDiscordPresenceRuntime({ + getDiscordPresenceService: () => input.appState.discordPresenceService, + isDiscordPresenceEnabled: () => input.getResolvedConfig().discordPresence.enabled === true, + getMpvClient: () => input.appState.mpvClient, + getCurrentMediaTitle: () => input.appState.currentMediaTitle, + getCurrentMediaPath: () => input.appState.currentMediaPath, + getCurrentSubtitleText: () => input.appState.currentSubText, + getPlaybackPaused: () => input.appState.playbackPaused, + getFallbackMediaDurationSec: () => input.getFallbackMediaDurationSec(), + getSessionStartedAtMs: () => sessionStartedAtMs, + getMediaDurationSec: () => mediaDurationSec, + setMediaDurationSec: (next) => { + mediaDurationSec = next; + }, + }); + + const initializeDiscordPresenceService = async (): Promise => { + if (input.getResolvedConfig().discordPresence.enabled !== true) { + input.appState.discordPresenceService = null; + return; + } + + input.appState.discordPresenceService = createDiscordPresenceService({ + config: input.getResolvedConfig().discordPresence, + createClient: () => createDiscordRpcClient(input.appId), + logDebug: (message, meta) => input.logger.debug(message, meta), + }); + await input.appState.discordPresenceService.start(); + discordPresenceRuntime.publishDiscordPresence(); + }; + + return { + discordPresenceRuntime, + initializeDiscordPresenceService, + }; +} diff --git a/src/main/runtime/overlay-mpv-sub-visibility.ts b/src/main/runtime/overlay-mpv-sub-visibility.ts index 16408347..7976a926 100644 --- a/src/main/runtime/overlay-mpv-sub-visibility.ts +++ b/src/main/runtime/overlay-mpv-sub-visibility.ts @@ -98,10 +98,62 @@ export function createRestoreOverlayMpvSubtitlesHandler(deps: { return; } - if (savedVisibility !== null) { - deps.setMpvSubVisibility(savedVisibility); - } + deps.setMpvSubVisibility(savedVisibility); deps.setSavedSubVisibility(null); }; } + +export function createOverlayMpvSubtitleSuppressionRuntime(deps: { + appState: { + mpvClient: MpvVisibilityClient | null; + overlaySavedMpvSubVisibility: boolean | null; + overlayMpvSubVisibilityRevision: number; + }; + getVisibleOverlayVisible: () => boolean; + setMpvSubVisibility: (visible: boolean) => void; + logWarn: (message: string, error: unknown) => void; +}) { + const ensureOverlayMpvSubtitlesHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({ + getMpvClient: () => deps.appState.mpvClient, + getSavedSubVisibility: () => deps.appState.overlaySavedMpvSubVisibility, + setSavedSubVisibility: (visible) => { + deps.appState.overlaySavedMpvSubVisibility = visible; + }, + getRevision: () => deps.appState.overlayMpvSubVisibilityRevision, + setRevision: (revision) => { + deps.appState.overlayMpvSubVisibilityRevision = revision; + }, + setMpvSubVisibility: (visible) => deps.setMpvSubVisibility(visible), + logWarn: (message, error) => deps.logWarn(message, error), + }); + + const restoreOverlayMpvSubtitles = createRestoreOverlayMpvSubtitlesHandler({ + getSavedSubVisibility: () => deps.appState.overlaySavedMpvSubVisibility, + setSavedSubVisibility: (visible) => { + deps.appState.overlaySavedMpvSubVisibility = visible; + }, + getRevision: () => deps.appState.overlayMpvSubVisibilityRevision, + setRevision: (revision) => { + deps.appState.overlayMpvSubVisibilityRevision = revision; + }, + isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected), + shouldKeepSuppressedFromVisibleOverlayBinding: () => deps.getVisibleOverlayVisible(), + setMpvSubVisibility: (visible) => deps.setMpvSubVisibility(visible), + }); + + const syncOverlayMpvSubtitleSuppression = (): void => { + if (deps.getVisibleOverlayVisible()) { + void ensureOverlayMpvSubtitlesHidden(); + return; + } + + restoreOverlayMpvSubtitles(); + }; + + return { + ensureOverlayMpvSubtitlesHidden, + restoreOverlayMpvSubtitles, + syncOverlayMpvSubtitleSuppression, + }; +} diff --git a/src/main/shortcuts-runtime.test.ts b/src/main/shortcuts-runtime.test.ts new file mode 100644 index 00000000..057d9dc9 --- /dev/null +++ b/src/main/shortcuts-runtime.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createShortcutsRuntime } from './shortcuts-runtime'; + +test('shortcuts runtime bridges modal shortcut sync to unregister and sync', () => { + const calls: string[] = []; + + const runtime = createShortcutsRuntime({ + globalShortcuts: { + getConfiguredShortcutsMainDeps: { + getResolvedConfig: () => ({}) as never, + defaultConfig: {} as never, + resolveConfiguredShortcuts: () => ({}) as never, + }, + buildRegisterGlobalShortcutsMainDeps: () => ({ + getConfiguredShortcuts: () => ({}) as never, + registerGlobalShortcutsCore: () => { + calls.push('registerGlobalShortcutsCore'); + }, + toggleVisibleOverlay: () => {}, + openYomitanSettings: () => {}, + isDev: false, + getMainWindow: () => null, + }), + buildRefreshGlobalAndOverlayShortcutsMainDeps: () => ({ + unregisterAllGlobalShortcuts: () => { + calls.push('unregisterAllGlobalShortcuts'); + }, + registerGlobalShortcuts: () => { + calls.push('registerGlobalShortcuts'); + }, + syncOverlayShortcuts: () => { + calls.push('syncOverlayShortcuts'); + }, + }), + }, + numericShortcutRuntimeMainDeps: { + globalShortcut: { + register: () => true, + unregister: () => {}, + }, + showMpvOsd: () => {}, + setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), + clearTimer: (timer) => clearTimeout(timer), + }, + numericSessions: { + onMultiCopyDigit: () => {}, + onMineSentenceDigit: () => {}, + }, + overlayShortcutsRuntimeMainDeps: { + overlayShortcutsRuntime: { + registerOverlayShortcuts: () => { + calls.push('registerOverlayShortcuts'); + }, + unregisterOverlayShortcuts: () => { + calls.push('unregisterOverlayShortcuts'); + }, + syncOverlayShortcuts: () => { + calls.push('syncOverlayShortcutsRuntime'); + }, + refreshOverlayShortcuts: () => { + calls.push('refreshOverlayShortcuts'); + }, + }, + }, + }); + + assert.equal(typeof runtime.getConfiguredShortcuts, 'function'); + assert.equal(typeof runtime.registerGlobalShortcuts, 'function'); + assert.equal(typeof runtime.syncOverlayShortcutsForModal, 'function'); + + runtime.syncOverlayShortcutsForModal(true); + runtime.syncOverlayShortcutsForModal(false); + + assert.deepEqual(calls, ['unregisterOverlayShortcuts', 'syncOverlayShortcutsRuntime']); +}); diff --git a/src/main/shortcuts-runtime.ts b/src/main/shortcuts-runtime.ts new file mode 100644 index 00000000..a0ca041e --- /dev/null +++ b/src/main/shortcuts-runtime.ts @@ -0,0 +1,278 @@ +import type { OverlayHostedModal } from '../shared/ipc/contracts'; +import type { ResolvedConfig } from '../types'; +import type { ConfiguredShortcuts } from '../core/utils/shortcut-config'; +import { DEFAULT_CONFIG } from '../config'; +import { resolveConfiguredShortcuts } from '../core/utils'; +import type { AppState } from './state'; +import type { MiningRuntime } from './mining-runtime'; +import type { OverlayModalRuntime } from './overlay-runtime'; +import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './runtime/domains/shortcuts'; +import { + composeShortcutRuntimes, + type ShortcutsRuntimeComposerOptions, +} from './runtime/composers/shortcuts-runtime-composer'; +import { createOverlayShortcutsRuntimeService } from './overlay-shortcuts-runtime'; + +type GlobalShortcutsInput = ShortcutsRuntimeComposerOptions['globalShortcuts']; +type NumericShortcutRuntimeMainDepsInput = + ShortcutsRuntimeComposerOptions['numericShortcutRuntimeMainDeps']; +type NumericSessionsInput = ShortcutsRuntimeComposerOptions['numericSessions']; +type OverlayShortcutsRuntimeMainDepsInput = + ShortcutsRuntimeComposerOptions['overlayShortcutsRuntimeMainDeps']; + +export interface ShortcutsRuntimeInput { + globalShortcuts: GlobalShortcutsInput; + numericShortcutRuntimeMainDeps: NumericShortcutRuntimeMainDepsInput; + numericSessions: NumericSessionsInput; + overlayShortcutsRuntimeMainDeps: OverlayShortcutsRuntimeMainDepsInput; +} + +export interface ShortcutsRuntime { + getConfiguredShortcuts: () => ConfiguredShortcuts; + registerGlobalShortcuts: () => void; + refreshGlobalAndOverlayShortcuts: () => void; + cancelPendingMultiCopy: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + cancelPendingMineSentenceMultiple: () => void; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + registerOverlayShortcuts: () => void; + unregisterOverlayShortcuts: () => void; + syncOverlayShortcuts: () => void; + refreshOverlayShortcuts: () => void; + syncOverlayShortcutsForModal: (isActive: boolean) => void; +} + +export interface ShortcutsRuntimeBootstrapInput { + globalShortcuts: ShortcutsRuntimeInput['globalShortcuts']; + numericShortcutRuntimeMainDeps: ShortcutsRuntimeInput['numericShortcutRuntimeMainDeps']; + numericSessions: ShortcutsRuntimeInput['numericSessions']; + overlayShortcuts: { + getConfiguredShortcuts: () => ConfiguredShortcuts; + getShortcutsRegistered: () => boolean; + setShortcutsRegistered: (registered: boolean) => void; + isOverlayRuntimeInitialized: () => boolean; + isOverlayShortcutContextActive: () => boolean; + showMpvOsd: (text: string) => void; + openRuntimeOptionsPalette: () => void; + openJimaku: () => void; + markAudioCard: () => void | Promise; + copySubtitle: () => void | Promise; + toggleSecondarySubMode: () => void; + updateLastCardFromClipboard: () => void | Promise; + triggerFieldGrouping: () => void | Promise; + triggerSubsyncFromConfig: () => void | Promise; + mineSentenceCard: () => void | Promise; + }; +} + +export function createShortcutsRuntime(input: ShortcutsRuntimeInput): ShortcutsRuntime { + const shortcutsRuntime = composeShortcutRuntimes({ + globalShortcuts: input.globalShortcuts, + numericShortcutRuntimeMainDeps: input.numericShortcutRuntimeMainDeps, + numericSessions: input.numericSessions, + overlayShortcutsRuntimeMainDeps: input.overlayShortcutsRuntimeMainDeps, + }); + + return { + ...shortcutsRuntime, + syncOverlayShortcutsForModal: (isActive: boolean) => { + if (isActive) { + shortcutsRuntime.unregisterOverlayShortcuts(); + return; + } + shortcutsRuntime.syncOverlayShortcuts(); + }, + }; +} + +export interface ShortcutsRuntimeBootstrap { + shortcuts: ShortcutsRuntime; + overlayShortcutsRuntime: ReturnType; + syncOverlayShortcutsForModal: (isActive: boolean) => void; +} + +export interface ShortcutsRuntimeFromMainStateInput { + appState: Pick; + getResolvedConfig: () => ResolvedConfig; + globalShortcut: NumericShortcutRuntimeMainDepsInput['globalShortcut'] & { + unregisterAll: () => void; + }; + registerGlobalShortcutsCore: typeof import('../core/services').registerGlobalShortcuts; + isDev: boolean; + overlay: { + getOverlayUi: () => + | { + toggleVisibleOverlay: () => void; + openRuntimeOptionsPalette: () => void; + } + | null + | undefined; + overlayManager: { + getMainWindow: () => Electron.BrowserWindow | null; + getVisibleOverlayVisible: () => boolean; + }; + overlayModalRuntime: Pick; + }; + actions: { + showMpvOsd: (text: string) => void; + openYomitanSettings: () => boolean; + triggerSubsyncFromConfig: () => Promise; + handleCycleSecondarySubMode: () => void; + handleMultiCopyDigit: (count: number) => void; + }; + mining: { + copyCurrentSubtitle: () => void; + handleMineSentenceDigit: (count: number) => void; + markLastCardAsAudioCard: () => Promise; + mineSentenceCard: () => Promise; + triggerFieldGrouping: () => Promise; + updateLastCardFromClipboard: () => Promise; + }; +} + +export function createShortcutsRuntimeBootstrap( + input: ShortcutsRuntimeBootstrapInput, +): ShortcutsRuntimeBootstrap { + let shortcuts: ShortcutsRuntime; + + const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( + createBuildOverlayShortcutsRuntimeMainDepsHandler({ + getConfiguredShortcuts: () => input.overlayShortcuts.getConfiguredShortcuts(), + getShortcutsRegistered: () => input.overlayShortcuts.getShortcutsRegistered(), + setShortcutsRegistered: (registered: boolean) => { + input.overlayShortcuts.setShortcutsRegistered(registered); + }, + isOverlayRuntimeInitialized: () => input.overlayShortcuts.isOverlayRuntimeInitialized(), + isOverlayShortcutContextActive: () => input.overlayShortcuts.isOverlayShortcutContextActive(), + showMpvOsd: (text: string) => input.overlayShortcuts.showMpvOsd(text), + openRuntimeOptionsPalette: () => { + input.overlayShortcuts.openRuntimeOptionsPalette(); + }, + openJimaku: () => { + input.overlayShortcuts.openJimaku(); + }, + markAudioCard: () => Promise.resolve(input.overlayShortcuts.markAudioCard()), + copySubtitleMultiple: (timeoutMs: number) => { + shortcuts.startPendingMultiCopy(timeoutMs); + }, + copySubtitle: () => Promise.resolve(input.overlayShortcuts.copySubtitle()), + toggleSecondarySubMode: () => input.overlayShortcuts.toggleSecondarySubMode(), + updateLastCardFromClipboard: () => + Promise.resolve(input.overlayShortcuts.updateLastCardFromClipboard()), + triggerFieldGrouping: () => Promise.resolve(input.overlayShortcuts.triggerFieldGrouping()), + triggerSubsyncFromConfig: () => + Promise.resolve(input.overlayShortcuts.triggerSubsyncFromConfig()), + mineSentenceCard: () => Promise.resolve(input.overlayShortcuts.mineSentenceCard()), + mineSentenceMultiple: (timeoutMs: number) => { + shortcuts.startPendingMineSentenceMultiple(timeoutMs); + }, + cancelPendingMultiCopy: () => { + shortcuts.cancelPendingMultiCopy(); + }, + cancelPendingMineSentenceMultiple: () => { + shortcuts.cancelPendingMineSentenceMultiple(); + }, + })(), + ); + + shortcuts = createShortcutsRuntime({ + globalShortcuts: input.globalShortcuts, + numericShortcutRuntimeMainDeps: input.numericShortcutRuntimeMainDeps, + numericSessions: input.numericSessions, + overlayShortcutsRuntimeMainDeps: { + overlayShortcutsRuntime, + }, + }); + + return { + shortcuts, + overlayShortcutsRuntime, + syncOverlayShortcutsForModal: (isActive: boolean) => { + shortcuts.syncOverlayShortcutsForModal(isActive); + }, + }; +} + +export function createShortcutsRuntimeFromMainState( + input: ShortcutsRuntimeFromMainStateInput, +): ShortcutsRuntimeBootstrap { + let shortcuts: ShortcutsRuntime; + + const bootstrap = createShortcutsRuntimeBootstrap({ + globalShortcuts: { + getConfiguredShortcutsMainDeps: { + getResolvedConfig: () => input.getResolvedConfig(), + defaultConfig: DEFAULT_CONFIG, + resolveConfiguredShortcuts, + }, + buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcutsHandler) => ({ + getConfiguredShortcuts: () => getConfiguredShortcutsHandler(), + registerGlobalShortcutsCore: input.registerGlobalShortcutsCore, + toggleVisibleOverlay: () => input.overlay.getOverlayUi()?.toggleVisibleOverlay(), + openYomitanSettings: () => { + input.actions.openYomitanSettings(); + }, + isDev: input.isDev, + getMainWindow: () => input.overlay.overlayManager.getMainWindow(), + }), + buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcutsHandler) => ({ + unregisterAllGlobalShortcuts: () => input.globalShortcut.unregisterAll(), + registerGlobalShortcuts: () => registerGlobalShortcutsHandler(), + syncOverlayShortcuts: () => shortcuts.syncOverlayShortcuts(), + }), + }, + numericShortcutRuntimeMainDeps: { + globalShortcut: input.globalShortcut, + showMpvOsd: (text) => input.actions.showMpvOsd(text), + setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), + clearTimer: (timer) => clearTimeout(timer), + }, + numericSessions: { + onMultiCopyDigit: (count) => input.actions.handleMultiCopyDigit(count), + onMineSentenceDigit: (count) => input.mining.handleMineSentenceDigit(count), + }, + overlayShortcuts: { + getConfiguredShortcuts: () => shortcuts.getConfiguredShortcuts(), + getShortcutsRegistered: () => input.appState.shortcutsRegistered, + setShortcutsRegistered: (registered: boolean) => { + input.appState.shortcutsRegistered = registered; + }, + isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized, + isOverlayShortcutContextActive: () => { + if (process.platform !== 'win32') { + return true; + } + + if (!input.overlay.overlayManager.getVisibleOverlayVisible()) { + return false; + } + + const windowTracker = input.appState.windowTracker; + if (!windowTracker || !windowTracker.isTracking()) { + return false; + } + + return windowTracker.isTargetWindowFocused(); + }, + showMpvOsd: (text: string) => input.actions.showMpvOsd(text), + openRuntimeOptionsPalette: () => { + input.overlay.getOverlayUi()?.openRuntimeOptionsPalette(); + }, + openJimaku: () => { + input.overlay.overlayModalRuntime.sendToActiveOverlayWindow('jimaku:open', undefined, { + restoreOnModalClose: 'jimaku' as OverlayHostedModal, + }); + }, + markAudioCard: () => input.mining.markLastCardAsAudioCard(), + copySubtitle: () => input.mining.copyCurrentSubtitle(), + toggleSecondarySubMode: () => input.actions.handleCycleSecondarySubMode(), + updateLastCardFromClipboard: () => input.mining.updateLastCardFromClipboard(), + triggerFieldGrouping: () => input.mining.triggerFieldGrouping(), + triggerSubsyncFromConfig: () => input.actions.triggerSubsyncFromConfig(), + mineSentenceCard: () => input.mining.mineSentenceCard(), + }, + }); + + shortcuts = bootstrap.shortcuts; + return bootstrap; +} diff --git a/src/main/startup-flags.ts b/src/main/startup-flags.ts new file mode 100644 index 00000000..68ad01b6 --- /dev/null +++ b/src/main/startup-flags.ts @@ -0,0 +1,57 @@ +import type { CliArgs } from '../cli/args'; +import { isStandaloneTexthookerCommand, shouldRunSettingsOnlyStartup } from '../cli/args'; + +export function getPasswordStoreArg(argv: string[]): string | null { + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg?.startsWith('--password-store')) { + continue; + } + + if (arg === '--password-store') { + const value = argv[i + 1]; + if (value && !value.startsWith('--')) { + return value; + } + return null; + } + + const [prefix, value] = arg.split('=', 2); + if (prefix === '--password-store' && value && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} + +export function normalizePasswordStoreArg(value: string): string { + const normalized = value.trim(); + if (normalized.toLowerCase() === 'gnome') { + return 'gnome-libsecret'; + } + return normalized; +} + +export function getDefaultPasswordStore(): string { + return 'gnome-libsecret'; +} + +export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): { + shouldUseMinimalStartup: boolean; + shouldSkipHeavyStartup: boolean; +} { + return { + shouldUseMinimalStartup: Boolean( + (initialArgs && isStandaloneTexthookerCommand(initialArgs)) || + (initialArgs?.stats && + (initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)), + ), + shouldSkipHeavyStartup: Boolean( + initialArgs && + (shouldRunSettingsOnlyStartup(initialArgs) || + initialArgs.stats || + initialArgs.dictionary || + initialArgs.setup), + ), + }; +} diff --git a/src/main/startup-lifecycle-runtime.ts b/src/main/startup-lifecycle-runtime.ts new file mode 100644 index 00000000..bf716517 --- /dev/null +++ b/src/main/startup-lifecycle-runtime.ts @@ -0,0 +1,41 @@ +import { + composeStartupLifecycleHandlers, + type StartupLifecycleComposerOptions, +} from './runtime/composers'; + +export interface StartupLifecycleRuntimeInput { + protocolUrl: StartupLifecycleComposerOptions['registerProtocolUrlHandlersMainDeps']; + cleanup: StartupLifecycleComposerOptions['onWillQuitCleanupMainDeps']; + shouldRestoreWindowsOnActivate: StartupLifecycleComposerOptions['shouldRestoreWindowsOnActivateMainDeps']; + restoreWindowsOnActivate: StartupLifecycleComposerOptions['restoreWindowsOnActivateMainDeps']; +} + +export interface StartupLifecycleRuntime { + registerProtocolUrlHandlers: () => void; + onWillQuitCleanup: () => void; + shouldRestoreWindowsOnActivate: () => boolean; + restoreWindowsOnActivate: () => void; +} + +export function createStartupLifecycleRuntime( + input: StartupLifecycleRuntimeInput, +): StartupLifecycleRuntime { + const { + registerProtocolUrlHandlers, + onWillQuitCleanup, + shouldRestoreWindowsOnActivate, + restoreWindowsOnActivate, + } = composeStartupLifecycleHandlers({ + registerProtocolUrlHandlersMainDeps: input.protocolUrl, + onWillQuitCleanupMainDeps: input.cleanup, + shouldRestoreWindowsOnActivateMainDeps: input.shouldRestoreWindowsOnActivate, + restoreWindowsOnActivateMainDeps: input.restoreWindowsOnActivate, + }); + + return { + registerProtocolUrlHandlers, + onWillQuitCleanup, + shouldRestoreWindowsOnActivate, + restoreWindowsOnActivate, + }; +} diff --git a/src/main/startup-sequence-runtime.test.ts b/src/main/startup-sequence-runtime.test.ts new file mode 100644 index 00000000..62550b95 --- /dev/null +++ b/src/main/startup-sequence-runtime.test.ts @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createStartupSequenceRuntime } from './startup-sequence-runtime'; + +test('startup sequence delegates non-refresh headless command to initial args handler', async () => { + const calls: string[] = []; + + const runtime = createStartupSequenceRuntime({ + appState: { + initialArgs: { refreshKnownWords: false } as never, + runtimeOptionsManager: null, + }, + userDataPath: '/tmp/subminer', + getResolvedConfig: () => ({ ankiConnect: { enabled: true } }) as never, + anilist: { + refreshAnilistClientSecretStateIfEnabled: async () => undefined, + refreshRetryQueueState: () => {}, + }, + actions: { + initializeDiscordPresenceService: async () => {}, + requestAppQuit: () => {}, + }, + logger: { + error: () => {}, + }, + runHeadlessKnownWordRefresh: async () => { + calls.push('refreshKnownWords'); + }, + }); + + await runtime.runHeadlessInitialCommand({ + handleInitialArgs: () => { + calls.push('handleInitialArgs'); + }, + }); + + assert.deepEqual(calls, ['handleInitialArgs']); +}); + +test('startup sequence runs headless known-word refresh when requested', async () => { + const calls: string[] = []; + const runtimeOptionsManager = { + getEffectiveAnkiConnectConfig: (config: never) => config, + } as never; + + const runtime = createStartupSequenceRuntime({ + appState: { + initialArgs: { refreshKnownWords: true } as never, + runtimeOptionsManager, + }, + userDataPath: '/tmp/subminer', + getResolvedConfig: () => ({ ankiConnect: { enabled: true } }) as never, + anilist: { + refreshAnilistClientSecretStateIfEnabled: async () => undefined, + refreshRetryQueueState: () => {}, + }, + actions: { + initializeDiscordPresenceService: async () => {}, + requestAppQuit: () => { + calls.push('requestAppQuit'); + }, + }, + logger: { + error: () => {}, + }, + runHeadlessKnownWordRefresh: async (input) => { + calls.push(`refresh:${input.userDataPath}`); + assert.equal(input.runtimeOptionsManager, runtimeOptionsManager); + }, + }); + + await runtime.runHeadlessInitialCommand({ + handleInitialArgs: () => { + calls.push('handleInitialArgs'); + }, + }); + + assert.deepEqual(calls, ['refresh:/tmp/subminer']); +}); + +test('startup sequence runs deferred AniList and Discord init only for full startup', async () => { + const calls: string[] = []; + + const runtime = createStartupSequenceRuntime({ + appState: { + initialArgs: null, + runtimeOptionsManager: null, + }, + userDataPath: '/tmp/subminer', + getResolvedConfig: () => ({ anilist: { enabled: true } }) as never, + anilist: { + refreshAnilistClientSecretStateIfEnabled: async (options) => { + calls.push(`anilist:${options.force}:${options.allowSetupPrompt}`); + }, + refreshRetryQueueState: () => { + calls.push('retryQueue'); + }, + }, + actions: { + initializeDiscordPresenceService: async () => { + calls.push('discord'); + }, + requestAppQuit: () => {}, + }, + logger: { + error: () => {}, + }, + }); + + runtime.runPostStartupInitialization(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(calls, ['anilist:true:false', 'retryQueue', 'discord']); +}); + +test('startup sequence skips deferred startup side effects in minimal mode', async () => { + const calls: string[] = []; + + const runtime = createStartupSequenceRuntime({ + appState: { + initialArgs: { background: true } as never, + runtimeOptionsManager: null, + }, + userDataPath: '/tmp/subminer', + getResolvedConfig: () => ({ anilist: { enabled: true } }) as never, + anilist: { + refreshAnilistClientSecretStateIfEnabled: async () => { + calls.push('anilist'); + }, + refreshRetryQueueState: () => { + calls.push('retryQueue'); + }, + }, + actions: { + initializeDiscordPresenceService: async () => { + calls.push('discord'); + }, + requestAppQuit: () => {}, + }, + logger: { + error: () => {}, + }, + getStartupModeFlags: () => ({ + shouldUseMinimalStartup: true, + shouldSkipHeavyStartup: false, + }), + isAnilistTrackingEnabled: () => true, + }); + + runtime.runPostStartupInitialization(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(calls, []); +}); diff --git a/src/main/startup-sequence-runtime.ts b/src/main/startup-sequence-runtime.ts new file mode 100644 index 00000000..ca32a5ff --- /dev/null +++ b/src/main/startup-sequence-runtime.ts @@ -0,0 +1,96 @@ +import type { CliArgs } from '../cli/args'; +import type { ResolvedConfig } from '../types'; +import { isAnilistTrackingEnabled } from './runtime/domains/anilist'; +import { getStartupModeFlags } from './startup-flags'; +import { runHeadlessKnownWordRefresh } from './headless-known-word-refresh'; + +export interface StartupSequenceRuntimeInput { + appState: { + initialArgs: CliArgs | null | undefined; + runtimeOptionsManager: Parameters< + typeof runHeadlessKnownWordRefresh + >[0]['runtimeOptionsManager']; + }; + userDataPath: string; + getResolvedConfig: () => ResolvedConfig; + anilist: { + refreshAnilistClientSecretStateIfEnabled: (options: { + force: boolean; + allowSetupPrompt?: boolean; + }) => Promise; + refreshRetryQueueState: () => void; + }; + actions: { + initializeDiscordPresenceService: () => Promise; + requestAppQuit: () => void; + }; + logger: { + error: (message: string, error?: unknown) => void; + }; + runHeadlessKnownWordRefresh?: typeof runHeadlessKnownWordRefresh; + getStartupModeFlags?: typeof getStartupModeFlags; + isAnilistTrackingEnabled?: typeof isAnilistTrackingEnabled; +} + +export interface StartupSequenceRuntime { + runHeadlessInitialCommand: (input: { handleInitialArgs: () => void }) => Promise; + runPostStartupInitialization: () => void; +} + +export function createStartupSequenceRuntime( + input: StartupSequenceRuntimeInput, +): StartupSequenceRuntime { + const runKnownWordRefresh = input.runHeadlessKnownWordRefresh ?? runHeadlessKnownWordRefresh; + const resolveStartupModeFlags = input.getStartupModeFlags ?? getStartupModeFlags; + const isTrackingEnabled = input.isAnilistTrackingEnabled ?? isAnilistTrackingEnabled; + + const shouldSkipDeferredStartup = (): boolean => { + if (!input.appState.initialArgs) { + return false; + } + + const startupModeFlags = resolveStartupModeFlags(input.appState.initialArgs); + return startupModeFlags.shouldUseMinimalStartup || startupModeFlags.shouldSkipHeavyStartup; + }; + + return { + runHeadlessInitialCommand: async ({ handleInitialArgs }): Promise => { + if (!input.appState.initialArgs?.refreshKnownWords) { + handleInitialArgs(); + return; + } + + await runKnownWordRefresh({ + resolvedConfig: input.getResolvedConfig(), + runtimeOptionsManager: input.appState.runtimeOptionsManager, + userDataPath: input.userDataPath, + logger: input.logger, + requestAppQuit: input.actions.requestAppQuit, + }); + }, + runPostStartupInitialization: (): void => { + if (shouldSkipDeferredStartup()) { + return; + } + + if (isTrackingEnabled(input.getResolvedConfig())) { + void input.anilist + .refreshAnilistClientSecretStateIfEnabled({ + force: true, + allowSetupPrompt: false, + }) + .catch((error) => { + input.logger.error( + 'Failed to refresh AniList client secret state during startup', + error, + ); + }); + input.anilist.refreshRetryQueueState(); + } + + void input.actions.initializeDiscordPresenceService().catch((error) => { + input.logger.error('Failed to initialize Discord presence service during startup', error); + }); + }, + }; +} diff --git a/src/main/startup-support-coordinator.ts b/src/main/startup-support-coordinator.ts new file mode 100644 index 00000000..56df7596 --- /dev/null +++ b/src/main/startup-support-coordinator.ts @@ -0,0 +1,171 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { MpvIpcClient } from '../core/services/mpv'; +import type { + JimakuLanguagePreference, + ResolvedConfig, + SecondarySubMode, + SubsyncManualPayload, +} from '../types'; +import type { ConfigService } from '../config'; +import type { RuntimeOptionsManager } from '../runtime-options'; +import type { AppState } from './state'; +import type { OverlayHostedModal } from '../shared/ipc/contracts'; +import { createStartupSupportRuntime, type StartupSupportRuntime } from './startup-support-runtime'; + +export interface StartupSupportCoordinatorInput { + platform: NodeJS.Platform; + defaultImmersionDbPath: string; + defaultJimakuLanguagePreference: JimakuLanguagePreference; + defaultJimakuMaxEntryResults: number; + defaultJimakuApiBaseUrl: string; + jellyfinLangPref: string; + getResolvedConfig: () => ResolvedConfig; + appState: AppState; + configService: Pick; + actions: { + sendMpvCommandRuntime: (client: MpvIpcClient, command: (string | number)[]) => void; + showMpvOsd: (text: string) => void; + openSubsyncManualPicker: (payload: SubsyncManualPayload) => void; + refreshGlobalAndOverlayShortcuts: () => void; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + showDesktopNotification: (title: string, options: { body?: string }) => void; + showErrorBox: (title: string, details: string) => void; + }; + logger: StartupSupportRuntime['configHotReloadRuntime'] extends never + ? never + : Parameters[0]['logger']; + watch: Parameters[0]['watch']; + timers: Parameters[0]['timers']; +} + +export interface StartupSupportFromMainStateInput { + platform: NodeJS.Platform; + defaultImmersionDbPath: string; + defaultJimakuLanguagePreference: JimakuLanguagePreference; + defaultJimakuMaxEntryResults: number; + defaultJimakuApiBaseUrl: string; + jellyfinLangPref: string; + getResolvedConfig: () => ResolvedConfig; + appState: AppState; + configService: Pick; + overlay: { + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => void; + }; + shortcuts: { + refreshGlobalAndOverlayShortcuts: () => void; + }; + notifications: { + showDesktopNotification: (title: string, options: { body?: string }) => void; + showErrorBox: (title: string, details: string) => void; + }; + logger: Parameters[0]['logger']; + mpv: { + sendMpvCommandRuntime: (client: MpvIpcClient, command: (string | number)[]) => void; + showMpvOsd: (text: string) => void; + }; +} + +export function createStartupSupportCoordinator( + input: StartupSupportCoordinatorInput, +): StartupSupportRuntime { + return createStartupSupportRuntime({ + platform: input.platform, + defaultImmersionDbPath: input.defaultImmersionDbPath, + defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference, + defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults, + defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl, + jellyfinLangPref: input.jellyfinLangPref, + getResolvedConfig: () => input.getResolvedConfig(), + appState: { + immersionTracker: input.appState.immersionTracker, + mpvClient: input.appState.mpvClient, + currentMediaPath: input.appState.currentMediaPath, + currentMediaTitle: input.appState.currentMediaTitle, + runtimeOptionsManager: input.appState.runtimeOptionsManager as RuntimeOptionsManager | null, + subsyncInProgress: input.appState.subsyncInProgress, + keybindings: input.appState.keybindings, + ankiIntegration: input.appState.ankiIntegration, + }, + mpv: { + sendMpvCommandRuntime: (client, command) => + input.actions.sendMpvCommandRuntime(client as MpvIpcClient, command), + showMpvOsd: (text) => input.actions.showMpvOsd(text), + }, + config: { + reloadConfigStrict: () => input.configService.reloadConfigStrict(), + }, + subsync: { + openManualPicker: (payload) => input.actions.openSubsyncManualPicker(payload), + }, + hotReload: { + setSecondarySubMode: (mode: SecondarySubMode) => { + input.appState.secondarySubMode = mode; + }, + refreshGlobalAndOverlayShortcuts: () => input.actions.refreshGlobalAndOverlayShortcuts(), + broadcastToOverlayWindows: (channel, payload) => + input.actions.broadcastToOverlayWindows(channel, payload), + }, + notifications: { + showDesktopNotification: (title, options) => + input.actions.showDesktopNotification(title, options), + showErrorBox: (title, details) => input.actions.showErrorBox(title, details), + }, + logger: input.logger, + watch: input.watch, + timers: input.timers, + }); +} + +export function createStartupSupportFromMainState( + input: StartupSupportFromMainStateInput, +): StartupSupportRuntime { + return createStartupSupportCoordinator({ + platform: input.platform, + defaultImmersionDbPath: input.defaultImmersionDbPath, + defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference, + defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults, + defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl, + jellyfinLangPref: input.jellyfinLangPref, + getResolvedConfig: () => input.getResolvedConfig(), + appState: input.appState, + configService: input.configService, + actions: { + sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command), + showMpvOsd: (text) => input.mpv.showMpvOsd(text), + openSubsyncManualPicker: (payload) => { + input.overlay.sendToActiveOverlayWindow('subsync:open-manual', payload, { + restoreOnModalClose: 'subsync', + }); + }, + refreshGlobalAndOverlayShortcuts: () => { + input.shortcuts.refreshGlobalAndOverlayShortcuts(); + }, + broadcastToOverlayWindows: (channel, payload) => { + input.overlay.broadcastToOverlayWindows(channel, payload); + }, + showDesktopNotification: (title, options) => + input.notifications.showDesktopNotification(title, options), + showErrorBox: (title, details) => input.notifications.showErrorBox(title, details), + }, + logger: input.logger, + watch: { + fileExists: (targetPath) => fs.existsSync(targetPath), + dirname: (targetPath) => path.dirname(targetPath), + watchPath: (targetPath, listener) => fs.watch(targetPath, listener), + }, + timers: { + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (timeout) => clearTimeout(timeout), + }, + }); +} diff --git a/src/main/startup-support-runtime.ts b/src/main/startup-support-runtime.ts new file mode 100644 index 00000000..d9e98b28 --- /dev/null +++ b/src/main/startup-support-runtime.ts @@ -0,0 +1,235 @@ +import { createConfigHotReloadRuntime, type MpvIpcClient } from '../core/services'; +import type { MpvRuntimeClientLike } from '../core/services/mpv'; +import type { + ConfigHotReloadPayload, + ConfigValidationWarning, + JimakuLanguagePreference, + ResolvedConfig, + SecondarySubMode, + SubsyncManualPayload, +} from '../types'; +import type { ReloadConfigStrictResult } from '../config'; +import { RuntimeOptionsManager } from '../runtime-options'; +import { + createApplyJellyfinMpvDefaultsHandler, + createBuildApplyJellyfinMpvDefaultsMainDepsHandler, + createBuildGetDefaultSocketPathMainDepsHandler, + createGetDefaultSocketPathHandler, +} from './runtime/domains/jellyfin'; +import { + createBuildConfigHotReloadAppliedMainDepsHandler, + createBuildConfigHotReloadMessageMainDepsHandler, + createBuildConfigHotReloadRuntimeMainDepsHandler, + createBuildWatchConfigPathMainDepsHandler, + createConfigHotReloadAppliedHandler, + createConfigHotReloadMessageHandler, + createWatchConfigPathHandler, + buildRestartRequiredConfigMessage, +} from './runtime/domains/overlay'; +import { + createBuildConfigDerivedRuntimeMainDepsHandler, + createBuildImmersionMediaRuntimeMainDepsHandler, + createBuildMainSubsyncRuntimeMainDepsHandler, + createConfigDerivedRuntime, + createImmersionMediaRuntime, + createMainSubsyncRuntime, +} from './runtime/domains/startup'; +import { + buildConfigWarningDialogDetails, + buildConfigWarningNotificationBody, +} from './config-validation'; + +type ImmersionTrackerLike = { + handleMediaChange: (path: string, title: string | null) => void; +}; + +type MpvClientLike = MpvIpcClient | null; +type JellyfinMpvClientLike = MpvRuntimeClientLike; + +export interface StartupSupportRuntimeInput { + platform: NodeJS.Platform; + defaultImmersionDbPath: string; + defaultJimakuLanguagePreference: JimakuLanguagePreference; + defaultJimakuMaxEntryResults: number; + defaultJimakuApiBaseUrl: string; + jellyfinLangPref: string; + getResolvedConfig: () => ResolvedConfig; + appState: { + immersionTracker: ImmersionTrackerLike | null; + mpvClient: MpvClientLike; + currentMediaPath: string | null; + currentMediaTitle: string | null; + runtimeOptionsManager: RuntimeOptionsManager | null; + subsyncInProgress: boolean; + keybindings: ConfigHotReloadPayload['keybindings']; + ankiIntegration: { + applyRuntimeConfigPatch: (patch: { + ai: ResolvedConfig['ankiConnect']['ai']['enabled']; + }) => void; + } | null; + }; + mpv: { + sendMpvCommandRuntime: (client: JellyfinMpvClientLike, command: (string | number)[]) => void; + showMpvOsd: (text: string) => void; + }; + config: { + reloadConfigStrict: () => ReloadConfigStrictResult; + }; + subsync: { + openManualPicker: (payload: SubsyncManualPayload) => void; + }; + hotReload: { + setSecondarySubMode: (mode: SecondarySubMode) => void; + refreshGlobalAndOverlayShortcuts: () => void; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + }; + notifications: { + showDesktopNotification: (title: string, options: { body?: string }) => void; + showErrorBox: (title: string, details: string) => void; + }; + logger: { + debug: (message: string) => void; + info: (message: string) => void; + warn: (message: string, error?: unknown) => void; + }; + watch: { + fileExists: (targetPath: string) => boolean; + dirname: (targetPath: string) => string; + watchPath: ( + targetPath: string, + listener: (eventType: string, filename: string | null) => void, + ) => { close: () => void }; + }; + timers: { + setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout; + clearTimeout: (timeout: NodeJS.Timeout) => void; + }; +} + +export interface StartupSupportRuntime { + applyJellyfinMpvDefaults: (client: JellyfinMpvClientLike) => void; + getDefaultSocketPath: () => string; + immersionMediaRuntime: ReturnType; + configDerivedRuntime: ReturnType; + subsyncRuntime: ReturnType; + configHotReloadRuntime: ReturnType; +} + +export function createStartupSupportRuntime( + input: StartupSupportRuntimeInput, +): StartupSupportRuntime { + const applyJellyfinMpvDefaultsHandler = createApplyJellyfinMpvDefaultsHandler( + createBuildApplyJellyfinMpvDefaultsMainDepsHandler({ + sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command), + jellyfinLangPref: input.jellyfinLangPref, + })(), + ); + + const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler( + createBuildGetDefaultSocketPathMainDepsHandler({ + platform: input.platform, + })(), + ); + + const immersionMediaRuntime = createImmersionMediaRuntime( + createBuildImmersionMediaRuntimeMainDepsHandler({ + getResolvedConfig: () => input.getResolvedConfig(), + defaultImmersionDbPath: input.defaultImmersionDbPath, + getTracker: () => input.appState.immersionTracker, + getMpvClient: () => input.appState.mpvClient, + getCurrentMediaPath: () => input.appState.currentMediaPath, + getCurrentMediaTitle: () => input.appState.currentMediaTitle, + logDebug: (message) => input.logger.debug(message), + logInfo: (message) => input.logger.info(message), + })(), + ); + + const configDerivedRuntime = createConfigDerivedRuntime( + createBuildConfigDerivedRuntimeMainDepsHandler({ + getResolvedConfig: () => input.getResolvedConfig(), + getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager, + defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference, + defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults, + defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl, + })(), + ); + + const subsyncRuntime = createMainSubsyncRuntime( + createBuildMainSubsyncRuntimeMainDepsHandler({ + getMpvClient: () => input.appState.mpvClient, + getResolvedConfig: () => input.getResolvedConfig(), + getSubsyncInProgress: () => input.appState.subsyncInProgress, + setSubsyncInProgress: (inProgress) => { + input.appState.subsyncInProgress = inProgress; + }, + showMpvOsd: (text) => input.mpv.showMpvOsd(text), + openManualPicker: (payload) => input.subsync.openManualPicker(payload), + })(), + ); + + const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler( + createBuildConfigHotReloadMessageMainDepsHandler({ + showMpvOsd: (message) => input.mpv.showMpvOsd(message), + showDesktopNotification: (title, options) => + input.notifications.showDesktopNotification(title, options), + })(), + ); + + const watchConfigPathHandler = createWatchConfigPathHandler( + createBuildWatchConfigPathMainDepsHandler({ + fileExists: (targetPath) => input.watch.fileExists(targetPath), + dirname: (targetPath) => input.watch.dirname(targetPath), + watchPath: (targetPath, listener) => input.watch.watchPath(targetPath, listener), + })(), + ); + + const configHotReloadRuntime = createConfigHotReloadRuntime( + createBuildConfigHotReloadRuntimeMainDepsHandler({ + getCurrentConfig: () => input.getResolvedConfig(), + reloadConfigStrict: () => input.config.reloadConfigStrict(), + watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange), + setTimeout: (callback, delayMs) => input.timers.setTimeout(callback, delayMs), + clearTimeout: (timeout) => input.timers.clearTimeout(timeout), + debounceMs: 250, + onHotReloadApplied: createConfigHotReloadAppliedHandler( + createBuildConfigHotReloadAppliedMainDepsHandler({ + setKeybindings: (keybindings) => { + input.appState.keybindings = keybindings; + }, + refreshGlobalAndOverlayShortcuts: () => { + input.hotReload.refreshGlobalAndOverlayShortcuts(); + }, + setSecondarySubMode: (mode) => input.hotReload.setSecondarySubMode(mode), + broadcastToOverlayWindows: (channel, payload) => + input.hotReload.broadcastToOverlayWindows(channel, payload), + applyAnkiRuntimeConfigPatch: (patch) => { + input.appState.ankiIntegration?.applyRuntimeConfigPatch(patch); + }, + })(), + ), + onRestartRequired: (fields) => + notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)), + onInvalidConfig: notifyConfigHotReloadMessage, + onValidationWarnings: (configPath, warnings: ConfigValidationWarning[]) => { + input.notifications.showDesktopNotification('SubMiner', { + body: buildConfigWarningNotificationBody(configPath, warnings), + }); + if (input.platform === 'darwin') { + input.notifications.showErrorBox( + 'SubMiner config validation warning', + buildConfigWarningDialogDetails(configPath, warnings), + ); + } + }, + })(), + ); + + return { + applyJellyfinMpvDefaults: (client) => applyJellyfinMpvDefaultsHandler(client), + getDefaultSocketPath: () => getDefaultSocketPathHandler(), + immersionMediaRuntime, + configDerivedRuntime, + subsyncRuntime, + configHotReloadRuntime, + }; +} diff --git a/src/main/stats-runtime-coordinator.ts b/src/main/stats-runtime-coordinator.ts new file mode 100644 index 00000000..50a64adc --- /dev/null +++ b/src/main/stats-runtime-coordinator.ts @@ -0,0 +1,185 @@ +import type { BrowserWindow } from 'electron'; +import { shell } from 'electron'; +import path from 'node:path'; + +import type { CliArgs, CliCommandSource } from '../cli/args'; +import type { ResolvedConfig } from '../types'; +import { + addYomitanNoteViaSearch, + syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore, +} from '../core/services'; +import { createLogger } from '../logger'; +import type { AppState } from './state'; +import { + createStatsRuntimeBootstrap, + type StatsRuntime, + type StatsRuntimeBootstrap, +} from './stats-runtime'; +import { registerStatsOverlayToggle, destroyStatsWindow } from '../core/services/stats-window.js'; + +export interface StatsRuntimeCoordinatorInput { + statsDaemonStatePath: string; + statsDistPath: string; + statsPreloadPath: string; + userDataPath: string; + appState: AppState; + getResolvedConfig: () => ResolvedConfig; + dictionarySupport: { + getConfiguredDbPath: () => string; + seedImmersionMediaFromCurrentMedia: () => Promise | void; + }; + overlay: { + getOverlayGeometry: () => { getCurrentOverlayGeometry: () => Electron.Rectangle }; + updateVisibleOverlayVisibility: () => void; + registerStatsOverlayToggle: StatsRuntimeBootstrap['stats'] extends never + ? never + : Parameters[0]['overlay']['registerStatsOverlayToggle']; + }; + mpvRuntime: { + createMecabTokenizerAndCheck: () => Promise; + }; + actions: { + openExternal: (url: string) => Promise; + requestAppQuit: () => void; + destroyStatsWindow: () => void; + }; + logger: { + info: (message: string) => void; + warn: (message: string, error?: unknown) => void; + error: (message: string, error?: unknown) => void; + debug: (message: string, details?: unknown) => void; + }; +} + +export interface StatsRuntimeCoordinator { + statsBootstrap: StatsRuntimeBootstrap; + stats: StatsRuntime; + ensureStatsServerStarted: () => string; + ensureBackgroundStatsServerStarted: () => { + url: string; + runningInCurrentProcess: boolean; + }; + stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>; + ensureImmersionTrackerStarted: () => void; + runStatsCliCommand: ( + args: Pick< + CliArgs, + | 'statsResponsePath' + | 'statsBackground' + | 'statsStop' + | 'statsCleanup' + | 'statsCleanupVocab' + | 'statsCleanupLifetime' + >, + source: CliCommandSource, + ) => Promise; +} + +export function createStatsRuntimeCoordinator( + input: StatsRuntimeCoordinatorInput, +): StatsRuntimeCoordinator { + const statsBootstrap = createStatsRuntimeBootstrap({ + statsDaemonStatePath: input.statsDaemonStatePath, + statsDistPath: input.statsDistPath, + statsPreloadPath: input.statsPreloadPath, + userDataPath: input.userDataPath, + appState: input.appState, + getResolvedConfig: () => input.getResolvedConfig(), + dictionarySupport: input.dictionarySupport, + overlay: input.overlay, + createMecabTokenizerAndCheck: async () => { + await input.mpvRuntime.createMecabTokenizerAndCheck(); + }, + addYomitanNote: async (word: string) => { + const yomitanDeps = { + getYomitanExt: () => input.appState.yomitanExt, + getYomitanSession: () => input.appState.yomitanSession, + getYomitanParserWindow: () => input.appState.yomitanParserWindow, + setYomitanParserWindow: (window: BrowserWindow | null) => { + input.appState.yomitanParserWindow = window; + }, + getYomitanParserReadyPromise: () => input.appState.yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise: Promise | null) => { + input.appState.yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => input.appState.yomitanParserInitPromise, + setYomitanParserInitPromise: (promise: Promise | null) => { + input.appState.yomitanParserInitPromise = promise; + }, + }; + const yomitanLogger = createLogger('main:yomitan-stats'); + const ankiUrl = input.getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765'; + await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { + forceOverride: true, + }); + return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); + }, + openExternal: input.actions.openExternal, + requestAppQuit: input.actions.requestAppQuit, + destroyStatsWindow: input.actions.destroyStatsWindow, + logger: input.logger, + }); + + const stats = statsBootstrap.stats; + + return { + statsBootstrap, + stats, + ensureStatsServerStarted: () => stats.ensureStatsServerStarted(), + ensureBackgroundStatsServerStarted: () => stats.ensureBackgroundStatsServerStarted(), + stopBackgroundStatsServer: async () => await stats.stopBackgroundStatsServer(), + ensureImmersionTrackerStarted: () => { + stats.ensureImmersionTrackerStarted(); + }, + runStatsCliCommand: async (args, source) => { + await stats.runStatsCliCommand(args, source); + }, + }; +} + +export interface StatsRuntimeFromMainStateInput { + dirname: string; + userDataPath: string; + appState: AppState; + getResolvedConfig: () => ResolvedConfig; + dictionarySupport: StatsRuntimeCoordinatorInput['dictionarySupport']; + overlay: { + getOverlayGeometry: () => { getCurrentOverlayGeometry: () => Electron.Rectangle }; + updateVisibleOverlayVisibility: () => void; + }; + mpvRuntime: { + createMecabTokenizerAndCheck: () => Promise; + }; + actions: { + requestAppQuit: () => void; + }; + logger: StatsRuntimeCoordinatorInput['logger']; +} + +export function createStatsRuntimeFromMainState( + input: StatsRuntimeFromMainStateInput, +): StatsRuntimeCoordinator { + return createStatsRuntimeCoordinator({ + statsDaemonStatePath: path.join(input.userDataPath, 'stats-daemon.json'), + statsDistPath: path.join(input.dirname, '..', 'stats', 'dist'), + statsPreloadPath: path.join(input.dirname, 'preload-stats.js'), + userDataPath: input.userDataPath, + appState: input.appState, + getResolvedConfig: () => input.getResolvedConfig(), + dictionarySupport: input.dictionarySupport, + overlay: { + getOverlayGeometry: () => input.overlay.getOverlayGeometry(), + updateVisibleOverlayVisibility: () => input.overlay.updateVisibleOverlayVisibility(), + registerStatsOverlayToggle, + }, + mpvRuntime: { + createMecabTokenizerAndCheck: () => input.mpvRuntime.createMecabTokenizerAndCheck(), + }, + actions: { + openExternal: (url) => shell.openExternal(url), + requestAppQuit: () => input.actions.requestAppQuit(), + destroyStatsWindow, + }, + logger: input.logger, + }); +} diff --git a/src/main/stats-runtime.test.ts b/src/main/stats-runtime.test.ts new file mode 100644 index 00000000..e3fca227 --- /dev/null +++ b/src/main/stats-runtime.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createStatsRuntime } from './stats-runtime'; + +function withTempDir(fn: (dir: string) => Promise | void): Promise | void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-runtime-test-')); + const result = fn(dir); + if (result instanceof Promise) { + return result.finally(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + } + fs.rmSync(dir, { recursive: true, force: true }); +} + +test('stats runtime removes stale daemon state', async () => { + await withTempDir(async (dir) => { + const statePath = path.join(dir, 'stats-daemon.json'); + fs.writeFileSync( + statePath, + JSON.stringify({ pid: 99999, port: 6969, startedAtMs: 1_234 }, null, 2), + ); + + const runtime = createStatsRuntime({ + statsDaemonStatePath: statePath, + getResolvedConfig: () => ({ + immersionTracking: { enabled: true }, + stats: { serverPort: 6969 }, + }), + getImmersionTracker: () => ({}) as never, + ensureImmersionTrackerStartedCore: () => {}, + startStatsServer: () => ({ close: () => {} }), + openExternal: async () => {}, + exitAppWithCode: () => {}, + logInfo: () => {}, + logWarn: () => {}, + logError: () => {}, + getCurrentPid: () => 123, + isProcessAlive: () => false, + }); + + assert.equal(runtime.readLiveBackgroundStatsDaemonState(), null); + assert.equal(fs.existsSync(statePath), false); + }); +}); + +test('stats runtime starts background server and writes owned daemon state', async () => { + await withTempDir(async (dir) => { + const statePath = path.join(dir, 'stats-daemon.json'); + let startedPort: number | null = null; + + const runtime = createStatsRuntime({ + statsDaemonStatePath: statePath, + getResolvedConfig: () => ({ + immersionTracking: { enabled: true }, + stats: { serverPort: 6970 }, + }), + getImmersionTracker: () => ({}) as never, + ensureImmersionTrackerStartedCore: () => {}, + startStatsServer: (port) => { + startedPort = port; + return { close: () => {} }; + }, + openExternal: async () => {}, + exitAppWithCode: () => {}, + logInfo: () => {}, + logWarn: () => {}, + logError: () => {}, + getCurrentPid: () => 456, + isProcessAlive: () => false, + now: () => 999, + }); + + const result = runtime.ensureBackgroundStatsServerStarted(); + + assert.deepEqual(result, { + url: 'http://127.0.0.1:6970', + runningInCurrentProcess: true, + }); + assert.equal(startedPort, 6970); + assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), { + pid: 456, + port: 6970, + startedAtMs: 999, + }); + }); +}); + +test('stats runtime stops owned server and clears daemon state during quit cleanup', async () => { + await withTempDir(async (dir) => { + const statePath = path.join(dir, 'stats-daemon.json'); + const calls: string[] = []; + + const runtime = createStatsRuntime({ + statsDaemonStatePath: statePath, + getResolvedConfig: () => ({ + immersionTracking: { enabled: true }, + stats: { serverPort: 6971 }, + }), + getImmersionTracker: () => ({}) as never, + ensureImmersionTrackerStartedCore: () => {}, + startStatsServer: () => ({ + close: () => { + calls.push('close'); + }, + }), + openExternal: async () => {}, + exitAppWithCode: () => {}, + destroyStatsWindow: () => { + calls.push('destroy-window'); + }, + logInfo: () => {}, + logWarn: () => {}, + logError: () => {}, + getCurrentPid: () => 789, + isProcessAlive: () => true, + now: () => 500, + }); + + runtime.ensureBackgroundStatsServerStarted(); + runtime.cleanupBeforeQuit(); + + assert.deepEqual(calls, ['destroy-window', 'close']); + assert.equal(fs.existsSync(statePath), false); + assert.equal(runtime.getStatsServer(), null); + }); +}); diff --git a/src/main/stats-runtime.ts b/src/main/stats-runtime.ts new file mode 100644 index 00000000..5150bd6c --- /dev/null +++ b/src/main/stats-runtime.ts @@ -0,0 +1,469 @@ +import path from 'node:path'; +import { + isBackgroundStatsServerProcessAlive, + readBackgroundStatsServerState, + removeBackgroundStatsServerState, + resolveBackgroundStatsServerUrl, + writeBackgroundStatsServerState, + type BackgroundStatsServerState, +} from './runtime/stats-daemon'; +import { + createRunStatsCliCommandHandler, + writeStatsCliCommandResponse, +} from './runtime/stats-cli-command'; +import type { CliArgs, CliCommandSource } from '../cli/args'; +import { ImmersionTrackerService } from '../core/services/immersion-tracker-service'; +import { startStatsServer as startStatsServerCore } from '../core/services/stats-server'; +import { createLogger } from '../logger'; +import { createCoverArtFetcher } from '../core/services/anilist/cover-art-fetcher'; +import { createAnilistRateLimiter } from '../core/services/anilist/rate-limiter'; +import { resolveLegacyVocabularyPosFromTokens } from '../core/services/immersion-tracker/legacy-vocabulary-pos'; +import type { + LifetimeRebuildSummary, + VocabularyCleanupSummary, +} from '../core/services/immersion-tracker/types'; +import type { ResolvedConfig } from '../types'; +import type { AppReadyImmersionInput } from './app-ready-runtime'; +import { createImmersionTrackerStartupHandler } from './runtime/immersion-startup'; + +type StatsConfigLike = { + immersionTracking?: { + enabled?: boolean; + }; + stats: { + serverPort: number; + autoOpenBrowser?: boolean; + }; +}; + +type StatsServerLike = { + close: () => void; +}; + +type StatsTrackerLike = { + cleanupVocabularyStats?: () => Promise; + rebuildLifetimeSummaries?: () => Promise; + recordCardsMined?: (count: number, noteIds?: number[]) => void; +}; + +type StatsBootstrapAppState = { + mecabTokenizer: { + tokenize: (text: string) => Promise; + } | null; + immersionTracker: ImmersionTrackerService | null; + mpvClient: unknown | null; + mpvSocketPath: string; + ankiIntegration: { + resolveCurrentNoteId: (noteId: number) => number; + } | null; + statsOverlayVisible: boolean; +}; + +export interface StatsRuntimeInput< + TConfig extends StatsConfigLike = StatsConfigLike, + TTracker extends StatsTrackerLike = StatsTrackerLike, + TServer extends StatsServerLike = StatsServerLike, +> { + statsDaemonStatePath: string; + getResolvedConfig: () => TConfig; + getImmersionTracker: () => TTracker | null; + ensureImmersionTrackerStartedCore: () => void; + ensureVocabularyCleanupTokenizerReady?: () => Promise | void; + startStatsServer: (port: number) => TServer; + openExternal: (url: string) => Promise; + exitAppWithCode: (code: number) => void; + destroyStatsWindow?: () => void; + logInfo: (message: string) => void; + logWarn: (message: string, error?: unknown) => void; + logError: (message: string, error: unknown) => void; + now?: () => number; + getCurrentPid?: () => number; + isProcessAlive?: (pid: number) => boolean; + killProcess?: (pid: number, signal: NodeJS.Signals) => void; + wait?: (delayMs: number) => Promise; +} + +export interface StatsRuntime { + readLiveBackgroundStatsDaemonState: () => BackgroundStatsServerState | null; + ensureImmersionTrackerStarted: () => void; + ensureStatsServerStarted: () => string; + stopStatsServer: () => void; + ensureBackgroundStatsServerStarted: () => { + url: string; + runningInCurrentProcess: boolean; + }; + stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>; + runStatsCliCommand: ( + args: Pick< + CliArgs, + | 'statsResponsePath' + | 'statsBackground' + | 'statsStop' + | 'statsCleanup' + | 'statsCleanupVocab' + | 'statsCleanupLifetime' + >, + source: CliCommandSource, + ) => Promise; + cleanupBeforeQuit: () => void; + getStatsServer: () => StatsServerLike | null; + isStatsStartupInProgress: () => boolean; +} + +export interface StatsRuntimeBootstrapInput { + statsDaemonStatePath: string; + statsDistPath: string; + statsPreloadPath: string; + userDataPath: string; + appState: StatsBootstrapAppState; + getResolvedConfig: () => ResolvedConfig; + dictionarySupport: { + getConfiguredDbPath: () => string; + seedImmersionMediaFromCurrentMedia: () => Promise | void; + }; + overlay: { + getOverlayGeometry: () => { getCurrentOverlayGeometry: () => Electron.Rectangle }; + updateVisibleOverlayVisibility: () => void; + registerStatsOverlayToggle: (options: { + staticDir: string; + preloadPath: string; + getApiBaseUrl: () => string; + getToggleKey: () => string; + resolveBounds: () => Electron.Rectangle; + onVisibilityChanged: (visible: boolean) => void; + }) => void; + }; + createMecabTokenizerAndCheck: () => Promise; + addYomitanNote: (word: string) => Promise; + openExternal: (url: string) => Promise; + requestAppQuit: () => void; + destroyStatsWindow: () => void; + logger: { + info: (message: string) => void; + warn: (message: string, error?: unknown) => void; + error: (message: string, error?: unknown) => void; + debug: (message: string, details?: unknown) => void; + }; +} + +export interface StatsRuntimeBootstrap { + stats: StatsRuntime; + immersion: AppReadyImmersionInput; + recordTrackedCardsMined: (count: number, noteIds?: number[]) => void; +} + +export function createStatsRuntime< + TConfig extends StatsConfigLike, + TTracker extends StatsTrackerLike, + TServer extends StatsServerLike, +>(input: StatsRuntimeInput): StatsRuntime { + const now = input.now ?? Date.now; + const getCurrentPid = input.getCurrentPid ?? (() => process.pid); + const isProcessAlive = input.isProcessAlive ?? isBackgroundStatsServerProcessAlive; + const killProcess = + input.killProcess ?? + ((pid: number, signal: NodeJS.Signals) => { + process.kill(pid, signal); + }); + const wait = + input.wait ?? + (async (delayMs: number) => { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + }); + + let statsServer: TServer | null = null; + let statsStartupInProgress = false; + let hasAttemptedImmersionTrackerStartup = false; + + const readLiveBackgroundStatsDaemonState = (): BackgroundStatsServerState | null => { + const state = readBackgroundStatsServerState(input.statsDaemonStatePath); + if (!state) { + removeBackgroundStatsServerState(input.statsDaemonStatePath); + return null; + } + if (state.pid === getCurrentPid() && !statsServer) { + removeBackgroundStatsServerState(input.statsDaemonStatePath); + return null; + } + if (!isProcessAlive(state.pid)) { + removeBackgroundStatsServerState(input.statsDaemonStatePath); + return null; + } + return state; + }; + + const clearOwnedBackgroundStatsDaemonState = (): void => { + const state = readBackgroundStatsServerState(input.statsDaemonStatePath); + if (state?.pid === getCurrentPid()) { + removeBackgroundStatsServerState(input.statsDaemonStatePath); + } + }; + + const stopStatsServer = (): void => { + if (!statsServer) { + return; + } + statsServer.close(); + statsServer = null; + clearOwnedBackgroundStatsDaemonState(); + }; + + const ensureImmersionTrackerStarted = (): void => { + if (hasAttemptedImmersionTrackerStartup || input.getImmersionTracker()) { + return; + } + hasAttemptedImmersionTrackerStartup = true; + statsStartupInProgress = true; + try { + input.ensureImmersionTrackerStartedCore(); + } finally { + statsStartupInProgress = false; + } + }; + + const ensureStatsServerStarted = (): string => { + const liveDaemon = readLiveBackgroundStatsDaemonState(); + if (liveDaemon && liveDaemon.pid !== getCurrentPid()) { + return resolveBackgroundStatsServerUrl(liveDaemon); + } + + const tracker = input.getImmersionTracker(); + if (!tracker) { + throw new Error('Immersion tracker failed to initialize.'); + } + + if (!statsServer) { + statsServer = input.startStatsServer(input.getResolvedConfig().stats.serverPort); + } + + return `http://127.0.0.1:${input.getResolvedConfig().stats.serverPort}`; + }; + + const ensureBackgroundStatsServerStarted = (): { + url: string; + runningInCurrentProcess: boolean; + } => { + const liveDaemon = readLiveBackgroundStatsDaemonState(); + if (liveDaemon && liveDaemon.pid !== getCurrentPid()) { + return { + url: resolveBackgroundStatsServerUrl(liveDaemon), + runningInCurrentProcess: false, + }; + } + + ensureImmersionTrackerStarted(); + const url = ensureStatsServerStarted(); + writeBackgroundStatsServerState(input.statsDaemonStatePath, { + pid: getCurrentPid(), + port: input.getResolvedConfig().stats.serverPort, + startedAtMs: now(), + }); + return { + url, + runningInCurrentProcess: true, + }; + }; + + const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => { + const state = readBackgroundStatsServerState(input.statsDaemonStatePath); + if (!state) { + removeBackgroundStatsServerState(input.statsDaemonStatePath); + return { ok: true, stale: true }; + } + if (!isProcessAlive(state.pid)) { + removeBackgroundStatsServerState(input.statsDaemonStatePath); + return { ok: true, stale: true }; + } + + try { + killProcess(state.pid, 'SIGTERM'); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') { + removeBackgroundStatsServerState(input.statsDaemonStatePath); + return { ok: true, stale: true }; + } + if ((error as NodeJS.ErrnoException)?.code === 'EPERM') { + throw new Error( + `Insufficient permissions to stop background stats server (pid ${state.pid}).`, + ); + } + throw error; + } + + const deadline = now() + 2_000; + while (now() < deadline) { + if (!isProcessAlive(state.pid)) { + removeBackgroundStatsServerState(input.statsDaemonStatePath); + return { ok: true, stale: false }; + } + await wait(50); + } + + throw new Error('Timed out stopping background stats server.'); + }; + + const runStatsCliCommand = createRunStatsCliCommandHandler({ + getResolvedConfig: () => input.getResolvedConfig(), + ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(), + ensureVocabularyCleanupTokenizerReady: input.ensureVocabularyCleanupTokenizerReady, + getImmersionTracker: () => input.getImmersionTracker(), + ensureStatsServerStarted: () => ensureStatsServerStarted(), + ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(), + stopBackgroundStatsServer: () => stopBackgroundStatsServer(), + openExternal: async (url) => await input.openExternal(url), + writeResponse: (responsePath, payload) => { + writeStatsCliCommandResponse(responsePath, payload); + }, + exitAppWithCode: (code) => input.exitAppWithCode(code), + logInfo: (message) => input.logInfo(message), + logWarn: (message, error) => input.logWarn(message, error), + logError: (message, error) => input.logError(message, error), + }); + + const cleanupBeforeQuit = (): void => { + input.destroyStatsWindow?.(); + stopStatsServer(); + }; + + return { + readLiveBackgroundStatsDaemonState, + ensureImmersionTrackerStarted, + ensureStatsServerStarted, + stopStatsServer, + ensureBackgroundStatsServerStarted, + stopBackgroundStatsServer, + runStatsCliCommand, + cleanupBeforeQuit, + getStatsServer: () => statsServer, + isStatsStartupInProgress: () => statsStartupInProgress, + }; +} + +export function createStatsRuntimeBootstrap( + input: StatsRuntimeBootstrapInput, +): StatsRuntimeBootstrap { + const statsCoverArtFetcher = createCoverArtFetcher( + createAnilistRateLimiter(), + createLogger('main:stats-cover-art'), + ); + const resolveLegacyVocabularyPos = async (row: { + headword: string; + word: string; + reading: string | null; + }) => { + const tokenizer = input.appState.mecabTokenizer; + if (!tokenizer) { + return null; + } + + const lookupTexts = [...new Set([row.headword, row.word, row.reading ?? ''])] + .map((value) => value.trim()) + .filter((value) => value.length > 0); + + for (const lookupText of lookupTexts) { + const tokens = await tokenizer.tokenize(lookupText); + if (!tokens) { + continue; + } + const resolved = resolveLegacyVocabularyPosFromTokens(lookupText, tokens as never); + if (resolved) { + return resolved; + } + } + + return null; + }; + + let stats: StatsRuntime | null = null; + const immersionMainDeps: Parameters[0] = { + getResolvedConfig: () => input.getResolvedConfig(), + getConfiguredDbPath: () => input.dictionarySupport.getConfiguredDbPath(), + createTrackerService: (params) => + new ImmersionTrackerService({ + ...params, + resolveLegacyVocabularyPos, + }), + setTracker: (tracker) => { + const trackerHasChanged = + input.appState.immersionTracker !== null && input.appState.immersionTracker !== tracker; + if (trackerHasChanged && stats?.getStatsServer()) { + stats.stopStatsServer(); + } + + input.appState.immersionTracker = tracker as ImmersionTrackerService | null; + input.appState.immersionTracker?.setCoverArtFetcher?.(statsCoverArtFetcher); + if (!tracker) { + return; + } + + if (!stats?.getStatsServer() && input.getResolvedConfig().stats.autoStartServer) { + stats?.ensureStatsServerStarted(); + } + + input.overlay.registerStatsOverlayToggle({ + staticDir: input.statsDistPath, + preloadPath: input.statsPreloadPath, + getApiBaseUrl: () => stats!.ensureStatsServerStarted(), + getToggleKey: () => input.getResolvedConfig().stats.toggleKey, + resolveBounds: () => input.overlay.getOverlayGeometry().getCurrentOverlayGeometry(), + onVisibilityChanged: (visible) => { + input.appState.statsOverlayVisible = visible; + input.overlay.updateVisibleOverlayVisibility(); + }, + }); + }, + getMpvClient: () => input.appState.mpvClient as never, + shouldAutoConnectMpv: () => !stats?.isStatsStartupInProgress(), + seedTrackerFromCurrentMedia: () => { + void input.dictionarySupport.seedImmersionMediaFromCurrentMedia(); + }, + logInfo: (message) => input.logger.info(message), + logDebug: (message) => input.logger.debug(message), + logWarn: (message, details) => input.logger.warn(message, details), + }; + const createImmersionTrackerStartup = createImmersionTrackerStartupHandler(immersionMainDeps); + + stats = createStatsRuntime({ + statsDaemonStatePath: input.statsDaemonStatePath, + getResolvedConfig: () => input.getResolvedConfig(), + getImmersionTracker: () => input.appState.immersionTracker, + ensureImmersionTrackerStartedCore: () => { + createImmersionTrackerStartup(); + }, + ensureVocabularyCleanupTokenizerReady: async () => { + await input.createMecabTokenizerAndCheck(); + }, + startStatsServer: (port) => + startStatsServerCore({ + port, + staticDir: input.statsDistPath, + tracker: input.appState.immersionTracker as ImmersionTrackerService, + knownWordCachePath: path.join(input.userDataPath, 'known-words-cache.json'), + mpvSocketPath: input.appState.mpvSocketPath, + ankiConnectConfig: input.getResolvedConfig().ankiConnect, + resolveAnkiNoteId: (noteId: number) => + input.appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, + addYomitanNote: (word: string) => input.addYomitanNote(word), + }), + openExternal: (url) => input.openExternal(url), + exitAppWithCode: (code) => { + process.exitCode = code; + input.requestAppQuit(); + }, + destroyStatsWindow: () => { + input.destroyStatsWindow(); + }, + logInfo: (message) => input.logger.info(message), + logWarn: (message, error) => input.logger.warn(message, error), + logError: (message, error) => input.logger.error(message, error), + }); + + return { + stats, + immersion: immersionMainDeps as AppReadyImmersionInput, + recordTrackedCardsMined: (count, noteIds) => { + stats.ensureImmersionTrackerStarted(); + input.appState.immersionTracker?.recordCardsMined?.(count, noteIds); + }, + }; +} diff --git a/src/main/subtitle-dictionary-runtime.ts b/src/main/subtitle-dictionary-runtime.ts new file mode 100644 index 00000000..ecabdf5b --- /dev/null +++ b/src/main/subtitle-dictionary-runtime.ts @@ -0,0 +1,451 @@ +import * as path from 'node:path'; + +import type { SubtitleCue } from '../core/services/subtitle-cue-parser'; +import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater'; +import type { OverlayHostedModal } from '../shared/ipc/contracts'; +import type { + FrequencyDictionaryLookup, + ResolvedConfig, + SubtitleData, + SubtitlePosition, +} from '../types'; +import { + deleteYomitanDictionaryByTitle, + getYomitanDictionaryInfo, + importYomitanDictionaryFromZip, + upsertYomitanDictionarySettings, + clearYomitanParserCachesForWindow, +} from '../core/services'; +import type { YomitanParserRuntimeDeps } from './yomitan-runtime'; +import { + createDictionarySupportRuntime, + type DictionarySupportRuntime, +} from './dictionary-support-runtime'; +import { + createDictionarySupportRuntimeInput, + type DictionarySupportRuntimeInputBuilderInput, +} from './dictionary-support-runtime-input'; +import { + createSubtitleRuntime, + type SubtitleRuntime, + type SubtitleRuntimeInput, +} from './subtitle-runtime'; +import type { JlptLookup } from './jlpt-runtime'; +import { formatSkippedYomitanWriteAction } from './runtime/yomitan-read-only-log'; + +type BrowserWindowLike = { + isDestroyed: () => boolean; + webContents: { + send: (channel: string, payload?: unknown) => void; + }; +}; + +type ImmersionTrackerLike = { + handleMediaChange: (path: string, title: string | null) => void; +} | null; + +type MpvClientLike = { + connected?: boolean; + currentSubStart?: number | null; + currentSubEnd?: number | null; + currentTimePos?: number | null; + currentVideoPath?: string | null; + requestProperty: (name: string) => Promise; +} | null; + +type OverlayUiLike = { + setVisibleOverlayVisible: (visible: boolean) => void; + getRestoreVisibleOverlayOnModalClose: () => Set; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; +} | null; + +type OverlayManagerLike = { + broadcastToOverlayWindows: (channel: string, payload?: unknown) => void; + getMainWindow: () => BrowserWindowLike | null; + getVisibleOverlayVisible: () => boolean; +}; + +type StartupOsdSequencerLike = NonNullable< + DictionarySupportRuntimeInputBuilderInput['startup']['startupOsdSequencer'] +>; + +export interface SubtitleDictionaryRuntimeInput { + env: { + platform: NodeJS.Platform; + dirname: string; + appPath: string; + resourcesPath: string; + userDataPath: string; + appUserDataPath: string; + homeDir: string; + appDataDir?: string; + cwd: string; + configDir: string; + defaultImmersionDbPath: string; + }; + appState: { + currentMediaPath: string | null; + currentMediaTitle: string | null; + currentSubText: string; + currentSubAssText: string; + mpvClient: MpvClientLike; + subtitlePosition: SubtitlePosition | null; + pendingSubtitlePosition: SubtitlePosition | null; + currentSubtitleData: SubtitleData | null; + activeParsedSubtitleCues: SubtitleCue[]; + activeParsedSubtitleSource: string | null; + immersionTracker: ImmersionTrackerLike; + jlptLevelLookup: JlptLookup; + frequencyRankLookup: FrequencyDictionaryLookup; + yomitanParserWindow: BrowserWindowLike | null; + }; + config: { + getResolvedConfig: () => ResolvedConfig; + }; + services: { + subtitleWsService: SubtitleRuntimeInput['subtitleWsService']; + annotationSubtitleWsService: SubtitleRuntimeInput['annotationSubtitleWsService']; + overlayManager: OverlayManagerLike; + startupOsdSequencer: StartupOsdSequencerLike; + }; + logging: { + debug: (message: string) => void; + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string, ...args: unknown[]) => void; + }; + subtitle: { + parseSubtitleCues: (content: string, filename: string) => SubtitleCue[]; + createSubtitlePrefetchService: SubtitleRuntimeInput['createSubtitlePrefetchService']; + schedule: (callback: () => void, delayMs: number) => ReturnType; + clearSchedule: (timer: ReturnType) => void; + }; + overlay: { + getOverlayUi: () => OverlayUiLike; + showMpvOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body?: string }) => void; + }; + playback: { + isRemoteMediaPath: (mediaPath: string) => boolean; + isYoutubePlaybackActive: (mediaPath: string | null, videoPath: string | null) => boolean; + waitForYomitanMutationReady: (mediaKey: string | null) => Promise; + }; + anilist: { + guessAnilistMediaInfo: ( + mediaPath: string | null, + mediaTitle: string | null, + ) => Promise; + }; + yomitan: { + isCharacterDictionaryEnabled: () => boolean; + getYomitanDictionaryInfo: () => Promise>; + importYomitanDictionary: (zipPath: string) => Promise; + deleteYomitanDictionary: (dictionaryTitle: string) => Promise; + upsertYomitanDictionarySettings: ( + dictionaryTitle: string, + profileScope: ResolvedConfig['anilist']['characterDictionary']['profileScope'], + ) => Promise; + hasParserWindow: () => boolean; + clearParserCaches: () => void; + }; +} + +export interface SubtitleDictionaryRuntime { + subtitle: SubtitleRuntime; + dictionarySupport: DictionarySupportRuntime; +} + +export interface SubtitleDictionaryRuntimeCoordinatorInput { + env: SubtitleDictionaryRuntimeInput['env']; + appState: SubtitleDictionaryRuntimeInput['appState']; + getResolvedConfig: () => ResolvedConfig; + services: SubtitleDictionaryRuntimeInput['services']; + logging: { + debug: (message: string, ...args: unknown[]) => void; + info: (message: string, ...args: unknown[]) => void; + warn: (message: string, ...args: unknown[]) => void; + error: (message: string, ...args: unknown[]) => void; + }; + overlay: SubtitleDictionaryRuntimeInput['overlay']; + playback: SubtitleDictionaryRuntimeInput['playback']; + anilist: SubtitleDictionaryRuntimeInput['anilist']; + subtitle: { + parseSubtitleCues: (content: string, filename: string) => SubtitleCue[]; + createSubtitlePrefetchService: SubtitleRuntimeInput['createSubtitlePrefetchService']; + }; + yomitan: { + isCharacterDictionaryEnabled: () => boolean; + isExternalReadOnlyMode: () => boolean; + logSkippedWrite: (message: string) => void; + ensureYomitanExtensionLoaded: () => Promise; + getParserRuntimeDeps: () => YomitanParserRuntimeDeps; + }; +} + +export function createSubtitleDictionaryRuntime( + input: SubtitleDictionaryRuntimeInput, +): SubtitleDictionaryRuntime { + const subtitlePositionsDir = path.join(input.env.configDir, 'subtitle-positions'); + + const subtitle = createSubtitleRuntime({ + getResolvedConfig: () => input.config.getResolvedConfig(), + getCurrentMediaPath: () => input.appState.currentMediaPath, + getCurrentMediaTitle: () => input.appState.currentMediaTitle, + getCurrentSubText: () => input.appState.currentSubText, + getCurrentSubAssText: () => input.appState.currentSubAssText, + getMpvClient: () => input.appState.mpvClient, + subtitleWsService: input.services.subtitleWsService, + annotationSubtitleWsService: input.services.annotationSubtitleWsService, + broadcastToOverlayWindows: (channel, payload) => + input.services.overlayManager.broadcastToOverlayWindows(channel, payload), + subtitlePositionsDir, + setSubtitlePosition: (position) => { + input.appState.subtitlePosition = position; + }, + setPendingSubtitlePosition: (position) => { + input.appState.pendingSubtitlePosition = position; + }, + clearPendingSubtitlePosition: () => { + input.appState.pendingSubtitlePosition = null; + }, + setCurrentSubtitleData: (payload) => { + input.appState.currentSubtitleData = payload; + }, + setActiveParsedSubtitleState: (cues, sourceKey) => { + input.appState.activeParsedSubtitleCues = cues; + input.appState.activeParsedSubtitleSource = sourceKey; + }, + parseSubtitleCues: (content, filename) => input.subtitle.parseSubtitleCues(content, filename), + createSubtitlePrefetchService: (deps) => input.subtitle.createSubtitlePrefetchService(deps), + schedule: (callback, delayMs) => input.subtitle.schedule(callback, delayMs), + clearSchedule: (timer) => input.subtitle.clearSchedule(timer), + logDebug: (message) => input.logging.debug(message), + logInfo: (message) => input.logging.info(message), + logWarn: (message) => input.logging.warn(message), + }); + + const dictionarySupport = createDictionarySupportRuntime( + createDictionarySupportRuntimeInput({ + env: { + platform: input.env.platform, + dirname: input.env.dirname, + appPath: input.env.appPath, + resourcesPath: input.env.resourcesPath, + userDataPath: input.env.userDataPath, + appUserDataPath: input.env.appUserDataPath, + homeDir: input.env.homeDir, + appDataDir: input.env.appDataDir, + cwd: input.env.cwd, + subtitlePositionsDir, + defaultImmersionDbPath: input.env.defaultImmersionDbPath, + }, + config: { + getResolvedConfig: () => input.config.getResolvedConfig(), + }, + dictionaryState: { + setJlptLevelLookup: (lookup) => { + input.appState.jlptLevelLookup = lookup; + }, + setFrequencyRankLookup: (lookup) => { + input.appState.frequencyRankLookup = lookup; + }, + }, + logger: { + info: (message) => input.logging.info(message), + debug: (message) => input.logging.debug(message), + warn: (message) => input.logging.warn(message), + error: (message, ...args) => input.logging.error(message, ...args), + }, + media: { + isRemoteMediaPath: (mediaPath) => input.playback.isRemoteMediaPath(mediaPath), + getCurrentMediaPath: () => input.appState.currentMediaPath, + setCurrentMediaPath: (mediaPath) => { + input.appState.currentMediaPath = mediaPath; + }, + getCurrentMediaTitle: () => input.appState.currentMediaTitle, + setCurrentMediaTitle: (title) => { + input.appState.currentMediaTitle = title; + }, + getPendingSubtitlePosition: () => input.appState.pendingSubtitlePosition, + clearPendingSubtitlePosition: () => { + input.appState.pendingSubtitlePosition = null; + }, + setSubtitlePosition: (position) => { + input.appState.subtitlePosition = position; + }, + }, + subtitle: { + loadSubtitlePosition: () => subtitle.loadSubtitlePosition(), + invalidateTokenizationCache: () => { + subtitle.invalidateTokenizationCache(); + }, + refreshSubtitlePrefetchFromActiveTrack: () => { + subtitle.refreshSubtitlePrefetchFromActiveTrack(); + }, + refreshCurrentSubtitle: (text) => { + subtitle.refreshCurrentSubtitle(text); + }, + getCurrentSubtitleText: () => input.appState.currentSubText, + }, + overlay: { + broadcastSubtitlePosition: (position) => { + input.services.overlayManager.broadcastToOverlayWindows('subtitle:position', position); + }, + broadcastToOverlayWindows: (channel, payload) => { + input.services.overlayManager.broadcastToOverlayWindows(channel, payload); + }, + getMainWindow: () => input.services.overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => input.services.overlayManager.getVisibleOverlayVisible(), + setVisibleOverlayVisible: (visible) => { + input.overlay.getOverlayUi()?.setVisibleOverlayVisible(visible); + }, + getRestoreVisibleOverlayOnModalClose: () => + input.overlay.getOverlayUi()?.getRestoreVisibleOverlayOnModalClose() ?? + new Set(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => + input.overlay + .getOverlayUi() + ?.sendToActiveOverlayWindow(channel, payload, runtimeOptions) ?? false, + }, + tracker: { + getTracker: () => input.appState.immersionTracker, + getMpvClient: () => input.appState.mpvClient, + }, + anilist: { + guessAnilistMediaInfo: (mediaPath, mediaTitle) => + input.anilist.guessAnilistMediaInfo(mediaPath, mediaTitle), + }, + yomitan: { + isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(), + getYomitanDictionaryInfo: () => input.yomitan.getYomitanDictionaryInfo(), + importYomitanDictionary: (zipPath) => input.yomitan.importYomitanDictionary(zipPath), + deleteYomitanDictionary: (dictionaryTitle) => + input.yomitan.deleteYomitanDictionary(dictionaryTitle), + upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) => + input.yomitan.upsertYomitanDictionarySettings(dictionaryTitle, profileScope), + hasParserWindow: () => input.yomitan.hasParserWindow(), + clearParserCaches: () => input.yomitan.clearParserCaches(), + }, + startup: { + getNotificationType: () => + input.config.getResolvedConfig().ankiConnect.behavior.notificationType, + showMpvOsd: (message) => input.overlay.showMpvOsd(message), + showDesktopNotification: (title, options) => + input.overlay.showDesktopNotification(title, options), + startupOsdSequencer: input.services.startupOsdSequencer, + }, + playback: { + isYoutubePlaybackActiveNow: () => + input.playback.isYoutubePlaybackActive( + input.appState.currentMediaPath, + input.appState.mpvClient?.currentVideoPath ?? null, + ), + waitForYomitanMutationReady: () => + input.playback.waitForYomitanMutationReady( + input.appState.currentMediaPath?.trim() || + input.appState.mpvClient?.currentVideoPath?.trim() || + null, + ), + }, + }), + ); + + return { + subtitle, + dictionarySupport, + }; +} + +export function createSubtitleDictionaryRuntimeCoordinator( + input: SubtitleDictionaryRuntimeCoordinatorInput, +): SubtitleDictionaryRuntime { + return createSubtitleDictionaryRuntime({ + env: input.env, + appState: input.appState, + config: { + getResolvedConfig: () => input.getResolvedConfig(), + }, + services: input.services, + logging: input.logging, + subtitle: { + parseSubtitleCues: (content, filename) => input.subtitle.parseSubtitleCues(content, filename), + createSubtitlePrefetchService: (deps) => input.subtitle.createSubtitlePrefetchService(deps), + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + clearSchedule: (timer) => clearTimeout(timer), + }, + overlay: input.overlay, + playback: input.playback, + anilist: input.anilist, + yomitan: { + isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(), + getYomitanDictionaryInfo: async () => { + await input.yomitan.ensureYomitanExtensionLoaded(); + return await getYomitanDictionaryInfo(input.yomitan.getParserRuntimeDeps(), { + error: (message, ...args) => input.logging.error(message, ...args), + info: (message, ...args) => input.logging.info(message, ...args), + }); + }, + importYomitanDictionary: async (zipPath) => { + if (input.yomitan.isExternalReadOnlyMode()) { + input.yomitan.logSkippedWrite( + formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath), + ); + return false; + } + await input.yomitan.ensureYomitanExtensionLoaded(); + return await importYomitanDictionaryFromZip(zipPath, input.yomitan.getParserRuntimeDeps(), { + error: (message, ...args) => input.logging.error(message, ...args), + info: (message, ...args) => input.logging.info(message, ...args), + }); + }, + deleteYomitanDictionary: async (dictionaryTitle) => { + if (input.yomitan.isExternalReadOnlyMode()) { + input.yomitan.logSkippedWrite( + formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle), + ); + return false; + } + await input.yomitan.ensureYomitanExtensionLoaded(); + return await deleteYomitanDictionaryByTitle( + dictionaryTitle, + input.yomitan.getParserRuntimeDeps(), + { + error: (message, ...args) => input.logging.error(message, ...args), + info: (message, ...args) => input.logging.info(message, ...args), + }, + ); + }, + upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => { + if (input.yomitan.isExternalReadOnlyMode()) { + input.yomitan.logSkippedWrite( + formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle), + ); + return false; + } + await input.yomitan.ensureYomitanExtensionLoaded(); + return await upsertYomitanDictionarySettings( + dictionaryTitle, + profileScope, + input.yomitan.getParserRuntimeDeps(), + { + error: (message, ...args) => input.logging.error(message, ...args), + info: (message, ...args) => input.logging.info(message, ...args), + }, + ); + }, + hasParserWindow: () => Boolean(input.appState.yomitanParserWindow), + clearParserCaches: () => { + if (input.appState.yomitanParserWindow) { + clearYomitanParserCachesForWindow(input.appState.yomitanParserWindow as never); + } + }, + }, + }); +} diff --git a/src/main/subtitle-runtime-sources.ts b/src/main/subtitle-runtime-sources.ts new file mode 100644 index 00000000..8a640e29 --- /dev/null +++ b/src/main/subtitle-runtime-sources.ts @@ -0,0 +1,131 @@ +import * as fs from 'node:fs'; +import { spawn } from 'node:child_process'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { codecToExtension } from '../subsync/utils'; +import { resolveSubtitleSourcePath } from './runtime/subtitle-prefetch-source'; + +export type MpvSubtitleTrackLike = { + type?: unknown; + id?: unknown; + codec?: unknown; + external?: unknown; + 'ff-index'?: unknown; + 'external-filename'?: unknown; +}; + +const DEFAULT_SUBTITLE_SOURCE_FETCH_TIMEOUT_MS = 4000; + +function parseTrackId(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value.trim()); + return Number.isInteger(parsed) ? parsed : null; + } + return null; +} + +function buildFfmpegSubtitleExtractionArgs( + videoPath: string, + ffIndex: number, + outputPath: string, +): string[] { + return [ + '-hide_banner', + '-nostdin', + '-y', + '-loglevel', + 'error', + '-an', + '-vn', + '-i', + videoPath, + '-map', + `0:${ffIndex}`, + '-f', + path.extname(outputPath).slice(1), + outputPath, + ]; +} + +export function createSubtitleSourceLoader(options?: { + fetchImpl?: typeof fetch; + subtitleSourceFetchTimeoutMs?: number; +}): (source: string) => Promise { + const fetchImpl = options?.fetchImpl ?? fetch; + const timeoutMs = + options?.subtitleSourceFetchTimeoutMs ?? DEFAULT_SUBTITLE_SOURCE_FETCH_TIMEOUT_MS; + + return async (source: string): Promise => { + if (/^https?:\/\//i.test(source)) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(source, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Failed to download subtitle source (${response.status})`); + } + return await response.text(); + } finally { + clearTimeout(timeoutId); + } + } + + const filePath = resolveSubtitleSourcePath(source); + return await fs.promises.readFile(filePath, 'utf8'); + }; +} + +export function createExtractInternalSubtitleTrackToTempFileHandler() { + return async ( + ffmpegPath: string, + videoPath: string, + track: MpvSubtitleTrackLike, + ): Promise<{ path: string; cleanup: () => Promise } | null> => { + const ffIndex = parseTrackId(track['ff-index']); + const codec = typeof track.codec === 'string' ? track.codec : null; + const extension = codecToExtension(codec ?? undefined); + if (ffIndex === null || extension === null) { + return null; + } + + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-')); + const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`); + + try { + await new Promise((resolve, reject) => { + const child = spawn( + ffmpegPath, + buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath), + ); + let stderr = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('error', (error) => { + reject(error); + }); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`)); + }); + }); + } catch (error) { + await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + throw error; + } + + return { + path: outputPath, + cleanup: async () => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }, + }; + }; +} diff --git a/src/main/subtitle-runtime.test.ts b/src/main/subtitle-runtime.test.ts new file mode 100644 index 00000000..7be536c0 --- /dev/null +++ b/src/main/subtitle-runtime.test.ts @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createSubtitleRuntime } from './subtitle-runtime'; + +function createResolvedConfig() { + return { + subtitleStyle: { + frequencyDictionary: { + enabled: true, + topX: 5, + mode: 'top', + }, + }, + subtitleSidebar: { + autoScroll: true, + pauseVideoOnHover: false, + maxWidth: 420, + opacity: 0.92, + backgroundColor: '#111111', + textColor: '#ffffff', + fontFamily: 'sans-serif', + fontSize: 24, + timestampColor: '#cccccc', + activeLineColor: '#ffffff', + activeLineBackgroundColor: '#222222', + hoverLineBackgroundColor: '#333333', + }, + subtitlePosition: { + yPercent: 84, + }, + subsync: { + defaultMode: 'auto' as const, + alass_path: 'alass', + ffmpeg_path: 'ffmpeg', + ffsubsync_path: 'ffsubsync', + replace: false, + }, + } as never; +} + +function createMpvClient(properties: Record) { + return { + connected: true, + currentSubStart: 1.25, + currentSubEnd: 2.5, + currentTimePos: 12.5, + requestProperty: async (name: string) => properties[name], + }; +} + +function createRuntime(overrides: Partial[0]> = {}) { + const calls: string[] = []; + const config = createResolvedConfig(); + let subtitlePosition: unknown = null; + let pendingSubtitlePosition: unknown = null; + const runtime = createSubtitleRuntime({ + getResolvedConfig: () => config, + getCurrentMediaPath: () => '/media/episode.mkv', + getCurrentMediaTitle: () => 'Episode', + getCurrentSubText: () => 'current subtitle', + getCurrentSubAssText: () => '[Events]', + getMpvClient: () => + createMpvClient({ + 'current-tracks/sub/external-filename': '/tmp/episode.ass', + 'current-tracks/sub': { + type: 'sub', + id: 3, + external: true, + 'external-filename': '/tmp/episode.ass', + }, + 'track-list': [ + { + type: 'sub', + id: 3, + external: true, + 'external-filename': '/tmp/episode.ass', + }, + ], + sid: 3, + path: '/media/episode.mkv', + }), + broadcastToOverlayWindows: (channel, payload) => { + calls.push(`${channel}:${JSON.stringify(payload)}`); + }, + subtitleWsService: { + broadcast: () => calls.push('subtitle-ws'), + }, + annotationSubtitleWsService: { + broadcast: () => calls.push('annotation-ws'), + }, + subtitlePositionsDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subtitle-runtime-')), + setSubtitlePosition: (position) => { + subtitlePosition = position; + }, + setPendingSubtitlePosition: (position) => { + pendingSubtitlePosition = position; + }, + clearPendingSubtitlePosition: () => { + pendingSubtitlePosition = null; + }, + parseSubtitleCues: (content) => [ + { + startTime: 0, + endTime: 1, + text: content.trim(), + }, + ], + createSubtitlePrefetchService: ({ cues }) => ({ + start: () => calls.push(`start:${cues.length}`), + stop: () => calls.push('stop'), + onSeek: (time) => calls.push(`seek:${time}`), + pause: () => calls.push('pause'), + resume: () => calls.push('resume'), + }), + logDebug: (message) => calls.push(`debug:${message}`), + logInfo: (message) => calls.push(`info:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + schedule: (fn, delayMs) => setTimeout(fn, delayMs), + clearSchedule: (timer) => clearTimeout(timer), + ...overrides, + }); + return { runtime, calls, subtitlePosition, pendingSubtitlePosition }; +} + +test('subtitle runtime schedules and cancels subtitle prefetch refreshes', async () => { + const calls: string[] = []; + const { runtime } = createRuntime({ + refreshSubtitlePrefetchFromActiveTrack: async () => { + calls.push('refresh'); + }, + }); + + runtime.scheduleSubtitlePrefetchRefresh(5); + runtime.clearScheduledSubtitlePrefetchRefresh(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.deepEqual(calls, []); +}); + +test('subtitle runtime times out remote subtitle source fetches', async () => { + const { runtime } = createRuntime({ + fetchImpl: async (_url, init) => { + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + }); + return new Response(''); + }, + subtitleSourceFetchTimeoutMs: 10, + }); + + await assert.rejects( + async () => await runtime.loadSubtitleSourceText('https://example.com/subtitles.srt'), + /aborted/, + ); +}); + +test('subtitle runtime reuses cached sidebar cues for the same source key', async () => { + const subtitlePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subtitle-cache-')), + 'episode.ass', + ); + fs.writeFileSync( + subtitlePath, + `1 +00:00:01,000 --> 00:00:02,000 +Hello`, + ); + + let loadCount = 0; + const { runtime } = createRuntime({ + getMpvClient: () => + createMpvClient({ + 'current-tracks/sub/external-filename': subtitlePath, + 'current-tracks/sub': { + type: 'sub', + id: 3, + external: true, + 'external-filename': subtitlePath, + }, + 'track-list': [ + { + type: 'sub', + id: 3, + external: true, + 'external-filename': subtitlePath, + }, + ], + sid: 3, + path: '/media/episode.mkv', + }), + loadSubtitleSourceText: async () => { + loadCount += 1; + return fs.readFileSync(subtitlePath, 'utf8'); + }, + }); + + const first = await runtime.getSubtitleSidebarSnapshot(); + const second = await runtime.getSubtitleSidebarSnapshot(); + + assert.equal(loadCount, 1); + assert.deepEqual(second.cues, first.cues); + assert.equal(second.currentSubtitle.text, 'current subtitle'); +}); diff --git a/src/main/subtitle-runtime.ts b/src/main/subtitle-runtime.ts new file mode 100644 index 00000000..21eccdf9 --- /dev/null +++ b/src/main/subtitle-runtime.ts @@ -0,0 +1,423 @@ +import { createSubtitleProcessingController } from '../core/services/subtitle-processing-controller'; +import { + createSubtitlePrefetchService, + type SubtitlePrefetchService, + type SubtitlePrefetchServiceDeps, +} from '../core/services/subtitle-prefetch'; +import type { SubtitleWebsocketFrequencyOptions } from '../core/services/subtitle-ws'; +import type { SubtitleCue } from '../core/services/subtitle-cue-parser'; +import { + loadSubtitlePosition as loadSubtitlePositionCore, + saveSubtitlePosition as saveSubtitlePositionCore, +} from '../core/services/subtitle-position'; +import type { + ResolvedConfig, + SubtitleData, + SubtitlePosition, + SubtitleSidebarSnapshot, +} from '../types'; +import { + createLoadSubtitlePositionHandler, + createSaveSubtitlePositionHandler, +} from './runtime/subtitle-position'; +import { resolveSubtitleSourcePath } from './runtime/subtitle-prefetch-source'; +import { + createRefreshSubtitlePrefetchFromActiveTrackHandler, + createResolveActiveSubtitleSidebarSourceHandler, +} from './runtime/subtitle-prefetch-runtime'; +import { createSubtitlePrefetchInitController } from './runtime/subtitle-prefetch-init'; +import { + createExtractInternalSubtitleTrackToTempFileHandler, + createSubtitleSourceLoader, + type MpvSubtitleTrackLike, +} from './subtitle-runtime-sources'; + +type SubtitleBroadcastService = { + broadcast: (payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions) => void; +}; + +type SubtitleRuntimeConfigLike = Pick< + ResolvedConfig, + 'subtitleStyle' | 'subtitleSidebar' | 'subtitlePosition' | 'subsync' +>; + +export interface SubtitleRuntimeInput { + getResolvedConfig: () => SubtitleRuntimeConfigLike; + getCurrentMediaPath: () => string | null; + getCurrentMediaTitle: () => string | null; + getCurrentSubText: () => string; + getCurrentSubAssText: () => string; + getMpvClient: () => { + connected?: boolean; + currentSubStart?: number | null; + currentSubEnd?: number | null; + currentTimePos?: number | null; + requestProperty: (name: string) => Promise; + } | null; + subtitleWsService: SubtitleBroadcastService; + annotationSubtitleWsService: SubtitleBroadcastService; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + subtitlePositionsDir: string; + setSubtitlePosition: (position: SubtitlePosition | null) => void; + setPendingSubtitlePosition: (position: SubtitlePosition | null) => void; + clearPendingSubtitlePosition: () => void; + setCurrentSubtitleData?: (payload: SubtitleData | null) => void; + setActiveParsedSubtitleState?: (cues: SubtitleCue[], sourceKey: string | null) => void; + parseSubtitleCues: (content: string, filename: string) => SubtitleCue[]; + createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService; + loadSubtitleSourceText?: (source: string) => Promise; + refreshSubtitlePrefetchFromActiveTrack?: () => Promise; + fetchImpl?: typeof fetch; + subtitleSourceFetchTimeoutMs?: number; + prefetchRefreshDelayMs?: number; + seekThresholdSeconds?: number; + schedule: (callback: () => void, delayMs: number) => ReturnType; + clearSchedule: (timer: ReturnType) => void; + logDebug: (message: string) => void; + logInfo: (message: string) => void; + logWarn: (message: string) => void; +} + +export interface SubtitleRuntime { + setTokenizeSubtitleDeferred: (tokenize: ((text: string) => Promise) | null) => void; + emitSubtitlePayload: (payload: SubtitleData) => void; + refreshCurrentSubtitle: (textOverride?: string) => void; + invalidateTokenizationCache: () => void; + preCacheTokenization: (text: string, data: SubtitleData) => void; + consumeCachedSubtitle: (text: string) => SubtitleData | null; + isCacheFull: () => boolean; + onSubtitleChange: (text: string) => void; + onCurrentMediaPathChange: (path: string | null) => void; + onTimePosUpdate: (time: number) => void; + getLastObservedTimePos: () => number; + refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise; + refreshSubtitlePrefetchFromActiveTrack: () => Promise; + cancelPendingSubtitlePrefetchInit: () => void; + scheduleSubtitlePrefetchRefresh: (delayMs?: number) => void; + clearScheduledSubtitlePrefetchRefresh: () => void; + getSubtitleSidebarSnapshot: () => Promise; + tokenizeCurrentSubtitle: () => Promise; + loadSubtitleSourceText: (source: string) => Promise; + extractInternalSubtitleTrackToTempFile: ( + ffmpegPath: string, + videoPath: string, + track: MpvSubtitleTrackLike, + ) => Promise<{ path: string; cleanup: () => Promise } | null>; + loadSubtitlePosition: () => SubtitlePosition | null; + saveSubtitlePosition: (position: SubtitlePosition) => void; + getCurrentSubtitleData: () => SubtitleData | null; + getActiveParsedSubtitleCues: () => SubtitleCue[]; + getActiveParsedSubtitleSource: () => string | null; +} + +const DEFAULT_PREFETCH_REFRESH_DELAY_MS = 500; +const SEEK_THRESHOLD_SECONDS = 3; + +export function createSubtitleRuntime(input: SubtitleRuntimeInput): SubtitleRuntime { + const loadSubtitleSourceText = + input.loadSubtitleSourceText ?? + createSubtitleSourceLoader({ + fetchImpl: input.fetchImpl, + subtitleSourceFetchTimeoutMs: input.subtitleSourceFetchTimeoutMs, + }); + const extractInternalSubtitleTrackToTempFile = + createExtractInternalSubtitleTrackToTempFileHandler(); + + let tokenizeSubtitleDeferred: ((text: string) => Promise) | null = null; + let currentSubtitleData: SubtitleData | null = null; + let subtitlePrefetchService: SubtitlePrefetchService | null = null; + let subtitlePrefetchRefreshTimer: ReturnType | null = null; + let lastObservedTimePos = 0; + let activeParsedSubtitleCues: SubtitleCue[] = []; + let activeParsedSubtitleSource: string | null = null; + + const setActiveParsedSubtitleState = ( + cues: SubtitleCue[] | null, + sourceKey: string | null, + ): void => { + activeParsedSubtitleCues = cues ?? []; + activeParsedSubtitleSource = sourceKey; + input.setActiveParsedSubtitleState?.(activeParsedSubtitleCues, activeParsedSubtitleSource); + }; + + const tokenizationController = createSubtitleProcessingController({ + tokenizeSubtitle: async (text: string) => + tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null }, + emitSubtitle: (payload) => emitSubtitlePayload(payload), + logDebug: (message) => { + input.logDebug(`[subtitle-processing] ${message}`); + }, + now: () => Date.now(), + }); + + const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ + getCurrentService: () => subtitlePrefetchService, + setCurrentService: (service) => { + subtitlePrefetchService = service; + }, + loadSubtitleSourceText, + parseSubtitleCues: (content, filename) => input.parseSubtitleCues(content, filename), + createSubtitlePrefetchService: (deps) => input.createSubtitlePrefetchService(deps), + tokenizeSubtitle: async (text) => + tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, + preCacheTokenization: (text, data) => tokenizationController.preCacheTokenization(text, data), + isCacheFull: () => tokenizationController.isCacheFull(), + logInfo: (message) => input.logInfo(message), + logWarn: (message) => input.logWarn(message), + onParsedSubtitleCuesChanged: (cues, sourceKey) => { + setActiveParsedSubtitleState(cues, sourceKey); + }, + }); + + const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler( + { + getFfmpegPath: () => input.getResolvedConfig().subsync.ffmpeg_path.trim() || 'ffmpeg', + extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) => + extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track), + }, + ); + + const refreshSubtitlePrefetchFromActiveTrackHandler = + input.refreshSubtitlePrefetchFromActiveTrack ?? + createRefreshSubtitlePrefetchFromActiveTrackHandler({ + getMpvClient: () => input.getMpvClient(), + getLastObservedTimePos: () => lastObservedTimePos, + subtitlePrefetchInitController, + resolveActiveSubtitleSidebarSource: (nextInput) => + resolveActiveSubtitleSidebarSourceHandler(nextInput), + }); + + const loadSubtitlePosition = createLoadSubtitlePositionHandler({ + loadSubtitlePositionCore: () => + loadSubtitlePositionCore({ + currentMediaPath: input.getCurrentMediaPath(), + fallbackPosition: input.getResolvedConfig().subtitlePosition, + subtitlePositionsDir: input.subtitlePositionsDir, + }), + setSubtitlePosition: (position) => input.setSubtitlePosition(position), + }); + + const saveSubtitlePosition = createSaveSubtitlePositionHandler({ + saveSubtitlePositionCore: (position) => + saveSubtitlePositionCore({ + position, + currentMediaPath: input.getCurrentMediaPath(), + subtitlePositionsDir: input.subtitlePositionsDir, + onQueuePending: (queued) => input.setPendingSubtitlePosition(queued), + onPersisted: () => input.clearPendingSubtitlePosition(), + }), + setSubtitlePosition: (position) => input.setSubtitlePosition(position), + }); + + const getSubtitleBroadcastOptions = (): SubtitleWebsocketFrequencyOptions => { + const config = input.getResolvedConfig().subtitleStyle.frequencyDictionary; + return { + enabled: config.enabled, + topX: config.topX, + mode: config.mode, + }; + }; + + const withCurrentSubtitleTiming = (payload: SubtitleData): SubtitleData => ({ + ...payload, + startTime: input.getMpvClient()?.currentSubStart ?? null, + endTime: input.getMpvClient()?.currentSubEnd ?? null, + }); + + const clearScheduledSubtitlePrefetchRefresh = (): void => { + if (subtitlePrefetchRefreshTimer) { + input.clearSchedule(subtitlePrefetchRefreshTimer); + subtitlePrefetchRefreshTimer = null; + } + }; + + const scheduleSubtitlePrefetchRefresh = (delayMs = 0): void => { + clearScheduledSubtitlePrefetchRefresh(); + subtitlePrefetchRefreshTimer = input.schedule(() => { + subtitlePrefetchRefreshTimer = null; + void refreshSubtitlePrefetchFromActiveTrackHandler(); + }, delayMs); + }; + + const refreshSubtitleSidebarFromSource = async (sourcePath: string): Promise => { + const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim()); + if (!normalizedSourcePath) { + return; + } + await subtitlePrefetchInitController.initSubtitlePrefetch( + normalizedSourcePath, + lastObservedTimePos, + normalizedSourcePath, + ); + }; + + const onCurrentMediaPathChange = (pathValue: string | null): void => { + clearScheduledSubtitlePrefetchRefresh(); + subtitlePrefetchInitController.cancelPendingInit(); + if (pathValue) { + scheduleSubtitlePrefetchRefresh( + input.prefetchRefreshDelayMs ?? DEFAULT_PREFETCH_REFRESH_DELAY_MS, + ); + } + }; + + const onTimePosUpdate = (time: number): void => { + const delta = time - lastObservedTimePos; + if ( + subtitlePrefetchService && + (delta > (input.seekThresholdSeconds ?? SEEK_THRESHOLD_SECONDS) || delta < 0) + ) { + subtitlePrefetchService.onSeek(time); + } + lastObservedTimePos = time; + }; + + const emitSubtitlePayload = (payload: SubtitleData): void => { + const timedPayload = withCurrentSubtitleTiming(payload); + currentSubtitleData = timedPayload; + input.setCurrentSubtitleData?.(timedPayload); + input.broadcastToOverlayWindows('subtitle:set', timedPayload); + const broadcastOptions = getSubtitleBroadcastOptions(); + input.subtitleWsService.broadcast(timedPayload, broadcastOptions); + input.annotationSubtitleWsService.broadcast(timedPayload, broadcastOptions); + subtitlePrefetchService?.resume(); + }; + + const tokenizeCurrentSubtitle = async (): Promise => { + const tokenized = await tokenizationController.consumeCachedSubtitle(input.getCurrentSubText()); + if (tokenized) { + return withCurrentSubtitleTiming(tokenized); + } + const text = input.getCurrentSubText(); + const deferred = tokenizeSubtitleDeferred + ? await tokenizeSubtitleDeferred(text) + : { text, tokens: null }; + return withCurrentSubtitleTiming(deferred); + }; + + return { + setTokenizeSubtitleDeferred: (tokenize) => { + tokenizeSubtitleDeferred = tokenize; + }, + emitSubtitlePayload, + refreshCurrentSubtitle: (textOverride?: string) => { + tokenizationController.refreshCurrentSubtitle(textOverride); + }, + invalidateTokenizationCache: () => { + tokenizationController.invalidateTokenizationCache(); + }, + preCacheTokenization: (text, data) => { + tokenizationController.preCacheTokenization(text, data); + }, + consumeCachedSubtitle: (text) => tokenizationController.consumeCachedSubtitle(text), + isCacheFull: () => tokenizationController.isCacheFull(), + onSubtitleChange: (text) => { + subtitlePrefetchService?.pause(); + tokenizationController.onSubtitleChange(text); + }, + onCurrentMediaPathChange, + onTimePosUpdate, + getLastObservedTimePos: () => lastObservedTimePos, + refreshSubtitleSidebarFromSource, + refreshSubtitlePrefetchFromActiveTrack: async () => { + await refreshSubtitlePrefetchFromActiveTrackHandler(); + }, + cancelPendingSubtitlePrefetchInit: () => { + subtitlePrefetchInitController.cancelPendingInit(); + }, + scheduleSubtitlePrefetchRefresh, + clearScheduledSubtitlePrefetchRefresh, + getSubtitleSidebarSnapshot: async (): Promise => { + const currentSubtitle = { + text: input.getCurrentSubText(), + startTime: input.getMpvClient()?.currentSubStart ?? null, + endTime: input.getMpvClient()?.currentSubEnd ?? null, + }; + const currentTimeSec = input.getMpvClient()?.currentTimePos ?? null; + const config = input.getResolvedConfig().subtitleSidebar; + const client = input.getMpvClient(); + if (!client?.connected) { + return { + cues: activeParsedSubtitleCues, + currentTimeSec, + currentSubtitle, + config, + }; + } + + try { + const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] = + await Promise.all([ + client.requestProperty('current-tracks/sub/external-filename').catch(() => null), + client.requestProperty('current-tracks/sub').catch(() => null), + client.requestProperty('track-list'), + client.requestProperty('sid'), + client.requestProperty('path'), + ]); + const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : ''; + if (!videoPath) { + return { + cues: activeParsedSubtitleCues, + currentTimeSec, + currentSubtitle, + config, + }; + } + + const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({ + currentExternalFilenameRaw, + currentTrackRaw, + trackListRaw, + sidRaw, + videoPath, + }); + if (!resolvedSource) { + return { + cues: activeParsedSubtitleCues, + currentTimeSec, + currentSubtitle, + config, + }; + } + + try { + if (activeParsedSubtitleSource === resolvedSource.sourceKey) { + return { + cues: activeParsedSubtitleCues, + currentTimeSec, + currentSubtitle, + config, + }; + } + + const content = await loadSubtitleSourceText(resolvedSource.path); + const cues = input.parseSubtitleCues(content, resolvedSource.path); + setActiveParsedSubtitleState(cues, resolvedSource.sourceKey); + return { + cues, + currentTimeSec, + currentSubtitle, + config, + }; + } finally { + await resolvedSource.cleanup?.(); + } + } catch { + return { + cues: activeParsedSubtitleCues, + currentTimeSec, + currentSubtitle, + config, + }; + } + }, + tokenizeCurrentSubtitle, + loadSubtitleSourceText, + extractInternalSubtitleTrackToTempFile, + loadSubtitlePosition, + saveSubtitlePosition, + getCurrentSubtitleData: () => currentSubtitleData, + getActiveParsedSubtitleCues: () => activeParsedSubtitleCues, + getActiveParsedSubtitleSource: () => activeParsedSubtitleSource, + }; +} diff --git a/src/main/yomitan-runtime-bootstrap.ts b/src/main/yomitan-runtime-bootstrap.ts new file mode 100644 index 00000000..bf613564 --- /dev/null +++ b/src/main/yomitan-runtime-bootstrap.ts @@ -0,0 +1,101 @@ +import type { BrowserWindow, Extension, Session } from 'electron'; + +import type { YomitanExtensionLoaderDeps } from '../core/services/yomitan-extension-loader'; +import type { ResolvedConfig } from '../types'; +import { createYomitanProfilePolicy } from './runtime/yomitan-profile-policy'; +import { createYomitanRuntime, type YomitanRuntime } from './yomitan-runtime'; + +export interface YomitanRuntimeBootstrapInput { + userDataPath: string; + getResolvedConfig: () => ResolvedConfig; + appState: { + yomitanParserWindow: BrowserWindow | null; + yomitanParserReadyPromise: Promise | null; + yomitanParserInitPromise: Promise | null; + yomitanExt: Extension | null; + yomitanSession: Session | null; + yomitanSettingsWindow: BrowserWindow | null; + }; + loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise; + getLoadInFlight: () => Promise | null; + setLoadInFlight: (promise: Promise | null) => void; + openYomitanSettingsWindow: (params: { + yomitanExt: Extension | null; + getExistingWindow: () => BrowserWindow | null; + setWindow: (window: BrowserWindow | null) => void; + yomitanSession?: Session | null; + onWindowClosed?: () => void; + }) => void; + logInfo: (message: string) => void; + logWarn: (message: string) => void; + logError: (message: string, error: unknown) => void; + showMpvOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body: string }) => void; +} + +export interface YomitanRuntimeBootstrap { + yomitan: YomitanRuntime; + yomitanProfilePolicy: ReturnType; +} + +export function createYomitanRuntimeBootstrap( + input: YomitanRuntimeBootstrapInput, +): YomitanRuntimeBootstrap { + const yomitanProfilePolicy = createYomitanProfilePolicy({ + externalProfilePath: input.getResolvedConfig().yomitan.externalProfilePath, + logInfo: (message) => input.logInfo(message), + }); + + const yomitan = createYomitanRuntime({ + userDataPath: input.userDataPath, + externalProfilePath: yomitanProfilePolicy.externalProfilePath, + loadYomitanExtensionCore: input.loadYomitanExtensionCore, + getYomitanParserWindow: () => input.appState.yomitanParserWindow, + setYomitanParserWindow: (window) => { + input.appState.yomitanParserWindow = window; + }, + getYomitanParserReadyPromise: () => input.appState.yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise) => { + input.appState.yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => input.appState.yomitanParserInitPromise, + setYomitanParserInitPromise: (promise) => { + input.appState.yomitanParserInitPromise = promise; + }, + getYomitanExtension: () => input.appState.yomitanExt, + setYomitanExtension: (extension) => { + input.appState.yomitanExt = extension; + }, + getYomitanSession: () => input.appState.yomitanSession, + setYomitanSession: (session) => { + input.appState.yomitanSession = session; + }, + getLoadInFlight: () => input.getLoadInFlight(), + setLoadInFlight: (promise) => { + input.setLoadInFlight(promise); + }, + getResolvedConfig: () => input.getResolvedConfig(), + openYomitanSettingsWindow: (params) => + input.openYomitanSettingsWindow({ + yomitanExt: params.yomitanExt, + getExistingWindow: params.getExistingWindow, + setWindow: params.setWindow, + yomitanSession: params.yomitanSession, + onWindowClosed: params.onWindowClosed, + }), + getExistingSettingsWindow: () => input.appState.yomitanSettingsWindow, + setSettingsWindow: (window) => { + input.appState.yomitanSettingsWindow = window; + }, + logInfo: (message) => input.logInfo(message), + logWarn: (message) => input.logWarn(message), + logError: (message, error) => input.logError(message, error), + showMpvOsd: (message) => input.showMpvOsd(message), + showDesktopNotification: (title, options) => input.showDesktopNotification(title, options), + }); + + return { + yomitan, + yomitanProfilePolicy, + }; +} diff --git a/src/main/yomitan-runtime.ts b/src/main/yomitan-runtime.ts new file mode 100644 index 00000000..5769f4ca --- /dev/null +++ b/src/main/yomitan-runtime.ts @@ -0,0 +1,202 @@ +import type { BrowserWindow, Extension, Session } from 'electron'; + +import { clearYomitanParserCachesForWindow, syncYomitanDefaultAnkiServer } from '../core/services'; +import type { YomitanExtensionLoaderDeps } from '../core/services/yomitan-extension-loader'; +import type { ResolvedConfig } from '../types'; +import { createYomitanExtensionRuntime } from './runtime/yomitan-extension-runtime'; +import { createYomitanProfilePolicy } from './runtime/yomitan-profile-policy'; +import { createYomitanSettingsRuntime } from './runtime/yomitan-settings-runtime'; +import { + getPreferredYomitanAnkiServerUrl, + shouldForceOverrideYomitanAnkiServer, +} from './runtime/yomitan-anki-server'; +export interface YomitanParserRuntimeDeps { + getYomitanExt: () => Extension | null; + getYomitanSession: () => Session | null; + getYomitanParserWindow: () => BrowserWindow | null; + setYomitanParserWindow: (window: BrowserWindow | null) => void; + getYomitanParserReadyPromise: () => Promise | null; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + getYomitanParserInitPromise: () => Promise | null; + setYomitanParserInitPromise: (promise: Promise | null) => void; +} + +export interface YomitanRuntimeInput { + userDataPath: string; + externalProfilePath: string; + loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise; + getYomitanParserWindow: () => BrowserWindow | null; + setYomitanParserWindow: (window: BrowserWindow | null) => void; + getYomitanParserReadyPromise: () => Promise | null; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + getYomitanParserInitPromise: () => Promise | null; + setYomitanParserInitPromise: (promise: Promise | null) => void; + getYomitanExtension: () => Extension | null; + setYomitanExtension: (extension: Extension | null) => void; + getYomitanSession: () => Session | null; + setYomitanSession: (session: Session | null) => void; + getLoadInFlight: () => Promise | null; + setLoadInFlight: (promise: Promise | null) => void; + getResolvedConfig: () => ResolvedConfig; + openYomitanSettingsWindow: (params: { + yomitanExt: Extension | null; + getExistingWindow: () => BrowserWindow | null; + setWindow: (window: BrowserWindow | null) => void; + yomitanSession?: Session | null; + onWindowClosed?: () => void; + }) => void; + getExistingSettingsWindow: () => BrowserWindow | null; + setSettingsWindow: (window: BrowserWindow | null) => void; + logInfo: (message: string) => void; + logWarn: (message: string) => void; + logError: (message: string, error: unknown) => void; + showMpvOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body: string }) => void; +} + +export interface YomitanRuntime { + loadYomitanExtension: () => Promise; + ensureYomitanExtensionLoaded: () => Promise; + openYomitanSettings: () => boolean; + syncDefaultProfileAnkiServer: () => Promise; + getParserRuntimeDeps: () => YomitanParserRuntimeDeps; + getPreferredAnkiServerUrl: () => string; + isExternalReadOnlyMode: () => boolean; + isCharacterDictionaryEnabled: () => boolean; + getCharacterDictionaryDisabledReason: () => string | null; + clearParserCachesForWindow: (window: BrowserWindow) => void; +} + +export function createYomitanRuntime(input: YomitanRuntimeInput): YomitanRuntime { + const profilePolicy = createYomitanProfilePolicy({ + externalProfilePath: input.externalProfilePath, + logInfo: (message) => input.logInfo(message), + }); + + const extensionRuntime = createYomitanExtensionRuntime({ + loadYomitanExtensionCore: input.loadYomitanExtensionCore, + userDataPath: input.userDataPath, + externalProfilePath: profilePolicy.externalProfilePath, + getYomitanParserWindow: () => input.getYomitanParserWindow(), + setYomitanParserWindow: (window) => input.setYomitanParserWindow(window), + setYomitanParserReadyPromise: (promise) => input.setYomitanParserReadyPromise(promise), + setYomitanParserInitPromise: (promise) => input.setYomitanParserInitPromise(promise), + setYomitanExtension: (extension) => input.setYomitanExtension(extension), + setYomitanSession: (session) => input.setYomitanSession(session), + getYomitanExtension: () => input.getYomitanExtension(), + getLoadInFlight: () => input.getLoadInFlight(), + setLoadInFlight: (promise) => input.setLoadInFlight(promise), + }); + + const settingsRuntime = createYomitanSettingsRuntime({ + ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), + openYomitanSettingsWindow: (params) => + input.openYomitanSettingsWindow({ + yomitanExt: params.yomitanExt as Extension | null, + getExistingWindow: () => params.getExistingWindow() as BrowserWindow | null, + setWindow: (window) => params.setWindow(window), + yomitanSession: (params.yomitanSession as Session | null | undefined) ?? null, + onWindowClosed: () => { + input.setSettingsWindow(null); + params.onWindowClosed?.(); + }, + }), + getExistingWindow: () => input.getExistingSettingsWindow(), + setWindow: (window) => input.setSettingsWindow(window as BrowserWindow | null), + getYomitanSession: () => input.getYomitanSession(), + logWarn: (message) => input.logWarn(message), + logError: (message, error) => input.logError(message, error), + }); + + let lastSyncedYomitanAnkiServer: string | null = null; + + const getParserRuntimeDeps = (): YomitanParserRuntimeDeps => ({ + getYomitanExt: () => input.getYomitanExtension(), + getYomitanSession: () => input.getYomitanSession(), + getYomitanParserWindow: () => input.getYomitanParserWindow(), + setYomitanParserWindow: (window) => input.setYomitanParserWindow(window), + getYomitanParserReadyPromise: () => input.getYomitanParserReadyPromise(), + setYomitanParserReadyPromise: (promise) => input.setYomitanParserReadyPromise(promise), + getYomitanParserInitPromise: () => input.getYomitanParserInitPromise(), + setYomitanParserInitPromise: (promise) => input.setYomitanParserInitPromise(promise), + }); + + const syncDefaultProfileAnkiServer = async (): Promise => { + if (profilePolicy.isExternalReadOnlyMode()) { + return; + } + + const targetUrl = getPreferredYomitanAnkiServerUrl( + input.getResolvedConfig().ankiConnect, + ).trim(); + if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) { + return; + } + + const synced = await syncYomitanDefaultAnkiServer( + targetUrl, + getParserRuntimeDeps(), + { + error: (message, ...args) => { + input.logError(message, args[0]); + }, + info: (message, ...args) => { + input.logInfo([message, ...args].join(' ')); + }, + }, + { + forceOverride: shouldForceOverrideYomitanAnkiServer(input.getResolvedConfig().ankiConnect), + }, + ); + + if (synced) { + lastSyncedYomitanAnkiServer = targetUrl; + } + }; + + const loadYomitanExtension = async (): Promise => { + const extension = await extensionRuntime.loadYomitanExtension(); + if (extension && !profilePolicy.isExternalReadOnlyMode()) { + await syncDefaultProfileAnkiServer(); + } + return extension; + }; + + const ensureYomitanExtensionLoaded = async (): Promise => { + const extension = await extensionRuntime.ensureYomitanExtensionLoaded(); + if (extension && !profilePolicy.isExternalReadOnlyMode()) { + await syncDefaultProfileAnkiServer(); + } + return extension; + }; + + const openYomitanSettings = (): boolean => { + if (profilePolicy.isExternalReadOnlyMode()) { + const message = 'Yomitan settings unavailable while using read-only external-profile mode.'; + input.logWarn( + 'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.', + ); + input.showDesktopNotification('SubMiner', { body: message }); + input.showMpvOsd(message); + return false; + } + + settingsRuntime.openYomitanSettings(); + return true; + }; + + return { + loadYomitanExtension, + ensureYomitanExtensionLoaded, + openYomitanSettings, + syncDefaultProfileAnkiServer, + getParserRuntimeDeps, + getPreferredAnkiServerUrl: () => + getPreferredYomitanAnkiServerUrl(input.getResolvedConfig().ankiConnect), + isExternalReadOnlyMode: () => profilePolicy.isExternalReadOnlyMode(), + isCharacterDictionaryEnabled: () => profilePolicy.isCharacterDictionaryEnabled(), + getCharacterDictionaryDisabledReason: () => + profilePolicy.getCharacterDictionaryDisabledReason(), + clearParserCachesForWindow: (window) => clearYomitanParserCachesForWindow(window), + }; +} diff --git a/src/main/youtube-runtime-bootstrap.ts b/src/main/youtube-runtime-bootstrap.ts new file mode 100644 index 00000000..5d61140e --- /dev/null +++ b/src/main/youtube-runtime-bootstrap.ts @@ -0,0 +1,346 @@ +import os from 'node:os'; +import path from 'node:path'; + +import type { OverlayHostedModal } from '../shared/ipc/contracts'; +import type { WindowGeometry } from '../types'; +import type { YoutubeRuntimeInput } from './youtube-runtime'; +import { createWaitForMpvConnectedHandler } from './runtime/jellyfin-remote-connection'; +import { createPrepareYoutubePlaybackInMpvHandler } from './runtime/youtube-playback-launch'; +import { openYoutubeTrackPicker } from './runtime/youtube-picker-open'; +import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './runtime/windows-mpv-launch'; + +type MpvClientLike = { + connected: boolean; + currentVideoPath?: string | null; + connect: () => void; + requestProperty: (name: string) => Promise; + send: (payload: { command: Array }) => void; +}; + +type AnkiIntegrationLike = { + waitUntilReady: () => Promise; +}; + +type WindowTrackerLike = { + getGeometry: () => WindowGeometry | null; + isTargetWindowFocused: () => boolean; + isTracking: () => boolean; +}; + +type OverlayMainWindowLike = { + isDestroyed: () => boolean; + isFocused: () => boolean; + focus: () => void; + setIgnoreMouseEvents: (ignore: boolean) => void; + webContents: { + isFocused: () => boolean; + focus: () => void; + }; +}; + +type OverlayUiLike = { + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + handleOverlayModalClosed: (modal: OverlayHostedModal) => void; +}; + +type OverlayGeometryLike = { + geometryMatches: (left: WindowGeometry | null, right: WindowGeometry | null) => boolean; + getLastOverlayWindowGeometry: () => WindowGeometry | null; +}; + +type SubtitleRuntimeLike = { + refreshCurrentSubtitle: (text: string) => void; + refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise; +}; + +type TokenizationGateLike = { + waitUntilReady: (mediaPath: string | null) => Promise; +}; + +export interface YoutubeRuntimeBootstrapInput { + appState: { + getMpvClient: () => MpvClientLike | null; + getCurrentMediaPath: () => string | null; + getPlaybackPaused: () => boolean | null; + getWindowTracker: () => WindowTrackerLike | null; + getAnkiIntegration: () => AnkiIntegrationLike | null; + }; + overlay: { + getOverlayUi: () => OverlayUiLike | null; + getMainWindow: () => OverlayMainWindowLike | null; + getOverlayGeometry: () => OverlayGeometryLike; + broadcastYoutubePickerCancel: () => void; + }; + getSubtitle: () => SubtitleRuntimeLike; + tokenization: { + startTokenizationWarmups: () => Promise; + getGate: () => TokenizationGateLike; + }; + appReady: { + ensureYoutubePlaybackRuntimeReady: () => Promise; + }; + services: { + probeYoutubeTracks: YoutubeRuntimeInput['flow']['probeYoutubeTracks']; + acquireYoutubeSubtitleTrack: YoutubeRuntimeInput['flow']['acquireYoutubeSubtitleTrack']; + acquireYoutubeSubtitleTracks: YoutubeRuntimeInput['flow']['acquireYoutubeSubtitleTracks']; + resolveYoutubePlaybackUrl: YoutubeRuntimeInput['playback']['resolveYoutubePlaybackUrl']; + sendMpvCommand: YoutubeRuntimeInput['flow']['sendMpvCommand']; + showMpvOsd: YoutubeRuntimeInput['showMpvOsd']; + showDesktopNotification: YoutubeRuntimeInput['showDesktopNotification']; + showErrorBox: (title: string, content: string) => void; + logInfo: (message: string) => void; + logWarn: (message: string, error?: unknown) => void; + logDebug: (message: string) => void; + }; + config: { + platform: NodeJS.Platform; + directPlaybackFormat: string; + mpvYtdlFormat: string; + autoLaunchTimeoutMs: number; + connectTimeoutMs: number; + logPath: string; + getSocketPath: () => string; + getNotificationType: () => YoutubeRuntimeInput['getNotificationType'] extends () => infer T + ? T + : string; + getPrimarySubtitleLanguages: () => string[]; + }; +} + +export function createYoutubeRuntimeInput( + input: YoutubeRuntimeBootstrapInput, +): YoutubeRuntimeInput { + const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({ + requestPath: async () => { + const client = input.appState.getMpvClient(); + if (!client) return null; + const value = await client.requestProperty('path').catch(() => null); + return typeof value === 'string' ? value : null; + }, + requestProperty: async (name) => { + const client = input.appState.getMpvClient(); + if (!client) return null; + return await client.requestProperty(name); + }, + sendMpvCommand: (command) => { + input.services.sendMpvCommand(command); + }, + wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + }); + + const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({ + getMpvClient: () => input.appState.getMpvClient(), + now: () => Date.now(), + sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), + }); + + return { + flow: { + probeYoutubeTracks: (url) => input.services.probeYoutubeTracks(url), + acquireYoutubeSubtitleTrack: (request) => input.services.acquireYoutubeSubtitleTrack(request), + acquireYoutubeSubtitleTracks: (request) => + input.services.acquireYoutubeSubtitleTracks(request), + openPicker: async (payload) => + await openYoutubeTrackPicker( + { + sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) => + input.overlay + .getOverlayUi() + ?.sendToActiveOverlayWindow(channel, nextPayload, runtimeOptions) ?? false, + waitForModalOpen: (modal, timeoutMs) => + input.overlay.getOverlayUi()?.waitForModalOpen(modal, timeoutMs) ?? + Promise.resolve(false), + logWarn: (message) => input.services.logWarn(message), + }, + payload, + ), + pauseMpv: () => { + input.services.sendMpvCommand(['set_property', 'pause', 'yes']); + }, + resumeMpv: () => { + input.services.sendMpvCommand(['set_property', 'pause', 'no']); + }, + sendMpvCommand: (command) => { + input.services.sendMpvCommand(command); + }, + requestMpvProperty: async (name) => { + const client = input.appState.getMpvClient(); + if (!client) return null; + return await client.requestProperty(name); + }, + refreshCurrentSubtitle: (text) => { + input.getSubtitle().refreshCurrentSubtitle(text); + }, + refreshSubtitleSidebarSource: async (sourcePath) => { + await input.getSubtitle().refreshSubtitleSidebarFromSource(sourcePath); + }, + startTokenizationWarmups: async () => { + await input.tokenization.startTokenizationWarmups(); + }, + waitForTokenizationReady: async () => { + const currentMediaPath = + input.appState.getCurrentMediaPath()?.trim() || + input.appState.getMpvClient()?.currentVideoPath?.trim() || + null; + await input.tokenization.getGate().waitUntilReady(currentMediaPath); + }, + waitForAnkiReady: async () => { + const integration = input.appState.getAnkiIntegration(); + if (!integration) { + return; + } + try { + await Promise.race([ + integration.waitUntilReady(), + new Promise((_, reject) => { + setTimeout( + () => reject(new Error('Timed out waiting for AnkiConnect integration')), + 2500, + ); + }), + ]); + } catch (error) { + input.services.logWarn( + 'Continuing YouTube playback before AnkiConnect integration reported ready:', + error instanceof Error ? error.message : String(error), + ); + } + }, + wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + waitForPlaybackWindowReady: async () => { + const deadline = Date.now() + 4000; + let stableGeometry: WindowGeometry | null = null; + let stableSinceMs = 0; + while (Date.now() < deadline) { + const tracker = input.appState.getWindowTracker(); + const trackerGeometry = tracker?.getGeometry() ?? null; + const mediaPath = + input.appState.getCurrentMediaPath()?.trim() || + input.appState.getMpvClient()?.currentVideoPath?.trim() || + ''; + const trackerFocused = tracker?.isTargetWindowFocused() ?? false; + if (tracker && tracker.isTracking() && trackerGeometry && trackerFocused && mediaPath) { + if ( + !input.overlay.getOverlayGeometry().geometryMatches(stableGeometry, trackerGeometry) + ) { + stableGeometry = trackerGeometry; + stableSinceMs = Date.now(); + } else if (Date.now() - stableSinceMs >= 200) { + return; + } + } else { + stableGeometry = null; + stableSinceMs = 0; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + input.services.logWarn( + 'Timed out waiting for tracked playback window focus/media readiness before opening YouTube subtitle picker.', + ); + }, + waitForOverlayGeometryReady: async () => { + const deadline = Date.now() + 4000; + while (Date.now() < deadline) { + const trackerGeometry = input.appState.getWindowTracker()?.getGeometry() ?? null; + if ( + trackerGeometry && + input.overlay + .getOverlayGeometry() + .geometryMatches( + input.overlay.getOverlayGeometry().getLastOverlayWindowGeometry(), + trackerGeometry, + ) + ) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + input.services.logWarn( + 'Timed out waiting for overlay geometry to match tracked playback window.', + ); + }, + focusOverlayWindow: () => { + const mainWindow = input.overlay.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + mainWindow.setIgnoreMouseEvents(false); + if (!mainWindow.isFocused()) { + mainWindow.focus(); + } + if (!mainWindow.webContents.isFocused()) { + mainWindow.webContents.focus(); + } + }, + showMpvOsd: (text) => input.services.showMpvOsd(text), + warn: (message) => input.services.logWarn(message), + log: (message) => input.services.logInfo(message), + getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'), + }, + playback: { + platform: input.config.platform, + directPlaybackFormat: input.config.directPlaybackFormat, + mpvYtdlFormat: input.config.mpvYtdlFormat, + autoLaunchTimeoutMs: input.config.autoLaunchTimeoutMs, + connectTimeoutMs: input.config.connectTimeoutMs, + getSocketPath: () => input.config.getSocketPath(), + getMpvConnected: () => Boolean(input.appState.getMpvClient()?.connected), + ensureYoutubePlaybackRuntimeReady: async () => { + await input.appReady.ensureYoutubePlaybackRuntimeReady(); + }, + resolveYoutubePlaybackUrl: (url, format) => + input.services.resolveYoutubePlaybackUrl(url, format), + launchWindowsMpv: (playbackUrl, args) => + launchWindowsMpv( + [playbackUrl], + createWindowsMpvLaunchDeps({ + showError: (title, content) => input.services.showErrorBox(title, content), + }), + [...args, `--log-file=${input.config.logPath}`], + ), + waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs), + prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), + logInfo: (message) => input.services.logInfo(message), + logWarn: (message) => input.services.logWarn(message), + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + clearScheduled: (timer) => clearTimeout(timer), + }, + autoplay: { + getCurrentMediaPath: () => input.appState.getCurrentMediaPath(), + getCurrentVideoPath: () => input.appState.getMpvClient()?.currentVideoPath ?? null, + getPlaybackPaused: () => input.appState.getPlaybackPaused(), + getMpvClient: () => input.appState.getMpvClient(), + signalPluginAutoplayReady: () => { + input.services.sendMpvCommand(['script-message', 'subminer-autoplay-ready']); + }, + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + logDebug: (message) => input.services.logDebug(message), + }, + notification: { + getPrimarySubtitleLanguages: () => input.config.getPrimarySubtitleLanguages(), + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + clearSchedule: (timer) => clearTimeout(timer as ReturnType), + }, + getNotificationType: () => input.config.getNotificationType(), + getCurrentMediaPath: () => input.appState.getCurrentMediaPath(), + getCurrentVideoPath: () => input.appState.getMpvClient()?.currentVideoPath ?? null, + showMpvOsd: (message) => input.services.showMpvOsd(message), + showDesktopNotification: (title, options) => + input.services.showDesktopNotification(title, options), + broadcastYoutubePickerCancel: () => { + input.overlay.broadcastYoutubePickerCancel(); + }, + closeYoutubePickerModal: () => { + input.overlay.getOverlayUi()?.handleOverlayModalClosed('youtube-track-picker'); + }, + logWarn: (message) => input.services.logWarn(message), + }; +} diff --git a/src/main/youtube-runtime-coordinator.ts b/src/main/youtube-runtime-coordinator.ts new file mode 100644 index 00000000..86deca20 --- /dev/null +++ b/src/main/youtube-runtime-coordinator.ts @@ -0,0 +1,238 @@ +import { IPC_CHANNELS } from '../shared/ipc/contracts'; +import { + acquireYoutubeSubtitleTrack, + acquireYoutubeSubtitleTracks, +} from '../core/services/youtube/generate'; +import { resolveYoutubePlaybackUrl } from '../core/services/youtube/playback-resolve'; +import { probeYoutubeTracks } from '../core/services/youtube/track-probe'; +import type { ResolvedConfig } from '../types'; +import type { AppState } from './state'; +import type { OverlayGeometryRuntime } from './overlay-geometry-runtime'; +import type { OverlayUiRuntime } from './overlay-ui-runtime'; +import type { SubtitleRuntime } from './subtitle-runtime'; +import { createYoutubeRuntime } from './youtube-runtime'; +import { createYoutubeRuntimeInput } from './youtube-runtime-bootstrap'; + +export interface YoutubeRuntimeCoordinatorInput { + appState: { + getMpvClient: () => Parameters< + typeof createYoutubeRuntimeInput + >[0]['appState']['getMpvClient'] extends () => infer T + ? T + : never; + getCurrentMediaPath: () => string | null; + getPlaybackPaused: () => boolean | null; + getWindowTracker: () => Parameters< + typeof createYoutubeRuntimeInput + >[0]['appState']['getWindowTracker'] extends () => infer T + ? T + : never; + getAnkiIntegration: () => Parameters< + typeof createYoutubeRuntimeInput + >[0]['appState']['getAnkiIntegration'] extends () => infer T + ? T + : never; + getSocketPath: () => string; + }; + overlay: { + getOverlayUi: () => Parameters< + typeof createYoutubeRuntimeInput + >[0]['overlay']['getOverlayUi'] extends () => infer T + ? T + : never; + getMainWindow: () => Parameters< + typeof createYoutubeRuntimeInput + >[0]['overlay']['getMainWindow'] extends () => infer T + ? T + : never; + getOverlayGeometry: () => Parameters< + typeof createYoutubeRuntimeInput + >[0]['overlay']['getOverlayGeometry'] extends () => infer T + ? T + : never; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + }; + subtitle: { + getSubtitle: Parameters[0]['getSubtitle']; + }; + tokenization: { + startTokenizationWarmups: () => Promise; + getGate: Parameters[0]['tokenization']['getGate']; + }; + appReady: { + ensureYoutubePlaybackRuntimeReady: () => Promise; + }; + services: { + sendMpvCommand: (command: (string | number)[]) => void; + showMpvOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body?: string }) => void; + showErrorBox: (title: string, content: string) => void; + logInfo: (message: string) => void; + logWarn: (message: string, error?: unknown) => void; + logDebug: (message: string) => void; + }; + config: { + platform: NodeJS.Platform; + directPlaybackFormat: string; + mpvYtdlFormat: string; + autoLaunchTimeoutMs: number; + connectTimeoutMs: number; + logPath: string; + getNotificationType: () => string; + getPrimarySubtitleLanguages: () => string[]; + }; +} + +export function createYoutubeRuntimeCoordinator(input: YoutubeRuntimeCoordinatorInput) { + return createYoutubeRuntime( + createYoutubeRuntimeInput({ + appState: { + getMpvClient: () => input.appState.getMpvClient(), + getCurrentMediaPath: () => input.appState.getCurrentMediaPath(), + getPlaybackPaused: () => input.appState.getPlaybackPaused(), + getWindowTracker: () => input.appState.getWindowTracker(), + getAnkiIntegration: () => input.appState.getAnkiIntegration(), + }, + overlay: { + getOverlayUi: () => input.overlay.getOverlayUi(), + getMainWindow: () => input.overlay.getMainWindow(), + getOverlayGeometry: () => input.overlay.getOverlayGeometry(), + broadcastYoutubePickerCancel: () => { + input.overlay.broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null); + }, + }, + getSubtitle: () => input.subtitle.getSubtitle(), + tokenization: { + startTokenizationWarmups: () => input.tokenization.startTokenizationWarmups(), + getGate: () => input.tokenization.getGate(), + }, + appReady: { + ensureYoutubePlaybackRuntimeReady: () => input.appReady.ensureYoutubePlaybackRuntimeReady(), + }, + services: { + probeYoutubeTracks: (url) => probeYoutubeTracks(url), + acquireYoutubeSubtitleTrack: (request) => acquireYoutubeSubtitleTrack(request), + acquireYoutubeSubtitleTracks: (request) => acquireYoutubeSubtitleTracks(request), + resolveYoutubePlaybackUrl: (url, format) => resolveYoutubePlaybackUrl(url, format), + sendMpvCommand: (command) => input.services.sendMpvCommand(command), + showMpvOsd: (message) => input.services.showMpvOsd(message), + showDesktopNotification: (title, options) => + input.services.showDesktopNotification(title, options), + showErrorBox: (title, content) => input.services.showErrorBox(title, content), + logInfo: (message) => input.services.logInfo(message), + logWarn: (message, error) => input.services.logWarn(message, error), + logDebug: (message) => input.services.logDebug(message), + }, + config: { + platform: input.config.platform, + directPlaybackFormat: input.config.directPlaybackFormat, + mpvYtdlFormat: input.config.mpvYtdlFormat, + autoLaunchTimeoutMs: input.config.autoLaunchTimeoutMs, + connectTimeoutMs: input.config.connectTimeoutMs, + logPath: input.config.logPath, + getSocketPath: () => input.appState.getSocketPath(), + getNotificationType: () => input.config.getNotificationType(), + getPrimarySubtitleLanguages: () => input.config.getPrimarySubtitleLanguages(), + }, + }), + ); +} + +export interface YoutubeRuntimeFromMainStateInput { + platform: NodeJS.Platform; + directPlaybackFormat: string; + mpvYtdlFormat: string; + autoLaunchTimeoutMs: number; + connectTimeoutMs: number; + logPath: string; + appState: Pick< + AppState, + | 'mpvClient' + | 'currentMediaPath' + | 'playbackPaused' + | 'windowTracker' + | 'ankiIntegration' + | 'mpvSocketPath' + >; + overlay: { + getOverlayUi: () => OverlayUiRuntime | null; + getMainWindow: () => Electron.BrowserWindow | null; + getOverlayGeometry: () => OverlayGeometryRuntime; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + }; + subtitle: { + getSubtitle: () => SubtitleRuntime; + }; + tokenization: { + startTokenizationWarmups: () => Promise; + getGate: Parameters[0]['tokenization']['getGate']; + }; + appReady: { + ensureYoutubePlaybackRuntimeReady: () => Promise; + }; + getResolvedConfig: () => ResolvedConfig; + notifications: { + showDesktopNotification: (title: string, options: { body?: string }) => void; + showErrorBox: (title: string, content: string) => void; + }; + mpv: { + sendMpvCommand: (command: (string | number)[]) => void; + showMpvOsd: (message: string) => void; + }; + logger: { + info: (message: string) => void; + warn: (message: string, error?: unknown) => void; + debug: (message: string) => void; + }; +} + +export function createYoutubeRuntimeFromMainState(input: YoutubeRuntimeFromMainStateInput) { + return createYoutubeRuntimeCoordinator({ + appState: { + getMpvClient: () => input.appState.mpvClient, + getCurrentMediaPath: () => input.appState.currentMediaPath, + getPlaybackPaused: () => input.appState.playbackPaused, + getWindowTracker: () => input.appState.windowTracker, + getAnkiIntegration: () => input.appState.ankiIntegration, + getSocketPath: () => input.appState.mpvSocketPath, + }, + overlay: { + getOverlayUi: () => input.overlay.getOverlayUi(), + getMainWindow: () => input.overlay.getMainWindow(), + getOverlayGeometry: () => input.overlay.getOverlayGeometry(), + broadcastToOverlayWindows: (channel, payload) => { + input.overlay.broadcastToOverlayWindows(channel, payload); + }, + }, + subtitle: { + getSubtitle: () => input.subtitle.getSubtitle(), + }, + tokenization: { + startTokenizationWarmups: () => input.tokenization.startTokenizationWarmups(), + getGate: () => input.tokenization.getGate(), + }, + appReady: { + ensureYoutubePlaybackRuntimeReady: () => input.appReady.ensureYoutubePlaybackRuntimeReady(), + }, + services: { + sendMpvCommand: (command) => input.mpv.sendMpvCommand(command), + showMpvOsd: (message) => input.mpv.showMpvOsd(message), + showDesktopNotification: (title, options) => + input.notifications.showDesktopNotification(title, options), + showErrorBox: (title, content) => input.notifications.showErrorBox(title, content), + logInfo: (message) => input.logger.info(message), + logWarn: (message, error) => input.logger.warn(message, error), + logDebug: (message) => input.logger.debug(message), + }, + config: { + platform: input.platform, + directPlaybackFormat: input.directPlaybackFormat, + mpvYtdlFormat: input.mpvYtdlFormat, + autoLaunchTimeoutMs: input.autoLaunchTimeoutMs, + connectTimeoutMs: input.connectTimeoutMs, + logPath: input.logPath, + getNotificationType: () => input.getResolvedConfig().ankiConnect.behavior.notificationType, + getPrimarySubtitleLanguages: () => input.getResolvedConfig().youtube.primarySubLanguages, + }, + }); +} diff --git a/src/main/youtube-runtime.test.ts b/src/main/youtube-runtime.test.ts new file mode 100644 index 00000000..e9d01825 --- /dev/null +++ b/src/main/youtube-runtime.test.ts @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createYoutubeRuntime } from './youtube-runtime'; + +function createRuntime(overrides: Partial[0]> = {}) { + const calls: string[] = []; + + const runtime = createYoutubeRuntime({ + flow: { + probeYoutubeTracks: async () => ({ videoId: 'demo', title: 'Demo', tracks: [] }), + acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/primary.vtt' }), + acquireYoutubeSubtitleTracks: async () => new Map(), + openPicker: async () => true, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: () => {}, + requestMpvProperty: async () => null, + refreshCurrentSubtitle: () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: (message) => { + calls.push(`flow-osd:${message}`); + }, + warn: (message) => { + calls.push(`warn:${message}`); + }, + log: (message) => { + calls.push(`log:${message}`); + }, + getYoutubeOutputDir: () => '/tmp', + }, + playback: { + platform: 'linux', + directPlaybackFormat: 'b', + mpvYtdlFormat: 'best', + autoLaunchTimeoutMs: 1000, + connectTimeoutMs: 1000, + getSocketPath: () => '/tmp/mpv.sock', + getMpvConnected: () => true, + ensureYoutubePlaybackRuntimeReady: async () => {}, + resolveYoutubePlaybackUrl: async (url) => url, + launchWindowsMpv: () => ({ ok: false }), + waitForYoutubeMpvConnected: async () => true, + prepareYoutubePlaybackInMpv: async () => true, + logInfo: () => {}, + logWarn: () => {}, + schedule: (callback) => setTimeout(callback, 0), + clearScheduled: (timer) => clearTimeout(timer), + }, + autoplay: { + getCurrentMediaPath: () => null, + getCurrentVideoPath: () => null, + getPlaybackPaused: () => true, + getMpvClient: () => null, + signalPluginAutoplayReady: () => { + calls.push('autoplay-ready'); + }, + schedule: (callback) => setTimeout(callback, 0), + logDebug: () => {}, + }, + notification: { + getPrimarySubtitleLanguages: () => ['ja'], + schedule: (callback) => setTimeout(callback, 0), + clearSchedule: (timer) => clearTimeout(timer as ReturnType), + }, + getNotificationType: () => 'osd', + getCurrentMediaPath: () => null, + getCurrentVideoPath: () => null, + showMpvOsd: (message) => { + calls.push(`osd:${message}`); + }, + showDesktopNotification: (_title, options) => { + calls.push(`notify:${options.body}`); + }, + broadcastYoutubePickerCancel: () => { + calls.push('picker-cancel'); + }, + closeYoutubePickerModal: () => { + calls.push('close-modal'); + }, + logWarn: (message) => { + calls.push(`warn:${message}`); + }, + ...overrides, + }); + + return { + runtime, + calls, + }; +} + +test('youtube runtime gates manual picker availability by playback context', async () => { + const inactive = createRuntime({ + getCurrentMediaPath: () => '/tmp/video.mkv', + getCurrentVideoPath: () => null, + }); + + await inactive.runtime.openYoutubeTrackPickerFromPlayback(); + assert.ok( + inactive.calls.includes( + 'osd:YouTube subtitle picker is only available during YouTube playback.', + ), + ); + + const active = createRuntime({ + getCurrentMediaPath: () => 'https://www.youtube.com/watch?v=demo', + getCurrentVideoPath: () => null, + createFlowRuntime: () => ({ + runYoutubePlaybackFlow: async () => {}, + openManualPicker: async ({ url }) => { + active.calls.push(`manual-picker:${url}`); + }, + resolveActivePicker: async () => ({ ok: true, message: 'resolved' }), + cancelActivePicker: () => true, + hasActiveSession: () => false, + }), + }); + + await active.runtime.openYoutubeTrackPickerFromPlayback(); + assert.ok(active.calls.includes('manual-picker:https://www.youtube.com/watch?v=demo')); +}); + +test('youtube runtime cancels active picker on mpv disconnect', () => { + const harness = createRuntime({ + createFlowRuntime: () => ({ + runYoutubePlaybackFlow: async () => {}, + openManualPicker: async () => {}, + resolveActivePicker: async () => ({ ok: true, message: 'resolved' }), + cancelActivePicker: () => { + harness.calls.push('cancel-active'); + return true; + }, + hasActiveSession: () => true, + }), + }); + + harness.runtime.handleMpvConnectionChange(false); + + assert.deepEqual(harness.calls, ['cancel-active', 'picker-cancel', 'close-modal']); +}); + +test('youtube runtime delegates picker resolution to flow runtime', async () => { + const harness = createRuntime({ + createFlowRuntime: () => ({ + runYoutubePlaybackFlow: async () => {}, + openManualPicker: async () => {}, + resolveActivePicker: async (request) => ({ request, ok: true, message: 'resolved' }), + cancelActivePicker: () => true, + hasActiveSession: () => false, + }), + }); + + const request = { + sessionId: 'session-1', + action: 'use-selected' as const, + primaryTrackId: 'ja', + secondaryTrackId: null, + }; + const result = await harness.runtime.resolveActivePicker(request); + assert.deepEqual(result, { request, ok: true, message: 'resolved' }); +}); + +test('youtube runtime routes subtitle failures through configured notification channels', () => { + const harness = createRuntime({ + getNotificationType: () => 'both', + }); + + harness.runtime.reportYoutubeSubtitleFailure('Primary subtitles failed'); + + assert.ok(harness.calls.includes('osd:Primary subtitles failed')); + assert.ok(harness.calls.includes('notify:Primary subtitles failed')); +}); diff --git a/src/main/youtube-runtime.ts b/src/main/youtube-runtime.ts new file mode 100644 index 00000000..b399366b --- /dev/null +++ b/src/main/youtube-runtime.ts @@ -0,0 +1,304 @@ +import type { CliArgs, CliCommandSource } from '../cli/args'; +import type { + SubtitleData, + YoutubePickerOpenPayload, + YoutubePickerResolveRequest, + YoutubePickerResolveResult, +} from '../types'; +import type { + YoutubeTrackOption, + YoutubeTrackProbeResult, +} from '../core/services/youtube/track-probe'; +import { createAutoplayReadyGate } from './runtime/autoplay-ready-gate'; +import { createYoutubeFlowRuntime } from './runtime/youtube-flow'; +import { createYoutubePlaybackRuntime } from './runtime/youtube-playback-runtime'; +import { + clearYoutubePrimarySubtitleNotificationTimer, + createYoutubePrimarySubtitleNotificationRuntime, +} from './runtime/youtube-primary-subtitle-notification'; +import { isYoutubePlaybackActive } from './runtime/youtube-playback'; + +type YoutubeFlowRuntimeLike = { + runYoutubePlaybackFlow: (request: { + url: string; + mode: 'download' | 'generate'; + }) => Promise; + openManualPicker: (request: { url: string }) => Promise; + resolveActivePicker: ( + request: YoutubePickerResolveRequest, + ) => Promise; + cancelActivePicker: () => boolean; + hasActiveSession: () => boolean; +}; + +type YoutubePlaybackRuntimeLike = { + clearYoutubePlayQuitOnDisconnectArmTimer: () => void; + getQuitOnDisconnectArmed: () => boolean; + runYoutubePlaybackFlow: (request: { + url: string; + mode: NonNullable; + source: CliCommandSource; + }) => Promise; +}; + +type YoutubeAutoplayGateLike = { + getAutoPlayReadySignalMediaPath: () => string | null; + invalidatePendingAutoplayReadyFallbacks: () => void; + maybeSignalPluginAutoplayReady: ( + payload: SubtitleData, + options?: { forceWhilePaused?: boolean }, + ) => void; +}; + +type YoutubePrimarySubtitleNotificationRuntimeLike = { + handleMediaPathChange: (path: string | null) => void; + handleSubtitleTrackChange: (sid: number | null) => void; + handleSubtitleTrackListChange: (trackList: unknown[] | null) => void; + setAppOwnedFlowInFlight: (inFlight: boolean) => void; + isAppOwnedFlowInFlight: () => boolean; +}; + +export interface YoutubeFlowRuntimeInput { + probeYoutubeTracks: (url: string) => Promise; + acquireYoutubeSubtitleTrack: (input: { + targetUrl: string; + outputDir: string; + track: YoutubeTrackOption; + }) => Promise<{ path: string }>; + acquireYoutubeSubtitleTracks: (input: { + targetUrl: string; + outputDir: string; + tracks: YoutubeTrackOption[]; + }) => Promise>; + openPicker: (payload: YoutubePickerOpenPayload) => Promise; + pauseMpv: () => void; + resumeMpv: () => void; + sendMpvCommand: (command: Array) => void; + requestMpvProperty: (name: string) => Promise; + refreshCurrentSubtitle: (text: string) => void; + refreshSubtitleSidebarSource?: (sourcePath: string) => Promise; + startTokenizationWarmups: () => Promise; + waitForTokenizationReady: () => Promise; + waitForAnkiReady: () => Promise; + wait: (ms: number) => Promise; + waitForPlaybackWindowReady: () => Promise; + waitForOverlayGeometryReady: () => Promise; + focusOverlayWindow: () => void; + showMpvOsd: (text: string) => void; + warn: (message: string) => void; + log: (message: string) => void; + getYoutubeOutputDir: () => string; +} + +export interface YoutubePlaybackRuntimeInput { + platform: NodeJS.Platform; + directPlaybackFormat: string; + mpvYtdlFormat: string; + autoLaunchTimeoutMs: number; + connectTimeoutMs: number; + getSocketPath: () => string; + getMpvConnected: () => boolean; + ensureYoutubePlaybackRuntimeReady: () => Promise; + resolveYoutubePlaybackUrl: (url: string, format: string) => Promise; + launchWindowsMpv: ( + playbackUrl: string, + args: string[], + ) => { + ok: boolean; + mpvPath?: string; + }; + waitForYoutubeMpvConnected: (timeoutMs: number) => Promise; + prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise; + logInfo: (message: string) => void; + logWarn: (message: string) => void; + schedule: (callback: () => void, delayMs: number) => ReturnType; + clearScheduled: (timer: ReturnType) => void; +} + +export interface YoutubeAutoplayRuntimeInput { + getCurrentMediaPath: () => string | null; + getCurrentVideoPath: () => string | null; + getPlaybackPaused: () => boolean | null; + getMpvClient: () => { + connected?: boolean; + requestProperty: (property: string) => Promise; + send: (payload: { command: Array }) => void; + } | null; + signalPluginAutoplayReady: () => void; + schedule: (callback: () => void, delayMs: number) => ReturnType; + logDebug: (message: string) => void; +} + +export interface YoutubeNotificationRuntimeInput { + getPrimarySubtitleLanguages: () => string[]; + schedule: ( + callback: () => void, + delayMs: number, + ) => ReturnType | { id: number }; + clearSchedule: (timer: ReturnType | { id: number } | null) => void; +} + +export interface YoutubeRuntimeInput { + flow: YoutubeFlowRuntimeInput; + playback: YoutubePlaybackRuntimeInput; + autoplay: YoutubeAutoplayRuntimeInput; + notification: YoutubeNotificationRuntimeInput; + getNotificationType: () => 'osd' | 'system' | 'both' | string; + getCurrentMediaPath: () => string | null; + getCurrentVideoPath: () => string | null; + showMpvOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body: string }) => void; + broadcastYoutubePickerCancel: () => void; + closeYoutubePickerModal: () => void; + logWarn: (message: string) => void; + createFlowRuntime?: ( + input: YoutubeFlowRuntimeInput & { reportSubtitleFailure: (message: string) => void }, + ) => YoutubeFlowRuntimeLike; + createPlaybackRuntime?: ( + input: YoutubePlaybackRuntimeInput & { + invalidatePendingAutoplayReadyFallbacks: () => void; + setAppOwnedFlowInFlight: (next: boolean) => void; + runYoutubePlaybackFlow: (request: { + url: string; + mode: NonNullable; + }) => Promise; + }, + ) => YoutubePlaybackRuntimeLike; + createAutoplayGate?: ( + input: { + isAppOwnedFlowInFlight: () => boolean; + } & YoutubeAutoplayRuntimeInput, + ) => YoutubeAutoplayGateLike; + createPrimarySubtitleNotificationRuntime?: ( + input: { + notifyFailure: (message: string) => void; + } & YoutubeNotificationRuntimeInput, + ) => YoutubePrimarySubtitleNotificationRuntimeLike; +} + +export interface YoutubeRuntime { + runYoutubePlaybackFlow: (request: { + url: string; + mode: NonNullable; + source: CliCommandSource; + }) => Promise; + openYoutubeTrackPickerFromPlayback: () => Promise; + resolveActivePicker: ( + request: YoutubePickerResolveRequest, + ) => Promise; + handleMpvConnectionChange: (connected: boolean) => void; + reportYoutubeSubtitleFailure: (message: string) => void; + handleMediaPathChange: (path: string | null) => void; + handleSubtitleTrackChange: (sid: number | null) => void; + handleSubtitleTrackListChange: (trackList: unknown[] | null) => void; + maybeSignalPluginAutoplayReady: ( + payload: SubtitleData, + options?: { forceWhilePaused?: boolean }, + ) => void; + invalidatePendingAutoplayReadyFallbacks: () => void; + getAutoPlayReadySignalMediaPath: () => string | null; + clearYoutubePlayQuitOnDisconnectArmTimer: () => void; + getQuitOnDisconnectArmed: () => boolean; + isAppOwnedFlowInFlight: () => boolean; +} + +export function createYoutubeRuntime(input: YoutubeRuntimeInput): YoutubeRuntime { + const reportYoutubeSubtitleFailure = (message: string): void => { + const type = input.getNotificationType(); + if (type === 'osd' || type === 'both') { + input.showMpvOsd(message); + } + if (type === 'system' || type === 'both') { + try { + input.showDesktopNotification('SubMiner', { body: message }); + } catch { + input.logWarn(`Unable to show desktop notification: ${message}`); + } + } + }; + + const notificationRuntime = ( + input.createPrimarySubtitleNotificationRuntime ?? + ((deps) => createYoutubePrimarySubtitleNotificationRuntime(deps)) + )({ + ...input.notification, + notifyFailure: (message) => reportYoutubeSubtitleFailure(message), + }); + + const autoplayGate = (input.createAutoplayGate ?? ((deps) => createAutoplayReadyGate(deps)))({ + ...input.autoplay, + isAppOwnedFlowInFlight: () => notificationRuntime.isAppOwnedFlowInFlight(), + }); + + const flowRuntime = ( + input.createFlowRuntime ?? ((deps) => createYoutubeFlowRuntime(deps as never)) + )({ + ...input.flow, + reportSubtitleFailure: (message) => reportYoutubeSubtitleFailure(message), + }); + + const playbackRuntime = ( + input.createPlaybackRuntime ?? ((deps) => createYoutubePlaybackRuntime(deps)) + )({ + ...input.playback, + invalidatePendingAutoplayReadyFallbacks: () => + autoplayGate.invalidatePendingAutoplayReadyFallbacks(), + setAppOwnedFlowInFlight: (next) => { + notificationRuntime.setAppOwnedFlowInFlight(next); + }, + runYoutubePlaybackFlow: (request) => flowRuntime.runYoutubePlaybackFlow(request), + }); + + const isYoutubePlaybackActiveNow = (): boolean => + isYoutubePlaybackActive(input.getCurrentMediaPath(), input.getCurrentVideoPath()); + + const openYoutubeTrackPickerFromPlayback = async (): Promise => { + if (flowRuntime.hasActiveSession()) { + input.showMpvOsd('YouTube subtitle flow already in progress.'); + return; + } + + const currentMediaPath = + input.getCurrentMediaPath()?.trim() || input.getCurrentVideoPath()?.trim() || ''; + if (!isYoutubePlaybackActiveNow() || !currentMediaPath) { + input.showMpvOsd('YouTube subtitle picker is only available during YouTube playback.'); + return; + } + + await flowRuntime.openManualPicker({ + url: currentMediaPath, + }); + }; + + const handleMpvConnectionChange = (connected: boolean): void => { + if (connected || !flowRuntime.hasActiveSession()) { + return; + } + flowRuntime.cancelActivePicker(); + input.broadcastYoutubePickerCancel(); + input.closeYoutubePickerModal(); + }; + + return { + runYoutubePlaybackFlow: (request) => playbackRuntime.runYoutubePlaybackFlow(request), + openYoutubeTrackPickerFromPlayback, + resolveActivePicker: (request) => flowRuntime.resolveActivePicker(request), + handleMpvConnectionChange, + reportYoutubeSubtitleFailure, + handleMediaPathChange: (path) => notificationRuntime.handleMediaPathChange(path), + handleSubtitleTrackChange: (sid) => notificationRuntime.handleSubtitleTrackChange(sid), + handleSubtitleTrackListChange: (trackList) => + notificationRuntime.handleSubtitleTrackListChange(trackList), + maybeSignalPluginAutoplayReady: (payload, options) => + autoplayGate.maybeSignalPluginAutoplayReady(payload, options), + invalidatePendingAutoplayReadyFallbacks: () => + autoplayGate.invalidatePendingAutoplayReadyFallbacks(), + getAutoPlayReadySignalMediaPath: () => autoplayGate.getAutoPlayReadySignalMediaPath(), + clearYoutubePlayQuitOnDisconnectArmTimer: () => + playbackRuntime.clearYoutubePlayQuitOnDisconnectArmTimer(), + getQuitOnDisconnectArmed: () => playbackRuntime.getQuitOnDisconnectArmed(), + isAppOwnedFlowInFlight: () => notificationRuntime.isAppOwnedFlowInFlight(), + }; +} + +export { clearYoutubePrimarySubtitleNotificationTimer };