From 696462a82e12734190c7d0027b871799f5f2fc41 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 1 Nov 2026 17:59:52 -0800 Subject: [PATCH] refactor(main): move IPC, CLI and app-ready wiring into assemblies (#311) --- docs-site/architecture.md | 4 +- docs/architecture/domains.md | 19 +- docs/architecture/layering.md | 10 +- src/core/services/app-ready.test.ts | 4 +- src/core/services/startup.test.ts | 2 +- src/core/services/startup.ts | 5 +- src/main.ts | 2249 +++-------------- src/main/app-lifecycle.ts | 4 +- src/main/assemblies/anilist.ts | 33 + .../assemblies/character-dictionary.test.ts | 19 + src/main/assemblies/character-dictionary.ts | 108 +- src/main/assemblies/mining.test.ts | 119 + src/main/assemblies/mining.ts | 406 +++ src/main/assemblies/overlay.test.ts | 88 +- src/main/assemblies/overlay.ts | 143 +- src/main/assemblies/playback.test.ts | 56 +- src/main/assemblies/playback.ts | 209 +- src/main/assemblies/shortcuts.test.ts | 58 + src/main/assemblies/shortcuts.ts | 289 +++ src/main/assemblies/slices.ts | 19 + src/main/assemblies/startup.test.ts | 181 ++ src/main/assemblies/startup.ts | 518 ++++ src/main/assemblies/stats.test.ts | 145 ++ src/main/assemblies/stats.ts | 353 +++ src/main/assemblies/tray.test.ts | 98 + src/main/assemblies/tray.ts | 107 + src/main/assemblies/yomitan.test.ts | 121 + src/main/assemblies/yomitan.ts | 335 +++ src/main/boot/services.test.ts | 1 - src/main/boot/services.ts | 9 - src/main/main-wiring.test.ts | 99 - src/main/runtime/aniskip-runtime.ts | 2 +- src/main/runtime/registry.ts | 7 - 33 files changed, 3719 insertions(+), 2101 deletions(-) create mode 100644 src/main/assemblies/mining.test.ts create mode 100644 src/main/assemblies/mining.ts create mode 100644 src/main/assemblies/shortcuts.test.ts create mode 100644 src/main/assemblies/shortcuts.ts create mode 100644 src/main/assemblies/slices.ts create mode 100644 src/main/assemblies/startup.test.ts create mode 100644 src/main/assemblies/startup.ts create mode 100644 src/main/assemblies/stats.test.ts create mode 100644 src/main/assemblies/stats.ts create mode 100644 src/main/assemblies/tray.test.ts create mode 100644 src/main/assemblies/tray.ts create mode 100644 src/main/assemblies/yomitan.test.ts create mode 100644 src/main/assemblies/yomitan.ts delete mode 100644 src/main/runtime/registry.ts diff --git a/docs-site/architecture.md b/docs-site/architecture.md index 76c5e649..45ce327c 100644 --- a/docs-site/architecture.md +++ b/docs-site/architecture.md @@ -24,7 +24,7 @@ src/ preload*.ts # preload bridges (overlay, settings, stats, sync, Jellyfin setup) main/ # main-process runtime modules and IPC/CLI wiring boot/ # pre-ready boot helpers - assemblies/ # per-domain assemblies main.ts builds once (overlay, playback, first-run setup, YouTube, Jellyfin, AniList, settings, sync, dictionaries, overlay modals) + assemblies/ # per-domain assemblies main.ts builds once (startup, overlay, playback, mining, stats, Yomitan, shortcuts, tray, first-run setup, YouTube, Jellyfin, AniList, settings, sync, dictionaries, overlay modals) runtime/composers/ # larger runtime clusters assembled for main.ts runtime/domains/ # domain barrels (startup, overlay, mpv, ipc, shortcuts, anilist, jellyfin, mining) core/services/ # focused services: mpv client, overlay, tokenizer, mining, integrations, stats @@ -139,7 +139,7 @@ Runtime code uses dependency injection: 2. Build its runtime dependencies in a `src/main/` module. Pass simple dependencies inline; extract an adapter only when it adds behavior or gets reused. 3. Call the service from lifecycle or command wiring. -`main.ts` gets domain handlers through `createMainRuntimeRegistry()` (`src/main/runtime/registry.ts`), which exposes the barrels in `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. A domain whose runtimes share state, such as the overlay windows, mpv playback and subtitle state, YouTube, Jellyfin, AniList, config settings, sync, or the character dictionary, is grouped into one assembly in `src/main/assemblies/` that owns that state and returns its runtimes. Handlers take their dependencies as a plain object passed straight to the handler factory, which keeps side effects out of the unit under test. +Domain handlers live in the barrels under `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. A domain whose runtimes share state, such as app startup, the overlay windows, mpv playback and subtitle state, card mining, stats, YouTube, Jellyfin, AniList, config settings, sync, or the character dictionary, is grouped into one assembly in `src/main/assemblies/` that owns that state and returns its runtimes. An assembly that answers renderer IPC or CLI commands also returns typed `ipc`/`cli` slices, and `main.ts` registers the handlers by combining those slices. Handlers take their dependencies as a plain object passed straight to the handler factory, which keeps side effects out of the unit under test. Composers declare their inputs with `ComposerInputs` and results with `ComposerOutputs` from `src/main/runtime/composers/contracts.ts`. A missing dependency then fails at compile time. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index 04f44458..7611a9f2 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -3,7 +3,7 @@ # Domain Ownership Status: active -Last verified: 2026-07-15 +Last verified: 2026-10-31 Owner: Kyle Yasuda Read when: you need to find the owner module for a behavior or test surface @@ -20,18 +20,29 @@ Read when: you need to find the owner module for a behavior or test surface `src/config/resolve/anki-connect.ts` from focused resolvers in `src/config/resolve/anki-connect/` Config hot-reload and the settings window: `src/main/assemblies/settings.ts` +- App startup: app-ready sequence, headless known-word refresh, overlay startup prerequisites and + overlay runtime bootstrap: `src/main/assemblies/startup.ts` - Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts` - Overlay windows, visibility, and Linux fullscreen handling: `src/main/assemblies/overlay.ts` + Overlay windows, visibility, Linux fullscreen handling, and the overlay-window IPC handlers + (modal open/close, pointer interaction, content measurement): `src/main/assemblies/overlay.ts` Overlay-hosted modal openers: `src/main/assemblies/overlay-modals.ts` - MPV runtime and protocol: `src/core/services/mpv*.ts` - mpv event wiring, subtitle prefetch, and subtitle sidebar state: `src/main/assemblies/playback.ts` + mpv client creation, mpv event wiring, AniSkip, subtitle prefetch, and subtitle sidebar state: `src/main/assemblies/playback.ts` +- Keyboard input outside the renderer (overlay, global and numeric shortcuts, session bindings): + `src/main/assemblies/shortcuts.ts` +- System tray: `src/main/assemblies/tray.ts` +- Yomitan/Hachidori extension loading, parser runtime deps, and dictionary settings windows: + `src/main/assemblies/yomitan.ts` Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows. - Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/` JLPT and frequency annotation lookups: `src/main/jlpt-runtime.ts`, `src/main/frequency-dictionary-runtime.ts`, assembled by `src/main/assemblies/dictionaries.ts` - First-run setup: `src/main/runtime/first-run-setup-*.ts`, assembled by `src/main/assemblies/first-run-setup.ts` - Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts` + Card mining handlers, the pending lookup context, field grouping, and media timing review wiring: + `src/main/assemblies/mining.ts` - Media timing review: `src/main/runtime/media-timing-review.ts` owns the review and hidden `MediaTimingPreviewSession`. The session waits for mpv readiness and acknowledges playback commands; its `time-pos` observations reach the modal through `media-timing-review:preview-position`. The renderer places the cursor from those timestamps and uses an inactivity timeout instead of timing the clip itself. -- Immersion tracking: `src/core/services/immersion-tracker/` +- Immersion tracking: `src/core/services/immersion-tracker/`; tracker startup, the stats server, + the stats overlay window options, and the `--stats` command are assembled by `src/main/assemblies/stats.ts` Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata. Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions. `delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; the expensive work runs in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and incremental lifetime subtraction (`planLifetimeRemovals`/`applyLifetimeRemovals` in `lifetime.ts`). Merges, moves, AniList reassignments, and `stats cleanup -l` use `repairLifetimeSummariesFromMedia` (recompute from the per-video media ledger). The full lifetime rebuild survives only as the empty-table bootstrap; anywhere else it would collapse lifetime totals to the session retention window. diff --git a/docs/architecture/layering.md b/docs/architecture/layering.md index 8b1eeaf6..65d180ed 100644 --- a/docs/architecture/layering.md +++ b/docs/architecture/layering.md @@ -3,7 +3,7 @@ # Layering Rules Status: active -Last verified: 2026-05-23 +Last verified: 2026-10-31 Owner: Kyle Yasuda Read when: deciding whether a dependency direction is acceptable @@ -28,6 +28,14 @@ Renderer, launcher, plugin, and stats each keep their own local layering and sho object, and returns its runtimes; `main.ts` builds it once and calls through the result. Siblings built later in `main.ts` are passed as getters or callbacks, and construction must not call them. +- An assembly that answers renderer IPC or CLI commands returns them as typed slices + (`IpcSlice<...>` / `CliSlice<...>` from `src/main/assemblies/slices.ts`). `main.ts` + spreads the slices into the registration and checks the result with + `satisfies Required<...>`, so a missing handler fails typecheck. Only app-level handlers + with no owning assembly stay inline in `main.ts`. +- Electron modules that value-import named Electron APIs (`dialog`, `screen`, `shell`, + `clipboard`, `globalShortcut`, `Tray`, ...) cannot load under `bun test`. Pass those APIs + into an assembly as deps instead of importing them there. - Prefer injecting dependencies from `src/main/` instead of reaching outward from core services. - Keep side effects explicit and close to composition boundaries. - Put reusable business logic in focused services, not in top-level lifecycle files. diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index 320e8400..4a8c9ec4 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -54,7 +54,7 @@ function makeDeps(overrides: Partial = {}) { startBackgroundWarmups: () => { calls.push('startBackgroundWarmups'); }, - texthookerOnlyMode: false, + isTexthookerOnlyMode: () => false, shouldAutoInitializeOverlayRuntimeFromConfig: () => true, setVisibleOverlayVisible: (visible) => calls.push(`setVisibleOverlayVisible:${visible}`), initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'), @@ -180,7 +180,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns test('runAppReadyRuntime keeps websocket startup in texthooker-only mode but skips overlay window', async () => { const { deps, calls } = makeDeps({ - texthookerOnlyMode: true, + isTexthookerOnlyMode: () => true, reloadConfig: () => calls.push('reloadConfig'), handleInitialArgs: () => calls.push('handleInitialArgs'), }); diff --git a/src/core/services/startup.test.ts b/src/core/services/startup.test.ts index e08948a5..729c0976 100644 --- a/src/core/services/startup.test.ts +++ b/src/core/services/startup.test.ts @@ -44,7 +44,7 @@ function createAppReadyDeps(overrides: Partial = {}) { handleFirstRunSetup: recordAsync('first-run'), prewarmSubtitleDictionaries: recordAsync('prewarm'), startBackgroundWarmups: record('warmups'), - texthookerOnlyMode: false, + isTexthookerOnlyMode: () => false, shouldAutoInitializeOverlayRuntimeFromConfig: () => false, setVisibleOverlayVisible: record('visible-overlay'), initializeOverlayRuntime: record('init-overlay'), diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index 6ebffa49..a29e1db9 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -148,7 +148,8 @@ export interface AppReadyRuntimeDeps { handleFirstRunSetup: () => Promise; prewarmSubtitleDictionaries?: () => Promise; startBackgroundWarmups: () => void; - texthookerOnlyMode: boolean; + /** Read when app-ready reaches the overlay decision, after startup state is applied. */ + isTexthookerOnlyMode: () => boolean; shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; setVisibleOverlayVisible: (visible: boolean) => void; initializeOverlayRuntime: () => void; @@ -351,7 +352,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise. */ -import { generateSentenceFurigana } from './core/services/tokenizer/sentence-furigana'; import { app, BrowserWindow, @@ -24,8 +23,6 @@ import { ipcMain, shell, protocol, - Extension, - Session, Menu, nativeImage, Tray, @@ -33,25 +30,12 @@ import { screen, } from 'electron'; import { applyControllerConfigUpdate } from './main/controller-config-update.js'; -import { - createHachidoriExtensionRuntime, - getHachidoriSession, -} from './core/services/hachidori-extension'; -import { - DICTIONARY_EXTERNAL_LINK_CHANNEL, - parseDictionaryExternalUrl, -} from './shared/dictionary-external-link'; -import { readMpvInputBindings } from './main/runtime/mpv-input-bindings'; -import { createAniSkipRuntime } from './main/runtime/aniskip-runtime'; -import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata'; +import { createHachidoriExtensionRuntime } from './core/services/hachidori-extension'; import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js'; import { startAppControlServer } from './main/runtime/app-control-server'; -import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup'; import { getAppControlSocketPath } from './shared/app-control'; import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus'; import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff'; -import { resolveFreshPlaybackPaused } from './main/runtime/playback-paused-state'; -import { mergeAiConfig } from './ai/config'; import { getDefaultPasswordStore, getPasswordStoreArg, @@ -80,15 +64,10 @@ import type { ResolvedConfig, SessionActionDispatchRequest, SecondarySubMode, - SubtitleMiningContext, SubtitlePosition, - WindowGeometry, } from './types'; import { OPEN_ANKI_CARD_ACTION_ID } from './types'; -import { AnkiIntegration } from './anki-integration'; import { AnkiConnectClient } from './anki-connect'; -import { SubtitleTimingTracker } from './subtitle-timing-tracker'; -import { RuntimeOptionsManager } from './runtime-options'; import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils'; import { createLogger, @@ -113,12 +92,10 @@ import { type CliCommandSource, } from './cli/args'; import { printHelp } from './cli/help'; -import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts'; +import { IPC_CHANNELS } from './shared/ipc/contracts'; import { buildMpvLoggingArgs } from './shared/mpv-logging-args'; import { getStartupModeFlags, - shouldHandleInitialArgsBeforeDeferredOverlayWarmup, - shouldRefreshAnilistOnConfigReload, shouldStartAutomaticUpdateChecks, } from './main/runtime/startup-mode-flags'; import { buildConfigParseErrorDetails, failStartupFromConfig } from './main/config-validation'; @@ -129,22 +106,13 @@ import { import { shouldShowTexthookerTrayEntry } from './main/runtime/tray-main-actions'; import { createGetDefaultSocketPathHandler } from './main/runtime/domains/jellyfin'; import { - createTrayRuntimeHandlers, createGetRuntimeOptionsStateHandler, - createGetFieldGroupingResolverHandler, - createSetFieldGroupingResolverHandler, createLoadSubtitlePositionHandler, createSaveSubtitlePositionHandler, createAppendClipboardVideoToQueueHandler, - createHandleOverlayModalClosedHandler, - buildTrayMenuTemplateRuntime, - resolveTrayIconPathRuntime, - createYomitanExtensionRuntime, - createYomitanSettingsRuntime, resolveSubtitleStyleForRenderer, } from './main/runtime/domains/overlay'; import { - createImmersionMediaRuntime, createConfigDerivedRuntime, appendClipboardVideoToQueueRuntime, createMainSubsyncRuntime, @@ -154,76 +122,42 @@ import { createCycleSecondarySubModeRuntimeHandler, } from './main/runtime/domains/mpv'; import { resolvePrimarySubtitle } from './main/runtime/primary-subtitle-text'; -import { - createMarkLastCardAsAudioCardHandler, - createMineSentenceCardHandler, - createRefreshKnownWordCacheHandler, - createTriggerFieldGroupingHandler, - createUpdateLastCardFromClipboardHandler, - createCopyCurrentSubtitleHandler, - createHandleMineSentenceDigitHandler, - createHandleMultiCopyDigitHandler, -} from './main/runtime/domains/mining'; import { enforceUnsupportedWaylandMode, forceX11Backend, generateDefaultConfigFile, - resolveConfiguredShortcuts, - resolveKeybindings, showDesktopNotification, } from './core/utils'; -import { ensureDefaultConfigBootstrap, getDefaultConfigFilePaths } from './shared/setup-state'; import { - ImmersionTrackerService, - MpvIpcClient, SubtitleWebSocket, Texthooker, broadcastRuntimeOptionsChangedRuntime, - captureLiveSubtitleMiningContext, - copyCurrentSubtitle as copyCurrentSubtitleCore, createDiscordPresenceService, - createFieldGroupingOverlayRuntime, createOverlayContentMeasurementStore, createOverlayManager, createOverlayWindow as createOverlayWindowCore, cycleSecondarySubMode as cycleSecondarySubModeCore, - destroyYomitanSettingsWindow, - handleMineSentenceDigit as handleMineSentenceDigitCore, - handleMultiCopyDigit as handleMultiCopyDigitCore, hasMpvWebsocketPlugin, - initializeOverlayRuntime as initializeOverlayRuntimeCore, isOverlayWindowContentReady, loadSubtitlePosition as loadSubtitlePositionCore, - loadYomitanExtension as loadYomitanExtensionCore, - markLastCardAsAudioCard as markLastCardAsAudioCardCore, - mineSentenceCard as mineSentenceCardCore, - openYomitanSettingsWindow, playNextSubtitleRuntime, promoteSettingsWindowAboveOverlay, - registerGlobalShortcuts as registerGlobalShortcutsCore, replayCurrentSubtitleRuntime, toJellyfinOriginalStreamUrl, runStartupBootstrapRuntime, saveSubtitlePosition as saveSubtitlePositionCore, - clearYomitanParserCachesForWindow, - getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore, sendMpvCommandRuntime, setMpvSubVisibilityRuntime, setOverlayDebugVisualizationEnabledRuntime, - shiftSubtitleRangeToVideoClock, showMpvOsdRuntime, toSubtitleClock, - triggerFieldGrouping as triggerFieldGroupingCore, - updateLastCardFromClipboard as updateLastCardFromClipboardCore, } from './core/services'; -import { applyOverlayClickThrough } from './core/services/overlay-click-through'; import { destroyStatsWindow, registerStatsOverlayToggle, toggleStatsOverlay as toggleStatsOverlayWindow, withStatsWindowLayerSuspendedForNativeDialog, } from './core/services/stats-window.js'; -import { shouldAutoOpenFirstRunSetup } from './main/runtime/first-run-setup-service'; import { createCharacterDictionaryAssembly } from './main/assemblies/character-dictionary'; import { createDictionariesAssembly } from './main/assemblies/dictionaries'; import { createJellyfinAssembly } from './main/assemblies/jellyfin'; @@ -234,6 +168,14 @@ import { createYoutubeAssembly } from './main/assemblies/youtube'; import { createOverlayModalsAssembly } from './main/assemblies/overlay-modals'; import { createFirstRunSetupAssembly } from './main/assemblies/first-run-setup'; import { createOverlayAssembly } from './main/assemblies/overlay'; +import { createYomitanAssembly } from './main/assemblies/yomitan'; +import { createStatsAssembly } from './main/assemblies/stats'; +import { createMiningAssembly } from './main/assemblies/mining'; +import { createShortcutsAssembly } from './main/assemblies/shortcuts'; +import { createTrayAssembly } from './main/assemblies/tray'; +import { createStartupAssembly } from './main/assemblies/startup'; +import type { IpcHandlerDeps } from './main/assemblies/slices'; +import type { CliCommandContextMainDeps } from './main/runtime/cli-command-context-factory'; import { createPlaybackAssembly } from './main/assemblies/playback'; import { createAutoplayReadyGate, @@ -245,7 +187,6 @@ import { createWindowsMpvPluginDetectionRuntime } from './main/runtime/windows-m import { refreshManagedCommandLineLauncher } from './main/runtime/command-line-launcher'; import { applyLoginShellPath } from './main/runtime/login-shell-path'; import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; -import { findMpvExecutablePath } from './main/runtime/mpv-process'; import { isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime, toggleJellyfinDiscoveryFromTray as toggleJellyfinDiscoveryFromTrayRuntime, @@ -255,42 +196,24 @@ import { shouldQuitOnWindowAllClosedForTrayState, } from './main/runtime/startup-tray-policy'; import { createLogExportTrayRuntime } from './main/runtime/log-export-tray'; -import { - createImmersionTrackerStartupHandler, - type ImmersionTrackerStartupDeps, -} from './main/runtime/immersion-startup'; -import { - createRunStatsCliCommandHandler, - writeStatsCliCommandResponse, -} from './main/runtime/stats-cli-command'; -import { createStatsServerRuntime } from './main/runtime/stats-server-runtime'; import { createForceQuitHandler } from './main/runtime/app-lifecycle-actions'; -import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos'; import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; -import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher'; import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter'; -import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver'; -import { createTmdbClient, createTmdbApiKeyResolver } from './core/services/tmdb/tmdb-client'; -import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key'; 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 { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions'; -import { createMainRuntimeRegistry } from './main/runtime/registry'; import { createEnsureOverlayMpvSubtitlesHiddenHandler, createRestoreOverlayMpvSubtitlesHandler, } from './main/runtime/overlay-mpv-sub-visibility'; import { - composeAppReadyRuntime, composeCliStartupHandlers, composeHeadlessStartupHandlers, composeIpcRuntimeHandlers, composeOverlayVisibilityRuntime, - composeShortcutRuntimes, composeStartupLifecycleHandlers, } from './main/runtime/composers'; -import { tryBeginVisibleOverlayNumericSelection } from './main/runtime/overlay-numeric-selection'; import { createStartupBootstrapRuntimeDeps } from './main/startup'; import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle'; import { @@ -313,16 +236,7 @@ import { createMainBootServices, type MainBootServicesResult } from './main/boot import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; -import { MediaTimingPreviewSession } from './core/services/media-timing-preview'; import { getSharedRemoteMediaWindowCache } from './core/services/remote-media-window-cache'; -import { resolveMediaGenerationInput } from './anki-integration/media-source'; -import { generateSpeechWaveform } from './core/services/media-timing-waveform'; -import { createMediaTimingFrameExtractor } from './core/services/media-timing-frame'; -import { - collectMediaTimingContextLines, - createMediaTimingReviewRuntime, -} from './main/runtime/media-timing-review'; -import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open'; import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open'; import { createChangelogRuntime } from './main/runtime/changelog/changelog-runtime'; import { @@ -337,18 +251,9 @@ import { } from './main/runtime/plugin-panel-runtime'; import { createPluginWindowRuntime } from './main/runtime/plugin-window-runtime'; import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; -import { createSessionBindingsRuntime } from './main/runtime/session-bindings-runtime'; -import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createMediaRuntimeService } from './main/media-runtime'; import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime'; -import { - getCharacterDictionaryManagerSnapshot, - moveCharacterDictionaryManagedEntry, - removeCharacterDictionaryManagedEntry, - replaceCharacterDictionaryManagedEntry, -} from './main/runtime/character-dictionary-auto-sync'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; -import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot'; import { createOverlayGeometryRuntime } from './main/runtime/overlay-geometry-runtime'; import { createVisibleOverlayInteractionRuntime } from './main/runtime/visible-overlay-interaction-runtime'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; @@ -369,22 +274,11 @@ import { import { ensureLinuxRuntimePluginAssets } from './main/runtime/linux-runtime-plugin-assets'; import { createUpdateServiceRuntime } from './main/runtime/update/update-service-runtime'; import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order'; -import { shouldUseCachedYoutubeParsedCues } from './main/runtime/youtube-playback'; -import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; -import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload'; -import { createYomitanAnkiServerSyncRuntime } from './main/runtime/yomitan-anki-server-sync'; import { type StartupState, applyStartupState, createAppState } from './main/state'; -import { - ConfigService, - DEFAULT_CONFIG, - DEFAULT_KEYBINDINGS, - generateConfigTemplate, -} from './config'; +import { ConfigService, DEFAULT_CONFIG, generateConfigTemplate } from './config'; import { resolveConfigDir } from './config/path-resolution'; -import { parseSubtitleCues } from './core/services/subtitle-cue-parser'; import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch'; import { loadSubtitleSourceText } from './main/runtime/internal-subtitle-extraction'; -import { applyCharacterDictionarySelection } from './main/character-dictionary-selection'; if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); @@ -403,18 +297,16 @@ const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath('mpv', { homeDir: os.homedir(), appDataDir: process.env.APPDATA, }); -const TRAY_TOOLTIP = 'SubMiner'; const DISCORD_PRESENCE_APP_ID = '1475264834730856619'; -let yomitanLoadInFlight: Promise | null = null; let notifyAnilistTokenStoreWarning: (message: string) => void = () => {}; const isDev = process.argv.includes('--dev') || process.argv.includes('--debug'); const texthookerService = new Texthooker(() => { const config = configService.getConfig(); const characterDictionaryEnabled = - config.subtitleStyle.nameMatchEnabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(); + config.subtitleStyle.nameMatchEnabled && yomitan.profilePolicy.isCharacterDictionaryEnabled(); const knownWordColoringEnabled = getRuntimeBooleanOption( 'subtitle.annotation.knownWords.highlightEnabled', config.ankiConnect.knownWords.highlightEnabled, @@ -449,8 +341,6 @@ const texthookerService = new Texthooker(() => { }, }; }); -let syncOverlayShortcutsForModal: (isActive: boolean) => void = () => {}; -let syncOverlayVisibilityForModal: () => void = () => {}; const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler({ platform: process.platform, }); @@ -462,7 +352,6 @@ type BootServices = MainBootServicesResult< ReturnType, SubtitleWebSocket, ReturnType, - ReturnType, ReturnType, ReturnType, ReturnType, @@ -531,7 +420,6 @@ const bootServices = createMainBootServices({ }), createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode), createLogger, - createMainRuntimeRegistry, createOverlayManager: () => createOverlayManager({ shouldPromoteWindowOnBoundsUpdate: (window) => @@ -548,8 +436,11 @@ const bootServices = createMainBootServices({ warn: (message: string) => logger.warn(message), }); }, - getSyncOverlayShortcutsForModal: () => syncOverlayShortcutsForModal, - getSyncOverlayVisibilityForModal: () => syncOverlayVisibilityForModal, + // Read lazily from modal-state callbacks, which only fire once the assemblies below exist. + getSyncOverlayShortcutsForModal: () => (isActive) => + shortcuts.syncOverlayShortcutsForModal(isActive), + getSyncOverlayVisibilityForModal: () => () => + overlayVisibilityRuntime.updateVisibleOverlayVisibility(), createOverlayModalRuntime: ({ overlayManager, overlayModalInputState }) => { return createOverlayModalRuntimeService( { @@ -598,7 +489,6 @@ const { subtitleWsService, annotationSubtitleWsService, logger, - runtimeRegistry, overlayManager, overlayModalInputState, overlayContentMeasurementStore, @@ -608,31 +498,25 @@ const { } = bootServices; // Backend changes take effect on restart; windows and parser must share one session. const activeDictionaryBackend = configService.getConfig().dictionaryBackend; -const hachidoriExtensionRuntime = createHachidoriExtensionRuntime(USER_DATA_PATH); -let hachidoriSettingsWindow: BrowserWindow | null = null; -let inactiveYomitanExtension: Extension | null = null; -let inactiveYomitanSettingsWindow: BrowserWindow | null = null; -let inactiveYomitanLoad: Promise | null = null; -let pendingSubtitleMiningContext: SubtitleMiningContext | null = null; - -ipcMain.handle(DICTIONARY_EXTERNAL_LINK_CHANNEL, async (event, value: unknown) => { - if ( - activeDictionaryBackend !== 'hachidori' || - event.senderFrame !== event.sender.mainFrame || - !overlayManager.getOverlayWindows().some((window) => window.webContents === event.sender) - ) { - throw new Error('Dictionary links are only available from the active overlay'); - } - await shell.openExternal(parseDictionaryExternalUrl(value)); +const yomitan = createYomitanAssembly({ + appState, + getConfig: () => configService.getConfig(), + logger, + userDataPath: USER_DATA_PATH, + dictionaryBackend: activeDictionaryBackend, + hachidori: createHachidoriExtensionRuntime(USER_DATA_PATH), + getOverlayWindows: () => overlayManager.getOverlayWindows(), + getOverlayNotifications: () => overlayNotificationsRuntime, + ipcMain, + openExternal: (url) => shell.openExternal(url), }); +yomitan.registerDictionaryExternalLinkHandler(); function getOverlayForegroundSeparateWindows(): BrowserWindow[] { return [ appState.configSettingsWindow, appState.syncUiWindow, - appState.yomitanSettingsWindow, - hachidoriSettingsWindow, - inactiveYomitanSettingsWindow, + ...yomitan.getSettingsWindows(), appState.anilistSetupWindow, appState.jellyfinSetupWindow, appState.firstRunSetupWindow, @@ -682,60 +566,36 @@ const reportFatalError = createFatalErrorReporter({ }); let forceQuitTimer: ReturnType | null = null; -const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); -// Release builds stage a project TMDB key next to the compiled main process. -const bundledTmdbApiKey = readBundledTmdbApiKey(__dirname); -const statsPreloadPath = path.join(__dirname, 'preload-stats.js'); -const statsServerRuntime = createStatsServerRuntime({ +const anilistRateLimiter = createAnilistRateLimiter(); +const stats = createStatsAssembly({ + appState, + getConfig: () => configService.getConfig(), + logger, userDataPath: USER_DATA_PATH, - statsDistPath, - getResolvedConfig: () => configService.getConfig(), - getImmersionTracker: () => appState.immersionTracker, - setAppStateStatsServer: (server) => { - appState.statsServer = server; - }, - getMpvSocketPath: () => appState.mpvSocketPath, - getYomitanExt: () => appState.yomitanExt, - getYomitanSession: () => appState.yomitanSession, - getYomitanParserWindow: () => appState.yomitanParserWindow, - setYomitanParserWindow: (w) => { - appState.yomitanParserWindow = w; - }, - getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, - setYomitanParserReadyPromise: (p) => { - appState.yomitanParserReadyPromise = p; - }, - getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, - setYomitanParserInitPromise: (p) => { - appState.yomitanParserInitPromise = p; - }, - getYomitanAnkiDeckName: () => getCurrentYomitanAnkiDeckNameForRuntime(), - getAnilistRateLimiter: () => anilistRateLimiter, - getBundledTmdbApiKey: () => bundledTmdbApiKey, - resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, - trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => { - appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds); - }, - resolveSentenceSearchHeadwords: (term) => resolveSentenceSearchHeadwords(term), - ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(), - setStatsStartupInProgress: (inProgress) => { - appState.statsStartupInProgress = inProgress; + mainDirname: __dirname, + defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH, + anilistRateLimiter, + statsWindow: { + registerToggle: (options) => registerStatsOverlayToggle(options), + toggle: (options) => toggleStatsOverlayWindow(options), }, + yomitanParserRuntimeDeps: yomitan.getParserRuntimeDeps(), + getYomitanAnkiDeckName: () => yomitan.getCurrentAnkiDeckName(), + getTokenizeSubtitle: () => getTokenizeSubtitleDeferred(), + createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(), + getOverlayNotifications: () => overlayNotificationsRuntime, + getOverlayBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(), + onStatsOverlayVisibilityChanged: (visible) => + visibleOverlayInteractionRuntime.handleStatsOverlayVisibilityChanged(visible), + runAnilistPostWatchUpdate: () => anilist.maybeRunAnilistPostWatchUpdate({ force: true }), + openExternal: (url) => shell.openExternal(url), + requestAppQuit: () => requestAppQuit(), }); -const { - stopStatsServer, - ensureStatsServerStarted, - ensureBackgroundStatsServerStarted, - stopBackgroundStatsServer, -} = statsServerRuntime; function requestAppQuit(): void { - destroyYomitanSettingsWindow(hachidoriSettingsWindow); - destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow); - destroyYomitanSettingsWindow(appState.yomitanSettingsWindow); - appState.yomitanSettingsWindow = null; + yomitan.destroySettingsWindows(); destroyStatsWindow(); - void stopStatsServer().catch((error: unknown) => { + void stats.stopStatsServer().catch((error: unknown) => { logger.warn('Failed to stop stats server while quitting.', error); }); if (!forceQuitTimer) { @@ -790,7 +650,7 @@ const youtube = createYoutubeAssembly({ openSubtitleGenerationModal(overlayModals.createOverlayHostedModalOpenDeps()), invalidatePendingAutoplayReadyFallbacks: () => autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(), - ensurePlaybackRuntimeReady: () => ensureYoutubePlaybackRuntimeReady(), + ensurePlaybackRuntimeReady: () => startup.ensureYoutubePlaybackRuntimeReady(), launchWindowsMpv: (playbackUrl, args) => { const config = configService.getConfig(); setLogFileToggles(config.logging.files); @@ -981,21 +841,17 @@ const firstRunSetup = createFirstRunSetupAssembly({ configDir: CONFIG_DIR, userDataPath: USER_DATA_PATH, dictionaryBackend: activeDictionaryBackend, - ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), - getYomitanParserRuntimeDeps: () => getYomitanParserRuntimeDeps(), + ensureYomitanExtensionLoaded: () => yomitan.ensureExtensionLoaded(), + getYomitanParserRuntimeDeps: () => yomitan.getParserRuntimeDeps(), createCommandLineLauncherRuntimeOptions: () => createCommandLineLauncherRuntimeOptions(), getAppPath: (name) => app.getPath(name), trashItem: (targetPath) => shell.trashItem(targetPath), writeShortcutLink: (shortcutPath, operation, details) => shell.writeShortcutLink(shortcutPath, operation, details), createBrowserWindow: (options) => new BrowserWindow(options), - openDictionarySettings: () => openDictionarySettings(), + openDictionarySettings: () => yomitan.openDictionarySettings(), openConfigSettingsWindow: () => settings.window.openWindow(), - onSetupStateChanged: () => { - if (appTray) { - ensureTrayHandler(); - } - }, + onSetupStateChanged: () => tray.refreshMenu(), requestAppQuit: () => requestAppQuit(), }); const discordPresenceSessionStartedAtMs = Date.now(); @@ -1072,32 +928,6 @@ function syncOverlayMpvSubtitleSuppression(): void { restoreOverlayMpvSubtitles(); } -const immersionMediaRuntime = createImmersionMediaRuntime({ - getResolvedConfig: () => configService.getConfig(), - 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 anilistRateLimiter = createAnilistRateLimiter(); -const statsCoverArtFetcher = createCoverArtFetcher( - anilistRateLimiter, - createLogger('main:stats-cover-art'), - { - liveAction: createLiveActionMetadataResolver( - createTmdbClient({ - resolveApiKey: createTmdbApiKeyResolver( - () => configService.getConfig().tmdb, - () => bundledTmdbApiKey, - ), - }), - createLogger('main:tmdb'), - ), - }, -); const configDerivedRuntime = createConfigDerivedRuntime({ getResolvedConfig: () => configService.getConfig(), getRuntimeOptionsManager: () => appState.runtimeOptionsManager, @@ -1129,67 +959,83 @@ const startupOsdSequencer = createStartupOsdSequencer({ overlayNotificationsRuntime.showOverlayNotification(payload), showDesktopNotification: (title, options) => showDesktopNotification(title, options), }); -let appTray: Tray | null = null; -function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null { - // Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active - // event, so a finished caption row lingering beside a fresh line would end up on the - // card. The parsed view also carries the cue's own timings for the clip range. - // Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first. - const subDelay = appState.mpvClient?.currentSubDelay ?? 0; - const resolved = resolvePrimarySubtitle({ - liveText: appState.mpvClient?.currentSubText ?? '', - currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay), - cues: appState.activeParsedSubtitleCues, - }); - // Same validity bar as the live capture path: an unusable resolved span must fall - // back rather than hand mining an empty line or an inverted range. - const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim(); - if ( - !resolved || - !resolvedText || - !Number.isFinite(resolved.startTime) || - !Number.isFinite(resolved.endTime) || - resolved.endTime <= resolved.startTime - ) { - return captureLiveSubtitleMiningContext(appState.mpvClient); - } - return shiftSubtitleRangeToVideoClock( - { - source: 'overlay', - text: resolvedText, - startTime: resolved.startTime, - endTime: resolved.endTime, - capturedAtMs: Date.now(), +const tray = createTrayAssembly({ + appState, + logger, + iconRoots: { + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + mainDirname: __dirname, + }, + electron: { + createTray: (icon) => new Tray(icon), + createImageFromPath: (iconPath) => nativeImage.createFromPath(iconPath), + createEmptyImage: () => nativeImage.createEmpty(), + buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), + }, + menu: { + openSessionHelpModal: () => overlayModals.openSessionHelpOverlay(), + openChangelogModal: () => overlayModals.openChangelogOverlay(), + openTexthookerInBrowser: () => handleCliCommand(parseArgs(['--texthooker', '--open-browser'])), + showTexthookerPage: () => shouldShowTexthookerTrayEntry(configService.getConfig()), + showFirstRunSetup: () => !firstRunSetup.service.isSetupCompleted(), + openFirstRunSetupWindow: (force?: boolean) => firstRunSetup.openWindow(force), + getDictionaryBackend: () => activeDictionaryBackend, + openHachidoriSettings: () => yomitan.openHachidoriSettings(), + openYomitanSettings: () => yomitan.openYomitanSettings(), + openConfigSettingsWindow: () => settings.window.openWindow(), + openSyncUiWindow: () => sync.openWindow(), + openYoutubeBrowserWindow: () => youtube.browserWindow.open(), + exportLogs: () => { + void exportLogsFromTray(); }, - subDelay, - ); -} -async function resolveSentenceSearchHeadwords(term: string): Promise { - const fallback = term.trim() ? [term.trim()] : []; - try { - const tokenizeSubtitleDeferred = getTokenizeSubtitleDeferred(); - const tokenized = tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(term) : null; - const tokens = tokenized?.tokens ?? []; - if (tokens.length === 0) return fallback; - - const seen = new Set(); - const headwords: string[] = []; - for (const token of tokens) { - const headword = (token.headword || token.surface).trim(); - if (!headword || seen.has(headword)) continue; - seen.add(headword); - headwords.push(headword); - } - return headwords.length > 0 ? headwords : fallback; - } catch (error) { - logger.debug( - 'Failed to resolve sentence-search headwords:', - error instanceof Error ? error.message : String(error), - ); - return fallback; - } -} - + openJellyfinSetupWindow: () => jellyfin.openJellyfinSetupWindow(), + isJellyfinConfigured: () => isJellyfinConfiguredForTrayRuntime(jellyfin.getTrayDiscoveryDeps()), + isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession), + toggleJellyfinDiscovery: (checked: boolean) => + toggleJellyfinDiscoveryFromTrayRuntime(jellyfin.getTrayDiscoveryDeps(), { + desiredActive: checked, + }), + openAnilistSetupWindow: () => anilist.openSetupWindow(), + checkForUpdates: () => { + void getUpdateService().checkForUpdates({ source: 'manual' }); + }, + quitApp: () => requestAppQuit(), + }, + initializeOverlayRuntime: () => startup.initializeOverlayRuntime(), + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), +}); +const overlay = createOverlayAssembly({ + appState, + logger, + overlayManager, + overlayModalInputState, + overlayModalRuntime, + overlayContentMeasurementStore, + autoplayReadyGate, + // Forward lazily: Electron's screen module must not be touched before app 'ready'. + screen: { + getCursorScreenPoint: () => screen.getCursorScreenPoint(), + getDisplayNearestPoint: (point) => screen.getDisplayNearestPoint(point), + }, + isDev, + createOverlayWindowCore, + getYomitanSession: () => yomitan.getOverlaySession(), + getVisibleOverlayInteraction: () => visibleOverlayInteractionRuntime, + getOverlayGeometry: () => overlayGeometryRuntime, + getOverlayShortcuts: () => shortcuts.overlayShortcutsRuntime, + getOverlayNotifications: () => overlayNotificationsRuntime, + getOverlayVisibilityComposer: () => overlayVisibilityComposer, + getMediaTimingReviewRuntime: () => mining.mediaTimingReviewRuntime, + getSubtitleProcessingController: () => subtitleProcessingController, + getAutoplaySubtitlePriming: () => autoplaySubtitlePrimingRuntime, + ensureOverlayMpvSubtitlesHidden: () => ensureOverlayMpvSubtitlesHidden(), + syncOverlayMpvSubtitleSuppression: () => syncOverlayMpvSubtitleSuppression(), + initializeOverlayRuntime: () => startup.initializeOverlayRuntime(), + getPluginPanel: () => pluginPanelRuntime, + setSubtitleSidebarRequestedOpen: (open) => playback.setSubtitleSidebarRequestedOpen(open), + stealAppFocus: () => app.focus({ steal: true }), +}); const { linuxOverlayModeRuntime, overlayVisibilityRuntime, @@ -1201,112 +1047,24 @@ const { setOverlayVisible, handleMpvFullscreenChange, clearLinuxFullscreenOverlayRefresh, -} = createOverlayAssembly({ +} = overlay; + +const shortcuts = createShortcutsAssembly({ appState, + getConfig: () => configService.getConfig(), logger, - overlayManager, - overlayModalInputState, - autoplayReadyGate, - // Forward lazily: Electron's screen module must not be touched before app 'ready'. - screen: { - getCursorScreenPoint: () => screen.getCursorScreenPoint(), - getDisplayNearestPoint: (point) => screen.getDisplayNearestPoint(point), - }, + configDir: CONFIG_DIR, isDev, - createOverlayWindowCore, - getYomitanSession: () => - activeDictionaryBackend === 'hachidori' ? getHachidoriSession() : appState.yomitanSession, - getVisibleOverlayInteraction: () => visibleOverlayInteractionRuntime, - getOverlayGeometry: () => overlayGeometryRuntime, - getOverlayShortcuts: () => overlayShortcutsRuntime, + overlayManager, + globalShortcut, getOverlayNotifications: () => overlayNotificationsRuntime, - getOverlayVisibilityComposer: () => overlayVisibilityComposer, - getMediaTimingReviewRuntime: () => mediaTimingReviewRuntime, - getSubtitleProcessingController: () => subtitleProcessingController, - getAutoplaySubtitlePriming: () => autoplaySubtitlePrimingRuntime, - ensureOverlayMpvSubtitlesHidden: () => ensureOverlayMpvSubtitlesHidden(), - syncOverlayMpvSubtitleSuppression: () => syncOverlayMpvSubtitleSuppression(), - initializeOverlayRuntime: () => initializeOverlayRuntime(), -}); -let subtitleSidebarRequestedOpen = false; - -const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({ - 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) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), - openRuntimeOptionsPalette: () => { - overlayModals.openRuntimeOptionsPalette(); - }, - openCharacterDictionaryManager: () => { - overlayModals.openCharacterDictionaryManagerOverlay(); - }, - openJimaku: () => { - overlayModals.openJimakuOverlay(); - }, - openTsukihime: () => { - overlayModals.openTsukihimeOverlay(); - }, - markAudioCard: () => markLastCardAsAudioCardHandler(), - copySubtitleMultiple: (timeoutMs: number) => { - startPendingMultiCopy(timeoutMs); - }, - copySubtitle: () => { - copyCurrentSubtitleHandler(); - }, - toggleSecondarySubMode: () => cycleSecondarySubMode(), - updateLastCardFromClipboard: () => updateLastCardFromClipboardHandler(), - triggerFieldGrouping: () => triggerFieldGroupingHandler(), + getOverlayModals: () => overlayModals, + getMining: () => mining, triggerSubsyncFromConfig: () => subsyncRuntime.triggerFromConfig(), - mineSentenceCard: () => mineSentenceCardHandler(), - mineSentenceMultiple: (timeoutMs: number) => { - startPendingMineSentenceMultiple(timeoutMs); - }, - cancelPendingMultiCopy: () => { - cancelPendingMultiCopy(); - }, - cancelPendingMineSentenceMultiple: () => { - cancelPendingMineSentenceMultiple(); - }, + cycleSecondarySubMode: () => cycleSecondarySubMode(), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + openDictionarySettings: () => yomitan.openDictionarySettings(), }); -syncOverlayShortcutsForModal = (isActive: boolean): void => { - if (isActive) { - overlayShortcutsRuntime.unregisterOverlayShortcuts(); - } else { - overlayShortcutsRuntime.syncOverlayShortcuts(); - } -}; - -async function getCurrentYomitanAnkiDeckNameForRuntime(): Promise { - await yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); - return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), { - error: (message, ...args) => { - logger.error(message, ...args); - }, - info: (message, ...args) => { - logger.info(message, ...args); - }, - }); -} const settings = createSettingsAssembly({ appState, @@ -1320,10 +1078,10 @@ const settings = createSettingsAssembly({ appState.keybindings = keybindings; }, setSessionBindings: (sessionBindings, sessionBindingWarnings) => { - persistSessionBindings(sessionBindings, sessionBindingWarnings); + shortcuts.persistSessionBindings(sessionBindings, sessionBindingWarnings); }, refreshGlobalAndOverlayShortcuts: () => { - refreshGlobalAndOverlayShortcuts(); + shortcuts.refreshGlobalAndOverlayShortcuts(); }, setSecondarySubMode: (mode) => { setSecondarySubMode(mode); @@ -1336,9 +1094,11 @@ const settings = createSettingsAssembly({ appState.ankiIntegration.applyRuntimeConfigPatch(patch); } if (activeDictionaryBackend === 'hachidori' && appState.yomitanExt) { - void syncYomitanDefaultProfileAnkiServer().catch((error: unknown) => - logger.error('Failed to auto-populate Hachidori Anki settings', error), - ); + void yomitan + .syncYomitanDefaultProfileAnkiServer() + .catch((error: unknown) => + logger.error('Failed to auto-populate Hachidori Anki settings', error), + ); } }, invalidateTokenizationCache: () => { @@ -1360,10 +1120,10 @@ const settings = createSettingsAssembly({ setLogFileToggles(files); }, applyAniSkipConfig: () => { - aniSkipRuntime.applyConfigChange(); + playback.aniSkipRuntime.applyConfigChange(); }, }, - getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime, + getYomitanAnkiDeckName: () => yomitan.getCurrentAnkiDeckName(), showErrorBox: (title, content) => dialog.showErrorBox(title, content), createBrowserWindow: (options) => new BrowserWindow(options), mainDirname: __dirname, @@ -1417,42 +1177,33 @@ const dictionaries = createDictionariesAssembly({ logger, }); -const getFieldGroupingResolverHandler = createGetFieldGroupingResolverHandler({ - getResolver: () => appState.fieldGroupingResolver, -}); - -const setFieldGroupingResolverHandler = createSetFieldGroupingResolverHandler({ - setResolver: (resolver) => { - appState.fieldGroupingResolver = resolver; - }, - nextSequence: () => { - appState.fieldGroupingResolverSequence += 1; - return appState.fieldGroupingResolverSequence; - }, - getSequence: () => appState.fieldGroupingResolverSequence, -}); - -const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime({ - getMainWindow: () => overlayManager.getMainWindow(), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), +const mining = createMiningAssembly({ + appState, + getConfig: () => configService.getConfig(), + logger, + overlayManager, + overlayModalRuntime, + youtube, + clipboard, + getOverlayNotifications: () => overlayNotificationsRuntime, + getOverlayModals: () => overlayModals, setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), - getResolver: () => getFieldGroupingResolverHandler(), - setResolver: (resolver) => setFieldGroupingResolverHandler(resolver), - getRestoreVisibleOverlayOnModalClose: () => - overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), - // These are optional on the runtime options, so a missing forward compiles silently, but - // dropping them leaves the field grouping modal with no modal-open ack/retry, no teardown on - // failure, and no warn logging. - waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), - handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal), - logWarn: (message) => logger.warn(message), - ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + ensureOverlayStartupPrereqs: () => startup.ensureOverlayStartupPrereqs(), ensureOverlayWindowsReadyForVisibilityActions: () => ensureOverlayWindowsReadyForVisibilityActions(), - sendToVisibleOverlay: (channel, payload, runtimeOptions) => - overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + ankiIntegrationCallbacks: { + recordCardsMined: (count, noteIds) => stats.recordCardsMined(count, noteIds), + getMiningProvenance: () => ({ + minedAt: Date.now(), + media: pluginHost.getMediaSnapshot(), + subtitle: getPluginCurrentSubtitle(), + }), + onCardsReady: (noteIds, notes) => pluginHost.emitAnkiEvent('card-mined', { noteIds, notes }), + onKnownWordCacheUpdated: () => refreshCurrentSubtitleAnnotations(), + generateSentenceFurigana: (text, highlightedText) => + yomitan.generateSentenceFurigana(text, highlightedText), + }, }); -const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback; const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions'); @@ -1487,12 +1238,12 @@ const characterDictionary = createCharacterDictionaryAssembly({ logger, startupOsdSequencer, resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath), - getYomitanProfilePolicy: () => yomitanProfilePolicy, + getYomitanProfilePolicy: () => yomitan.profilePolicy, isYoutubePlaybackActive: () => youtube.isPlaybackActiveNow(), waitForMediaTokenizationReady: (mediaPath) => currentMediaTokenizationGate.waitUntilReady(mediaPath), - ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), - getYomitanParserRuntimeDeps: () => getYomitanParserRuntimeDeps(), + ensureYomitanExtensionLoaded: () => yomitan.ensureExtensionLoaded(), + getYomitanParserRuntimeDeps: () => yomitan.getParserRuntimeDeps(), getOverlayNotifications: () => overlayNotificationsRuntime, showMpvOsd: (message) => showMpvOsd(message), showDesktopNotification: (title, options) => showDesktopNotification(title, options), @@ -1502,6 +1253,7 @@ const characterDictionary = createCharacterDictionaryAssembly({ getSubtitlePrefetchService()?.onSeek(getLastObservedTimePos()); }, refreshCurrentSubtitleAnnotations: () => refreshCurrentSubtitleAnnotations(), + resetAnilistMediaGuessState: () => anilist.resetAnilistMediaGuessState(), }); const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime({ @@ -1547,7 +1299,7 @@ const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime( subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); }, getOverlayWindows: () => overlayManager.getOverlayWindows(), - syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), + syncOverlayShortcuts: () => shortcuts.overlayShortcutsRuntime.syncOverlayShortcuts(), resetLastOverlayWindowGeometry: () => overlayGeometryRuntime.resetLastOverlayWindowGeometry(), enforceOverlayLayerOrder: () => { overlayGeometryRuntime.enforceOverlayLayerOrder(); @@ -1559,10 +1311,6 @@ const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler({ getRuntimeOptionsManager: () => appState.runtimeOptionsManager, }); -syncOverlayVisibilityForModal = () => { - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); -}; - const overlayNotificationsRuntime = createOverlayNotificationsRuntime({ getResolvedConfig: () => configService.getConfig(), getMainOverlayWindow: () => overlayManager.getMainWindow(), @@ -1615,7 +1363,7 @@ const overlayModals = createOverlayModalsAssembly({ logger, overlayModalRuntime, getOverlayNotifications: () => overlayNotificationsRuntime, - ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + ensureOverlayStartupPrereqs: () => startup.ensureOverlayStartupPrereqs(), ensureOverlayWindowsReadyForVisibilityActions: () => ensureOverlayWindowsReadyForVisibilityActions(), sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => @@ -1726,76 +1474,6 @@ const pluginHost = createPluginHost({ unstable: { appState, configService, overlayManager }, }); -const mediaTimingFrameExtractor = createMediaTimingFrameExtractor(); -const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({ - getMpvClient: () => appState.mpvClient, - getCurrentMediaPath: () => - appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, - // An invalid configured path is passed through so the preview fails instead of using PATH mpv. - getMpvExecutablePath: () => { - const configuredPath = configService.getConfig().mpv.executablePath; - return findMpvExecutablePath(configuredPath) || configuredPath; - }, - createPreviewSession: () => new MediaTimingPreviewSession(), - generateWaveform: (options) => generateSpeechWaveform(options), - generateFrame: (options) => mediaTimingFrameExtractor.generate(options), - clearFrameCache: () => mediaTimingFrameExtractor.clear(), - resolveVideoSource: () => - resolveMediaGenerationInput(appState.mpvClient, 'video', { - getCachedMediaPath: (currentVideoPath, kind) => - youtube.getCachedMediaPathForCurrentPlayback(currentVideoPath, kind), - remoteCacheMode: youtube.shouldRequireMediaCacheForCurrentPlayback() - ? 'required' - : 'optional', - }), - resolveMediaSource: async () => { - const resolved = await resolveMediaGenerationInput(appState.mpvClient, 'audio', { - getCachedMediaPath: (currentVideoPath, kind) => - youtube.getCachedMediaPathForCurrentPlayback(currentVideoPath, kind), - remoteCacheMode: youtube.shouldRequireMediaCacheForCurrentPlayback() - ? 'required' - : 'optional', - }); - return resolved - ? { - path: resolved.path, - ...(resolved.inputOptions ? { inputOptions: resolved.inputOptions } : {}), - singleResolvedStream: resolved.singleResolvedStream, - } - : null; - }, - acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range), - getSubtitleContextLines: (range) => - collectMediaTimingContextLines({ - // Review ranges and tracker entries are on the video clock; parsed cues are not. - cues: appState.activeParsedSubtitleCues.map((cue) => - shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0), - ), - fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [], - startTime: range.startTime, - endTime: range.endTime, - }), - openModal: (payload, signal) => - openMediaTimingReviewModal(overlayModals.createOverlayHostedModalOpenDeps(), payload, signal), - onPreviewEnded: (reviewId) => { - // The review may live in either overlay window; the renderer ignores foreign review ids. - for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) { - if (window && !window.isDestroyed()) { - window.webContents.send(IPC_CHANNELS.event.mediaTimingReviewPreviewEnded, reviewId); - } - } - }, - onPreviewPosition: (position) => { - for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) { - if (window && !window.isDestroyed()) { - window.webContents.send(IPC_CHANNELS.event.mediaTimingReviewPreviewPosition, position); - } - } - }, - showStatus: (message) => - overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }), -}); - function getRuntimeBooleanOption( id: | 'subtitle.annotation.knownWords.highlightEnabled' @@ -1838,14 +1516,14 @@ const jellyfin = createJellyfinAssembly({ getAppDataPath: () => app.getPath('appData'), getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(), getMpvPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(), - createMpvClient: () => createMpvClientRuntimeService(), + createMpvClient: () => playback.createMpvClient(), initSubtitlePrefetch: (sourcePath) => subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath), showVisibleOverlay: () => setVisibleOverlayVisible(true), getOverlayNotifications: () => overlayNotificationsRuntime, updateCurrentMediaTitle: (title) => mediaRuntime.updateCurrentMediaTitle(title), - ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(), - refreshTrayMenu: () => refreshTrayMenuIfPresent(), + ensureImmersionTrackerStarted: () => stats.ensureImmersionTrackerStarted(), + refreshTrayMenu: () => tray.refreshMenu(), setupPreloadPath: path.join(__dirname, 'preload-jellyfin-setup.js'), createBrowserWindow: (options) => new BrowserWindow(options), ipcMain, @@ -1924,7 +1602,7 @@ const { }, }, onWillQuitCleanupMainDeps: { - destroyTray: () => destroyTrayHandler(), + destroyTray: () => tray.destroyTray(), stopConfigHotReload: () => settings.hotReload.stop(), restorePreviousSecondarySubVisibility: () => overlayVisibilityComposer.restorePreviousSecondarySubVisibility(), @@ -1965,7 +1643,7 @@ const { }, getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getImmersionTracker: () => appState.immersionTracker, - stopStatsServer: () => stopStatsServer(), + stopStatsServer: () => stats.stopStatsServer(), clearImmersionTracker: () => { appState.statsServer = null; appState.immersionTracker = null; @@ -2022,472 +1700,52 @@ const { }); registerProtocolUrlHandlersHandler(); -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: ImmersionTrackerStartupDeps = { - getResolvedConfig: () => configService.getConfig(), - getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), - createTrackerService: (params) => - new ImmersionTrackerService({ - ...params, - resolveLegacyVocabularyPos, - }), - setTracker: (tracker) => { - const trackerHasChanged = - appState.immersionTracker !== null && appState.immersionTracker !== tracker; - if (trackerHasChanged && appState.statsServer) { - void stopStatsServer().catch((error: unknown) => { - logger.warn('Failed to stop stats server while replacing immersion tracker.', error); - }); - appState.statsServer = null; - } - - appState.immersionTracker = tracker as ImmersionTrackerService | null; - appState.immersionTracker?.setCoverArtFetcher(statsCoverArtFetcher); - if (tracker) { - // Start HTTP stats server - if (!appState.statsServer) { - const config = configService.getConfig(); - if (config.stats.autoStartServer) { - void ensureStatsServerStarted().catch((error: unknown) => { - logger.warn('Failed to auto-start stats server.', error); - }); - } - } - - // Register stats overlay toggle IPC handler (idempotent) - registerStatsOverlayToggle({ - preloadPath: statsPreloadPath, - getApiBaseUrl: async () => (await ensureStatsServerStarted()).url, - onStartupError: (error) => - overlayNotificationsRuntime.showConfiguredStatusNotification( - `Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`, - { title: 'Stats' }, - ), - getToggleKey: () => configService.getConfig().stats.toggleKey, - resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(), - onVisibilityChanged: (visible) => { - visibleOverlayInteractionRuntime.handleStatsOverlayVisibilityChanged(visible); - }, - }); - } - }, - 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( - immersionTrackerStartupMainDeps, -); -const recordSubtitleMiningContext = (context: SubtitleMiningContext | null): void => { - pendingSubtitleMiningContext = context; -}; -const consumePendingSubtitleMiningContext = (): SubtitleMiningContext | null => { - const context = pendingSubtitleMiningContext; - pendingSubtitleMiningContext = null; - return context; -}; -const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => { - ensureImmersionTrackerStarted(); - appState.immersionTracker?.recordCardsMined(count, noteIds); -}; -let hasAttemptedImmersionTrackerStartup = false; -const ensureImmersionTrackerStarted = (): void => { - if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) { - return; - } - hasAttemptedImmersionTrackerStartup = true; - createImmersionTrackerStartup(); -}; -const statsStartupRuntime = { - ensureStatsServerStarted: () => ensureStatsServerStarted(), - ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(), - stopBackgroundStatsServer: () => stopBackgroundStatsServer(), - ensureImmersionTrackerStarted: () => { - appState.statsStartupInProgress = true; - try { - ensureImmersionTrackerStarted(); - } finally { - appState.statsStartupInProgress = false; - } - }, -} as const; -const ensureBackgroundStatsServer = createEnsureBackgroundStatsServerHandler({ - isStatsAutoStartEnabled: () => configService.getConfig().stats.autoStartServer, - isImmersionTrackingEnabled: () => configService.getConfig().immersionTracking?.enabled !== false, - ensureBackgroundStatsServerStarted: () => - statsStartupRuntime.ensureBackgroundStatsServerStarted(), - logInfo: (message) => logger.info(message), - logWarn: (message, error) => logger.warn(message, error), -}); - -const runStatsCliCommand = createRunStatsCliCommandHandler({ - getResolvedConfig: () => configService.getConfig(), - ensureImmersionTrackerStarted: () => statsStartupRuntime.ensureImmersionTrackerStarted(), - ensureVocabularyCleanupTokenizerReady: async () => { - await createMecabTokenizerAndCheck(); - }, - getImmersionTracker: () => appState.immersionTracker, - ensureStatsServerStarted: async () => (await statsStartupRuntime.ensureStatsServerStarted()).url, - 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 = configService.getConfig(); - 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), - logDebug: (message) => appLogger.logDebug(message), - logWarning: (message) => appLogger.logWarning(message), - showDesktopNotification: (title, options) => showDesktopNotification(title, options), - startConfigHotReload: () => settings.hotReload.start(), - shouldRefreshAnilistClientSecretState: () => - shouldRefreshAnilistOnConfigReload(appState.initialArgs), - refreshAnilistClientSecretState: (options) => - anilist.refreshClientSecretStateIfEnabled(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(configService.getConfig(), DEFAULT_KEYBINDINGS); - refreshCurrentSessionBindings(); - }, - createMpvClient: () => { - appState.mpvClient = createMpvClientRuntimeService(); - }, - startPlugins: () => { - void pluginHost.loadAll(); - }, - getResolvedConfig: () => configService.getConfig(), - getConfigWarnings: () => configService.getWarnings(), - logConfigWarning: (warning) => appLogger.logConfigWarning(warning), +const startup = createStartupAssembly({ + appState, + configService, + logger, + logConfigWarning: (warning) => appLogger.logConfigWarning(warning), + configDir: CONFIG_DIR, + userDataPath: USER_DATA_PATH, + defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, + showErrorBox: (title, content) => dialog.showErrorBox(title, content), + reportFatalError, + requestAppQuit: () => requestAppQuit(), + logging: { setLogLevel: (level: string, source: LogLevelSource) => setLogLevel(level, source), setLogRotation: (rotation: number) => setLogRotation(rotation), setLogFileToggles: (files) => setLogFileToggles(files), - initRuntimeOptionsManager: () => { - appState.runtimeOptionsManager = new RuntimeOptionsManager( - () => configService.getConfig().ankiConnect, - { - applyAnkiPatch: (patch) => { - if (appState.ankiIntegration) { - appState.ankiIntegration.applyRuntimeConfigPatch(patch); - } - }, - getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle, - onOptionsChanged: () => { - subtitleProcessingController.invalidateTokenizationCache(); - getSubtitlePrefetchService()?.onSeek(getLastObservedTimePos()); - overlayVisibilityComposer.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), - () => { - const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; - return { - enabled: frequencyDictionary.enabled, - topX: frequencyDictionary.topX, - mode: 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), - () => { - const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; - return { - enabled: frequencyDictionary.enabled, - topX: frequencyDictionary.topX, - mode: 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(); - }, - ensureYomitanExtensionLoaded: async () => { - await ensureYomitanExtensionLoaded(); - }, - handleFirstRunSetup: async () => { - const snapshot = await firstRunSetup.service.ensureSetupStateInitialized(); - appState.firstRunSetupCompleted = snapshot.state.status === 'completed'; - const args = appState.initialArgs; - if (args && shouldAutoOpenFirstRunSetup(args)) { - const force = Boolean(args.setup); - if (force || snapshot.state.status !== 'completed') { - firstRunSetup.openWindow(force); - } - } - }, - startJellyfinRemoteSession: async () => { - await jellyfin.startJellyfinRemoteSession(); - }, - prewarmSubtitleDictionaries: async () => { - await prewarmSubtitleDictionaries(); - }, - startBackgroundWarmups: () => { - startBackgroundWarmups(); - }, - 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, - shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => - shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs), - createImmersionTracker: () => { - ensureImmersionTrackerStarted(); - }, - logDebug: (message: string) => { - logger.debug(message); - }, - now: () => Date.now(), }, - immersionTrackerStartupMainDeps, + overlayManager, + overlayModalRuntime, + subtitleWsService, + annotationSubtitleWsService, + texthookerService, + configDerivedRuntime, + hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), + loadSubtitlePosition: () => loadSubtitlePosition(), + setSecondarySubMode: (mode) => setSecondarySubMode(mode), + syncOverlayMpvSubtitleSuppression: () => syncOverlayMpvSubtitleSuppression(), + yomitan, + stats, + firstRunSetup, + youtube, + overlay, + shortcuts, + settings, + mining, + getAnilist: () => anilist, + getJellyfin: () => jellyfin, + getPlayback: () => playback, + getPluginHost: () => pluginHost, + getOverlayNotifications: () => overlayNotificationsRuntime, + getOverlayVisibilityComposer: () => overlayVisibilityComposer, + getVisibleOverlayInteraction: () => visibleOverlayInteractionRuntime, + getOverlayGeometry: () => overlayGeometryRuntime, + handleInitialArgs: () => handleInitialArgs(), }); -async function runAppReadyRuntimeWithFatalReporting(): Promise { - try { - await appReadyRuntimeRunner(); - } catch (error) { - reportFatalError(error, { - title: 'SubMiner startup failed', - context: 'SubMiner failed during app-ready startup.', - }); - process.exitCode = 1; - requestAppQuit(); - return; - } -} - -function ensureOverlayStartupPrereqs(): void { - if (appState.subtitlePosition === null) { - loadSubtitlePosition(); - } - if (appState.keybindings.length === 0) { - appState.keybindings = resolveKeybindings(configService.getConfig(), DEFAULT_KEYBINDINGS); - refreshCurrentSessionBindings(); - } else if (!appState.sessionBindingsInitialized) { - refreshCurrentSessionBindings(); - } - 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(); - getSubtitlePrefetchService()?.onSeek(getLastObservedTimePos()); - overlayVisibilityComposer.broadcastRuntimeOptionsChanged(); - refreshOverlayShortcuts(); - }, - }, - ); - } - if (!appState.subtitleTimingTracker) { - appState.subtitleTimingTracker = new SubtitleTimingTracker(); - } -} - -async function ensureYoutubePlaybackRuntimeReady(): Promise { - ensureOverlayStartupPrereqs(); - await ensureYomitanExtensionLoaded(); - if (!appState.overlayRuntimeInitialized) { - initializeOverlayRuntime(); - return; - } - ensureOverlayWindowsReadyForVisibilityActions(); -} - -const { - subtitleProcessingController, - autoplaySubtitlePrimingRuntime, - subtitlePrefetchRuntime, - cachedInternalSubtitleTrackExtractor, - resolveActiveSubtitleSidebarSourceHandler, - withCurrentSubtitleTiming, - getSubtitleSidebarPlaybackState, - sendRendererMpvCommand, - refreshCurrentSubtitleAnnotations, - createMpvClientRuntimeServiceHandler, - createMecabTokenizerAndCheck, - prewarmSubtitleDictionaries, - startBackgroundWarmups, - startTokenizationWarmups, - isTokenizationWarmupReady, - getSubtitlePrefetchService, - getLastObservedTimePos, - getTokenizeSubtitleDeferred, -} = createPlaybackAssembly({ +const playback = createPlaybackAssembly({ appState, configService, logger, @@ -2498,94 +1756,51 @@ const { currentMediaTokenizationGate, startupOsdSequencer, managedLocalSubtitleSelectionRuntime, - mediaTimingReviewRuntime, + mediaTimingReviewRuntime: mining.mediaTimingReviewRuntime, discordPresenceRuntime, - immersionMediaRuntime, + immersionMediaRuntime: stats.immersionMediaRuntime, mediaRuntime, overlayNotificationsRuntime, + overlayModalRuntime, youtube, jellyfin, anilist, characterDictionary, dictionaries, + getPluginHost: () => pluginHost, getSubtitleGenerationRuntime: () => subtitleGenerationRuntime, - isCharacterDictionaryEnabled: () => yomitanProfilePolicy.isCharacterDictionaryEnabled(), - hasTray: () => Boolean(appTray), + isCharacterDictionaryEnabled: () => yomitan.profilePolicy.isCharacterDictionaryEnabled(), + hasTray: () => tray.hasTray(), loadSubtitleSourceText, createSubtitlePrefetchService, requestAppQuit: () => requestAppQuit(), maybeStartOverlayLoadingOsd: (mediaPath) => maybeStartOverlayLoadingOsd(mediaPath), flushQueuedMpvOsdNotifications: () => flushQueuedMpvOsdNotifications(), - refreshMpvSessionBindings: () => refreshMpvSessionBindings(), - ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(), + refreshMpvSessionBindings: () => shortcuts.refreshMpvSessionBindings(), + ensureImmersionTrackerStarted: () => stats.ensureImmersionTrackerStarted(), restoreOverlayMpvSubtitles: () => restoreOverlayMpvSubtitles(), - resetSubtitleSidebarEmbeddedLayoutRuntime: () => resetSubtitleSidebarEmbeddedLayoutRuntime(), syncOverlayMpvSubtitleSuppression: () => syncOverlayMpvSubtitleSuppression(), setOverlayVisible: (visible) => setOverlayVisible(visible), - ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), + ensureYomitanExtensionLoaded: () => yomitan.ensureExtensionLoaded(), shouldInitializeMecabForAnnotations: () => shouldInitializeMecabForAnnotations(), getRuntimeBooleanOption: (id, fallback) => getRuntimeBooleanOption(id, fallback), onFullscreenChange: (fullscreen) => handleMpvFullscreenChange(fullscreen), }); - -const aniSkipRuntime = createAniSkipRuntime({ - getAniSkipConfig: () => ({ - aniskipEnabled: configService.getConfig().mpv.aniskipEnabled, - aniskipButtonKey: configService.getConfig().mpv.aniskipButtonKey, - }), - resolveMetadataForFile: (mediaPath) => resolveAniSkipMetadataForFile(mediaPath), - sendMpvCommand: (command) => { - appState.mpvClient?.send({ command }); - }, - requestMpvProperty: (name) => { - if (!appState.mpvClient) { - return Promise.reject(new Error('MPV not connected')); - } - return appState.mpvClient.requestProperty(name); - }, - isMpvConnected: () => appState.mpvClient?.connected === true, - getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? Number.NaN, - showMpvOsd: (text, durationMs) => { - appState.mpvClient?.send({ command: ['show-text', text, durationMs] }); - }, - showPlaybackFeedback: (text) => showConfiguredPlaybackFeedback(text), - logInfo: (message) => logger.info(message), - logWarn: (message, error) => logger.warn(message, error), - logDebug: (message) => logger.debug(message), -}); - -function createMpvClientRuntimeService(): MpvIpcClient { - const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient; - client.on('connection-change', ({ connected }) => { - if (connected) { - return; - } - youtube.browserPlayback.handleMpvDisconnected(); - youtube.flow.cancelWhisperGeneration(); - subtitleGenerationRuntime.handleMediaPathChange(null); - if (youtube.shouldQuitWhenMpvCloses()) { - requestAppQuit(); - return; - } - if (!youtube.flow.hasActiveSession()) { - return; - } - youtube.flow.cancelActivePicker(); - overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null); - overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker'); - }); - client.on('connection-change', aniSkipRuntime.handleConnectionChange); - client.on('media-path-change', aniSkipRuntime.handleMediaPathChange); - client.on('time-pos-change', aniSkipRuntime.handleTimePosChange); - client.on('client-message', aniSkipRuntime.handleClientMessage); - pluginHost.attachMpvClient(client); - return client; -} - -function resetSubtitleSidebarEmbeddedLayoutRuntime(): void { - sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-margin-ratio-right', 0]); - sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-pan-x', 0]); -} +const { + subtitleProcessingController, + autoplaySubtitlePrimingRuntime, + subtitlePrefetchRuntime, + cachedInternalSubtitleTrackExtractor, + sendRendererMpvCommand, + refreshCurrentSubtitleAnnotations, + createMecabTokenizerAndCheck, + startBackgroundWarmups, + startTokenizationWarmups, + isTokenizationWarmupReady, + getSubtitlePrefetchService, + getLastObservedTimePos, + getTokenizeSubtitleDeferred, +} = playback; const overlayGeometryRuntime = createOverlayGeometryRuntime({ overlayManager: { @@ -2624,257 +1839,12 @@ const overlayGeometryRuntime = createOverlayGeometryRuntime({ logDebug: (message, ...args) => logger.debug(message, ...args), }); -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; -} - -const { syncYomitanDefaultProfileAnkiServer } = createYomitanAnkiServerSyncRuntime({ - isExternalReadOnlyMode: () => yomitanProfilePolicy.isExternalReadOnlyMode(), - getResolvedConfig: () => configService.getConfig(), - getYomitanParserRuntimeDeps: () => getYomitanParserRuntimeDeps(), - logError: (message, ...args) => { - logger.error(message, ...args); - }, - logInfo: (message, ...args) => { - logger.info(message, ...args); - }, -}); - -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; - }, - }; -} - -function generateMiningSentenceFurigana( - text: string, - highlightedText?: string, -): Promise { - return generateSentenceFurigana(text, highlightedText, getYomitanParserRuntimeDeps(), logger); -} - -function initializeOverlayRuntime(): void { - initializeOverlayRuntimeHandler(); - if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) { - overlayModalRuntime.primeModalWindow(); - } - appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); - appState.ankiIntegration?.setMiningProvenanceProvider(() => ({ - minedAt: Date.now(), - media: pluginHost.getMediaSnapshot(), - subtitle: getPluginCurrentSubtitle(), - })); - appState.ankiIntegration?.setCardsReadyCallback((noteIds, notes) => - pluginHost.emitAnkiEvent('card-mined', { noteIds, notes }), - ); - appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations); - appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext); - appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview); - appState.ankiIntegration?.setSentenceFuriganaGenerator(generateMiningSentenceFurigana); - syncOverlayMpvSubtitleSuppression(); -} - -function openYomitanSettings(): boolean { - if (activeDictionaryBackend === 'hachidori') { - if (configService.getConfig().yomitan.externalProfilePath.trim()) { - logger.warn('Yomitan settings unavailable while using read-only external-profile mode.'); - return false; - } - inactiveYomitanLoad ??= inactiveYomitanExtension - ? Promise.resolve(inactiveYomitanExtension) - : loadYomitanExtensionCore({ - userDataPath: USER_DATA_PATH, - getYomitanParserWindow: () => null, - setYomitanParserWindow: () => {}, - setYomitanParserReadyPromise: () => {}, - setYomitanParserInitPromise: () => {}, - setYomitanExtension: (extension) => { - inactiveYomitanExtension = extension; - }, - setYomitanSession: () => {}, - }).finally(() => { - inactiveYomitanLoad = null; - }); - void ( - inactiveYomitanExtension ? Promise.resolve(inactiveYomitanExtension) : inactiveYomitanLoad - ) - .then((extension) => - openYomitanSettingsWindow({ - yomitanExt: extension, - getExistingWindow: () => inactiveYomitanSettingsWindow, - setWindow: (window) => { - inactiveYomitanSettingsWindow = window; - }, - }), - ) - .catch((error: unknown) => logger.error('Failed to open Yomitan settings', error)); - return true; - } - 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.', - ); - overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }); - return false; - } - openYomitanSettingsHandler(); - return true; -} - -function openHachidoriSettings(): void { - void ( - activeDictionaryBackend === 'hachidori' - ? ensureYomitanExtensionLoaded() - : hachidoriExtensionRuntime.ensureLoaded() - ) - .then((extension) => - openYomitanSettingsWindow({ - backend: 'hachidori', - yomitanExt: extension, - yomitanSession: getHachidoriSession(), - getExistingWindow: () => hachidoriSettingsWindow, - setWindow: (window) => { - hachidoriSettingsWindow = window; - }, - onWindowClosed: () => { - if (activeDictionaryBackend === 'hachidori' && appState.yomitanParserWindow) { - clearYomitanParserCachesForWindow(appState.yomitanParserWindow); - } - }, - }), - ) - .catch((error: unknown) => logger.error('Failed to open Hachidori settings', error)); -} - -function openDictionarySettings(): boolean { - if (activeDictionaryBackend === 'hachidori') { - openHachidoriSettings(); - return true; - } - return openYomitanSettings(); -} - const { exportLogsFromTray } = createLogExportTrayRuntime({ flushMpvLog: () => flushMpvLog(), logInfo: (message) => logger.info(message), logWarn: (message, details) => logger.warn(message, details), }); -const { - getConfiguredShortcuts, - registerGlobalShortcuts, - refreshGlobalAndOverlayShortcuts, - cancelPendingMultiCopy, - startPendingMultiCopy, - cancelPendingMineSentenceMultiple, - startPendingMineSentenceMultiple, - syncOverlayShortcuts, - refreshOverlayShortcuts, -} = composeShortcutRuntimes({ - globalShortcuts: { - getConfiguredShortcutsMainDeps: { - getResolvedConfig: () => configService.getConfig(), - defaultConfig: DEFAULT_CONFIG, - resolveConfiguredShortcuts, - }, - buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcutsHandler) => ({ - getConfiguredShortcuts: () => getConfiguredShortcutsHandler(), - registerGlobalShortcutsCore, - onToggleVisibleOverlay: () => toggleVisibleOverlay(), - onOpenYomitanSettings: () => openDictionarySettings(), - isDev, - getMainWindow: () => overlayManager.getMainWindow(), - }), - buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcutsHandler) => ({ - unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), - registerGlobalShortcuts: () => registerGlobalShortcutsHandler(), - syncOverlayShortcuts: () => syncOverlayShortcuts(), - }), - }, - numericShortcutRuntimeMainDeps: { - globalShortcut, - showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), - setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), - clearTimer: (timer) => clearTimeout(timer), - }, - numericSessions: { - onMultiCopyDigit: (count) => handleMultiCopyDigitHandler(count), - onMineSentenceDigit: (count) => handleMineSentenceDigitHandler(count), - tryBeginMultiCopyOverlaySelection: (timeoutMs) => - tryBeginVisibleOverlayNumericSelection({ - actionId: 'copySubtitleMultiple', - timeoutMs, - getMainWindow: () => overlayManager.getMainWindow(), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - }), - tryBeginMineSentenceOverlaySelection: (timeoutMs) => - tryBeginVisibleOverlayNumericSelection({ - actionId: 'mineSentenceMultiple', - timeoutMs, - getMainWindow: () => overlayManager.getMainWindow(), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - }), - }, - overlayShortcutsRuntimeMainDeps: { - overlayShortcutsRuntime, - }, -}); - -const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } = - createSessionBindingsRuntime({ - configDir: CONFIG_DIR, - getKeybindings: () => appState.keybindings, - getConfiguredShortcuts: () => getConfiguredShortcuts(), - getResolvedConfig: () => configService.getConfig(), - getMpvClient: () => appState.mpvClient, - setSessionBindings: (bindings) => { - appState.sessionBindings = bindings; - }, - setSessionBindingsInitialized: (initialized) => { - appState.sessionBindingsInitialized = initialized; - }, - logWarn: (message) => logger.warn(message), - onBindingsChanged: (bindings) => - overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings), - onMpvInputBindingsChanged: () => - overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.mpvInputBindingsChanged), - onWarning: (warning) => { - if (warning.kind !== 'conflict') return; - overlayNotificationsRuntime.showOverlayNotification({ - id: `session-binding-conflict:${warning.path}`, - title: 'Shortcut conflict', - body: warning.message, - variant: 'warning', - }); - }, - }); - const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ appendToMpvLogMainDeps: { getLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''), @@ -2948,66 +1918,6 @@ function setSecondarySubMode(mode: SecondarySubMode): void { appState.secondarySubMode = mode; } -const updateLastCardFromClipboardHandler = createUpdateLastCardFromClipboardHandler({ - getAnkiIntegration: () => appState.ankiIntegration, - readClipboardText: () => clipboard.readText(), - showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), - updateLastCardFromClipboardCore, -}); - -const refreshKnownWordCacheHandler = createRefreshKnownWordCacheHandler({ - getAnkiIntegration: () => appState.ankiIntegration, - missingIntegrationMessage: 'AnkiConnect integration not enabled', -}); - -const triggerFieldGroupingHandler = createTriggerFieldGroupingHandler({ - getAnkiIntegration: () => appState.ankiIntegration, - showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), - triggerFieldGroupingCore, -}); - -const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler({ - getAnkiIntegration: () => appState.ankiIntegration, - showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), - markLastCardAsAudioCardCore, -}); - -const mineSentenceCardHandler = createMineSentenceCardHandler({ - getAnkiIntegration: () => appState.ankiIntegration, - getMpvClient: () => appState.mpvClient, - getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(), - showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), - mineSentenceCardCore, -}); - -const handleMultiCopyDigitHandler = createHandleMultiCopyDigitHandler({ - getSubtitleTimingTracker: () => appState.subtitleTimingTracker, - writeClipboardText: (text) => clipboard.writeText(text), - showMpvOsd: (text) => showConfiguredPlaybackFeedback(text), - handleMultiCopyDigitCore, -}); - -const copyCurrentSubtitleHandler = createCopyCurrentSubtitleHandler({ - getSubtitleTimingTracker: () => appState.subtitleTimingTracker, - writeClipboardText: (text) => clipboard.writeText(text), - showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), - copyCurrentSubtitleCore, -}); - -const handleMineSentenceDigitHandler = createHandleMineSentenceDigitHandler({ - getSubtitleTimingTracker: () => appState.subtitleTimingTracker, - getAnkiIntegration: () => appState.ankiIntegration, - getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined, - showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), - logError: (message, err) => { - logger.error(message, err); - }, - handleMineSentenceDigitCore, -}); -const handleOverlayModalClosedHandler = createHandleOverlayModalClosedHandler({ - handleOverlayModalClosedRuntime: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal), -}); - const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHandler({ appendClipboardVideoToQueueRuntime, getMpvClient: () => appState.mpvClient, @@ -3020,29 +1930,15 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise { await dispatchSessionActionCore(request, { - toggleStatsOverlay: async () => - await toggleStatsOverlayWindow({ - preloadPath: statsPreloadPath, - getApiBaseUrl: async () => (await ensureStatsServerStarted()).url, - onStartupError: (error) => - overlayNotificationsRuntime.showConfiguredStatusNotification( - `Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`, - { title: 'Stats' }, - ), - getToggleKey: () => configService.getConfig().stats.toggleKey, - resolveBounds: () => overlayGeometryRuntime.getCurrentOverlayGeometry(), - onVisibilityChanged: (visible) => { - visibleOverlayInteractionRuntime.handleStatsOverlayVisibilityChanged(visible); - }, - }), + toggleStatsOverlay: async () => await stats.toggleStatsOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(), - copyCurrentSubtitle: () => copyCurrentSubtitleHandler(), - copySubtitleCount: (count) => handleMultiCopyDigitHandler(count), - updateLastCardFromClipboard: () => updateLastCardFromClipboardHandler(), - triggerFieldGrouping: () => triggerFieldGroupingHandler(), + copyCurrentSubtitle: () => mining.copyCurrentSubtitle(), + copySubtitleCount: (count) => mining.copySubtitleCount(count), + updateLastCardFromClipboard: () => mining.updateLastCardFromClipboard(), + triggerFieldGrouping: () => mining.triggerFieldGrouping(), triggerSubsyncFromConfig: () => subsyncRuntime.triggerFromConfig(), - mineSentenceCard: () => mineSentenceCardHandler(), - mineSentenceCount: (count) => handleMineSentenceDigitHandler(count), + mineSentenceCard: () => mining.mineSentenceCard(), + mineSentenceCount: (count) => mining.mineSentenceCount(count), toggleSecondarySub: () => cycleSecondarySubMode(), toggleSubtitleSidebar: () => overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle), @@ -3050,19 +1946,8 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro appendClipboardVideoToQueue: () => { appendClipboardVideoToQueueHandler(); }, - markLastCardAsAudioCard: () => markLastCardAsAudioCardHandler(), - markActiveVideoWatched: async () => { - ensureImmersionTrackerStarted(); - const marked = (await appState.immersionTracker?.markActiveVideoWatched()) ?? false; - if (marked) { - try { - await anilist.maybeRunAnilistPostWatchUpdate({ force: true }); - } catch (error) { - logger.warn('Failed to run AniList post-watch update after manual watched mark:', error); - } - } - return marked; - }, + markLastCardAsAudioCard: () => mining.markLastCardAsAudioCard(), + markActiveVideoWatched: () => stats.markActiveVideoWatched(), openRuntimeOptionsPalette: () => overlayModals.openRuntimeOptionsPalette(), openJimaku: () => overlayModals.openJimakuOverlay(), openTsukihime: () => overlayModals.openTsukihimeOverlay(), @@ -3144,99 +2029,16 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text), }, mainDeps: { - previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request), - getMediaTimingReviewFrame: (request) => mediaTimingReviewRuntime.getFrame(request), - getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request), - stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId), - resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request), - getMainWindow: () => overlayManager.getMainWindow(), - focusMainWindow: () => { - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) return; - if (process.platform === 'darwin') { - focusMacOSOverlayWindow({ - platform: process.platform, - getOverlayWindow: () => mainWindow, - stealAppFocus: () => app.focus({ steal: true }), - warn: (message, details) => logger.warn(message, details), - }); - return; - } - if (!mainWindow.isFocused()) { - mainWindow.focus(); - } - }, - activatePlaybackWindowForOverlayInteraction: async () => { - try { - const raised = (await appState.windowTracker?.raiseTargetWindow?.()) ?? false; - overlayGeometryRuntime.enforceOverlayLayerOrder(); - return raised; - } catch (error) { - logger.debug( - 'Failed to raise tracked mpv window for overlay interaction:', - error instanceof Error ? error.message : String(error), - ); - overlayGeometryRuntime.enforceOverlayLayerOrder(); - return false; - } - }, - onOverlayModalClosed: (modal, senderWindow, instanceId) => { - // A late close for a replaced plugin panel must not hide the panel now showing. - if ( - modal === 'plugin-panel' && - instanceId !== undefined && - !pluginPanelRuntime.handleModalClosed(instanceId) - ) { - return; - } - if (modal === 'media-timing-review') { - void mediaTimingReviewRuntime.dispose(); - } - if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) { - subtitleSidebarRequestedOpen = false; - } - const modalWindow = overlayManager.getModalWindow(); - if ( - senderWindow && - modalWindow && - senderWindow === modalWindow && - !senderWindow.isDestroyed() - ) { - applyOverlayClickThrough(senderWindow); - senderWindow.hide(); - } - handleOverlayModalClosedHandler(modal); - }, - onOverlayModalOpened: (modal, senderWindow) => { - if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) { - subtitleSidebarRequestedOpen = true; - } - overlayModalRuntime.notifyOverlayModalOpened(modal); - }, - onOverlayMouseInteractionChanged: (active, senderWindow) => { - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || senderWindow !== mainWindow) { - return; - } - const previousActive = - visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive(); - visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active); - if (previousActive === active) { - if (active && process.platform === 'darwin' && !mainWindow.isFocused()) { - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - } - return; - } - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - }, - onOverlayInteractiveHint: (interactive, senderWindow) => { - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || senderWindow !== mainWindow) { - return; - } - visibleOverlayInteractionRuntime.setLinuxOverlayInteractiveHint(interactive); - visibleOverlayInteractionRuntime.applyLinuxOverlayInputShapeFromLatestMeasurement(); - }, + ...overlay.ipc, + ...playback.ipc, + ...mining.ipc, + ...shortcuts.ipc, + ...anilist.ipc, + ...characterDictionary.ipc, + ...stats.ipc, + ...playlistBrowserMainDeps, + // App-level handlers with no owning assembly. + onYoutubePickerResolve: (request) => youtube.flow.resolveActivePicker(request), handleOverlayNotificationAction: (notificationId, actionId, noteId) => { if ( notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID && @@ -3270,131 +2072,13 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ }); } }, - onYoutubePickerResolve: (request) => youtube.flow.resolveActivePicker(request), - // Overlay lookups carry no cue context of their own; fall back to snapshotting the - // live mpv sub timings at lookup time so media generation clips the mined line even - // when extraction finishes long after playback has moved on. - recordSubtitleMiningContext: (context) => - recordSubtitleMiningContext( - context - ? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0) - : captureCurrentPrimarySubtitleMiningContext(), - ), - tokenizeCurrentSubtitle: async () => { - const tokenizeSubtitleForCurrent = getTokenizeSubtitleDeferred(); - return resolveCurrentSubtitleForRenderer({ - currentSubText: appState.currentSubText, - currentSubtitleData: appState.currentSubtitleData, - withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload), - tokenizeUncached: false, - tokenizeSubtitle: tokenizeSubtitleForCurrent - ? (text) => tokenizeSubtitleForCurrent(text) - : undefined, - onResolvedSubtitle: (payload) => { - appState.currentSubtitleData = payload; - autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true }); - }, - }); - }, - getCurrentSubtitleRaw: () => appState.currentSubText, - getSubtitleSidebarOpen: () => subtitleSidebarRequestedOpen, - getSubtitleSidebarPlaybackState: () => getSubtitleSidebarPlaybackState(), - // Full snapshot: resolves the active source over mpv IPC and re-parses it when it changed. - // The renderer fetches it on open, on source/cue changes, and on a slow fallback cadence. - getSubtitleSidebarSnapshot: async () => { - const config = configService.getConfig().subtitleSidebar; - const cachedSnapshot = () => ({ - ...getSubtitleSidebarPlaybackState(), - cues: appState.activeParsedSubtitleCues, - config, - }); - const client = appState.mpvClient; - if (!client?.connected) { - return cachedSnapshot(); - } - - 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 cachedSnapshot(); - } - if ( - shouldUseCachedYoutubeParsedCues({ - videoPath, - cachedMediaPath: appState.activeParsedSubtitleMediaPath, - cachedCueCount: appState.activeParsedSubtitleCues.length, - }) - ) { - return cachedSnapshot(); - } - - const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({ - currentExternalFilenameRaw, - currentTrackRaw, - trackListRaw, - sidRaw, - videoPath, - }); - if (!resolvedSource) { - return cachedSnapshot(); - } - - try { - if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) { - return cachedSnapshot(); - } - - const content = await loadSubtitleSourceText(resolvedSource.path); - const cues = parseSubtitleCues(content, resolvedSource.path); - appState.activeParsedSubtitleCues = cues; - appState.activeParsedSubtitleSource = resolvedSource.sourceKey; - appState.activeParsedSubtitleMediaPath = videoPath || null; - return cachedSnapshot(); - } finally { - await resolvedSource.cleanup?.(); - } - } catch { - return cachedSnapshot(); - } - }, - getPlaybackPaused: () => - resolveFreshPlaybackPaused({ - getCachedPlaybackPaused: () => appState.playbackPaused, - getMpvClient: () => appState.mpvClient, - }), + dispatchSessionAction: (request) => dispatchSessionAction(request), getSubtitlePosition: () => loadSubtitlePosition(), + saveSubtitlePosition: (position) => saveSubtitlePosition(position), getSubtitleStyle: () => { const resolvedConfig = configService.getConfig(); return resolveSubtitleStyleForRenderer(resolvedConfig); }, - saveSubtitlePosition: (position) => saveSubtitlePosition(position), - getMpvInputBindings: async () => { - await refreshMpvSessionBindings(); - return readMpvInputBindings({ - getMpvClient: () => appState.mpvClient, - getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [], - platform: - process.platform === 'darwin' - ? 'darwin' - : process.platform === 'win32' - ? 'win32' - : 'linux', - }); - }, - getSessionBindings: async () => { - await refreshMpvSessionBindings(); - return appState.sessionBindings; - }, - getConfiguredShortcuts: () => getConfiguredShortcuts(), - dispatchSessionAction: (request) => dispatchSessionAction(request), getStatsToggleKey: () => configService.getConfig().stats.toggleKey, getMarkWatchedKey: () => configService.getConfig().stats.markWatchedKey, getOverlayNotificationPosition: () => configService.getConfig().notifications.overlayPosition, @@ -3406,107 +2090,16 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ }); }, getSecondarySubMode: () => appState.secondarySubMode, - getMpvClient: () => appState.mpvClient, getRuntimeOptions: () => getRuntimeOptionsStateHandler(), - reportOverlayContentBounds: (payload: unknown) => { - if (overlayContentMeasurementStore.report(payload)) { - visibleOverlayInteractionRuntime.tickLinuxOverlayPointerInteractionNow(); - visibleOverlayInteractionRuntime.tickWindowsOverlayPointerInteractionNow(); - visibleOverlayInteractionRuntime.primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); - autoplayReadyGate.flushPendingAutoplayReadySignal(); - autoplaySubtitlePrimingRuntime.scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(); - } - }, - getAnilistStatus: () => anilist.stateRuntime.getStatusSnapshot(), - clearAnilistToken: () => anilist.stateRuntime.clearTokenState(), - openAnilistSetup: () => anilist.openSetupWindow(), - getAnilistQueueStatus: () => anilist.stateRuntime.getQueueStatusSnapshot(), - retryAnilistQueueNow: () => anilist.processNextAnilistRetryUpdate(), - runAnilistPostWatchUpdateOnManualMark: () => - anilist.maybeRunAnilistPostWatchUpdate({ force: true }), - getCharacterDictionarySelection: (searchTitle?: string) => - characterDictionary.runtime.getManualSelectionSnapshot(undefined, searchTitle), - setCharacterDictionarySelection: async ( - mediaId: number, - replaceManagedMediaId?: number, - mediaTitle?: string, - ) => { - if (replaceManagedMediaId !== undefined && mediaTitle) { - const result = replaceCharacterDictionaryManagedEntry( - USER_DATA_PATH, - replaceManagedMediaId, - { - mediaId, - mediaTitle, - }, - ); - if (result.ok && result.rebuildRequired) { - try { - await characterDictionary.autoSync.runSyncNow(); - } catch (error) { - logger.warn('Failed to rebuild character dictionary after manager override:', error); - } - } - return result; - } - return await applyCharacterDictionarySelection( - { mediaId }, - { - setManualSelection: (request) => - characterDictionary.runtime.setManualSelection(request), - resetAnilistMediaGuessState: anilist.resetAnilistMediaGuessState, - runSyncNow: () => characterDictionary.autoSync.runSyncNow(), - warn: (message, error) => logger.warn(message, error), - }, - ); - }, - getCharacterDictionaryManagerSnapshot: async () => - getCharacterDictionaryManagerSnapshot( - USER_DATA_PATH, - characterDictionary.autoSync.getCurrentMediaId(), - ), - removeCharacterDictionaryManagedEntry: async (mediaId: number) => { - const result = removeCharacterDictionaryManagedEntry( - USER_DATA_PATH, - mediaId, - characterDictionary.autoSync.getCurrentMediaId(), - ); - if (result.ok && result.rebuildRequired) { - try { - await characterDictionary.autoSync.runSyncNow(); - } catch (error) { - logger.warn('Failed to rebuild character dictionary after manager removal:', error); - } - } - return result; - }, - moveCharacterDictionaryManagedEntry: async (mediaId: number, direction: 1 | -1) => { - const result = moveCharacterDictionaryManagedEntry( - USER_DATA_PATH, - mediaId, - direction, - characterDictionary.autoSync.getCurrentMediaId(), - ); - if (result.ok && result.rebuildRequired) { - try { - await characterDictionary.autoSync.runSyncNow(); - } catch (error) { - logger.warn('Failed to rebuild character dictionary after manager reorder:', error); - } - } - return result; - }, getChangelogSnapshot: (options) => getChangelogSnapshot(options), - ...playlistBrowserMainDeps, - getImmersionTracker: () => appState.immersionTracker, - }, + } satisfies Required, ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({ getResolvedConfig: () => configService.getConfig(), getMpvClient: () => appState.mpvClient, getAnkiIntegration: () => appState.ankiIntegration, - getFieldGroupingResolver: () => getFieldGroupingResolverHandler(), + getFieldGroupingResolver: () => mining.getFieldGroupingResolver(), setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => - setFieldGroupingResolverHandler(resolver), + mining.setFieldGroupingResolver(resolver), parseMediaInfo: (mediaPath: string | null) => parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath)), getCurrentMediaPath: () => appState.currentMediaPath, @@ -3530,6 +2123,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ }); const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ cliCommandContextMainDeps: { + ...mining.cli, + ...shortcuts.cli, + ...anilist.cli, + ...characterDictionary.cli, + ...stats.cli, + // App-level commands with no owning assembly. appState, setLogLevel: (level) => setLogLevel(level, 'cli'), onMpvSocketPathChanged: (nextSocketPath, previousSocketPath) => @@ -3548,48 +2147,15 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ showMpvOsd: (text: string) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text), - initializeOverlayRuntime: () => initializeOverlayRuntime(), + initializeOverlayRuntime: () => startup.initializeOverlayRuntime(), toggleVisibleOverlay: () => toggleVisibleOverlay(), togglePrimarySubtitleBar: () => overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.primarySubtitleBarToggle), openFirstRunSetupWindow: (force?: boolean) => firstRunSetup.openWindow(force), setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), - copyCurrentSubtitle: () => copyCurrentSubtitleHandler(), - startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), - mineSentenceCard: () => mineSentenceCardHandler(), - startPendingMineSentenceMultiple: (timeoutMs: number) => - startPendingMineSentenceMultiple(timeoutMs), - updateLastCardFromClipboard: () => updateLastCardFromClipboardHandler(), - refreshKnownWordCache: () => refreshKnownWordCacheHandler(), - triggerFieldGrouping: () => triggerFieldGroupingHandler(), triggerSubsyncFromConfig: () => subsyncRuntime.triggerFromConfig(), - markLastCardAsAudioCard: () => markLastCardAsAudioCardHandler(), - getAnilistStatus: () => anilist.stateRuntime.getStatusSnapshot(), - clearAnilistToken: () => anilist.stateRuntime.clearTokenState(), - openAnilistSetupWindow: () => anilist.openSetupWindow(), openJellyfinSetupWindow: () => jellyfin.openJellyfinSetupWindow(), - getAnilistQueueStatus: () => anilist.stateRuntime.getQueueStatusSnapshot(), - processNextAnilistRetryUpdate: () => anilist.processNextAnilistRetryUpdate(), - generateCharacterDictionary: async (targetPath?: string) => { - const disabledReason = yomitanProfilePolicy.getCharacterDictionaryDisabledReason(); - if (disabledReason) { - throw new Error(disabledReason); - } - return await characterDictionary.runtime.generateForCurrentMedia(targetPath); - }, - getCharacterDictionarySelection: async (targetPath?: string) => - characterDictionary.runtime.getManualSelectionSnapshot(targetPath), - setCharacterDictionarySelection: async (request) => - applyCharacterDictionarySelection(request, { - setManualSelection: (selectionRequest) => - characterDictionary.runtime.setManualSelection(selectionRequest), - resetAnilistMediaGuessState: anilist.resetAnilistMediaGuessState, - runSyncNow: () => characterDictionary.autoSync.runSyncNow(), - warn: (message, error) => logger.warn(message, error), - }), runJellyfinCommand: (argsFromCommand: CliArgs) => jellyfin.runJellyfinCommand(argsFromCommand), - runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) => - runStatsCliCommand(argsFromCommand, source), runUpdateCommand: async (argsFromCommand: CliArgs, source: CliCommandSource) => { await runUpdateCliCommand(argsFromCommand, source, { checkForUpdates: (request) => getUpdateService().checkForUpdates(request), @@ -3614,9 +2180,8 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ ); }, runYoutubePlaybackFlow: (request) => youtube.playback.runYoutubePlaybackFlow(request), - ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(), - openHachidoriSettings: () => openHachidoriSettings(), - openYomitanSettings: () => openYomitanSettings(), + openHachidoriSettings: () => yomitan.openHachidoriSettings(), + openYomitanSettings: () => yomitan.openYomitanSettings(), openConfigSettingsWindow: () => settings.window.openWindow(), openSyncUiWindow: () => sync.openWindow(), openYoutubeBrowserWindow: () => youtube.browserWindow.open(), @@ -3627,17 +2192,16 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ hasMainWindow: () => Boolean(overlayManager.getMainWindow()), dispatchSessionAction: (request: SessionActionDispatchRequest) => dispatchSessionAction(request), - getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), logInfo: (message: string) => logger.info(message), logDebug: (message: string) => logger.debug(message), logWarn: (message: string) => logger.warn(message), logError: (message: string, err: unknown) => logger.error(message, err), - }, + } satisfies Required, cliCommandRuntimeHandlerMainDeps: { handleTexthookerOnlyModeTransitionMainDeps: { isTexthookerOnlyMode: () => appState.texthookerOnlyMode, - ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + ensureOverlayStartupPrereqs: () => startup.ensureOverlayStartupPrereqs(), setTexthookerOnlyMode: (enabled) => { appState.texthookerOnlyMode = enabled; }, @@ -3648,7 +2212,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ }, ensureTrayForCommand: (args) => { if (args.background || args.managedPlayback) { - ensureTrayHandler(); + tray.ensureTray(); } }, handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) => @@ -3660,15 +2224,15 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ shouldEnsureTrayOnStartup: () => shouldEnsureTrayOnStartupForInitialArgs(process.platform, appState.initialArgs), shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args), - ensureTray: () => ensureTrayHandler(), + ensureTray: () => tray.ensureTray(), isTexthookerOnlyMode: () => appState.texthookerOnlyMode, hasImmersionTracker: () => Boolean(appState.immersionTracker), getMpvClient: () => appState.mpvClient, commandNeedsOverlayStartupPrereqs: (args) => commandNeedsOverlayStartupPrereqs(args), commandNeedsOverlayRuntime: (args) => commandNeedsOverlayRuntime(args), - ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + ensureOverlayStartupPrereqs: () => startup.ensureOverlayStartupPrereqs(), isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - initializeOverlayRuntime: () => initializeOverlayRuntime(), + initializeOverlayRuntime: () => startup.initializeOverlayRuntime(), logInfo: (message) => logger.info(message), }, }); @@ -3697,14 +2261,14 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers< }); return () => server.close(); }, - onReady: runAppReadyRuntimeWithFatalReporting, + onReady: () => startup.runAppReady(), onWillQuitCleanup: () => onWillQuitCleanupHandler(), shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(), restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(), shouldQuitOnWindowAllClosed: () => shouldQuitOnWindowAllClosedForTrayState({ backgroundMode: appState.backgroundMode, - hasTray: Boolean(appTray), + hasTray: tray.hasTray(), }), }, createAppLifecycleRuntimeRunner: (params) => createAppLifecycleRuntimeRunner(params), @@ -3783,229 +2347,6 @@ if (!appState.initialArgs || (!shouldUseMinimalStartup && !shouldSkipHeavyStartu logger.error('Failed to initialize Discord presence service during startup', error); }); } -function refreshTrayMenuIfPresent(): void { - if (appTray) { - ensureTrayHandler(); - } -} - -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, - platform: process.platform, - initializeOverlayRuntime: () => initializeOverlayRuntime(), - isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - openSessionHelpModal: () => overlayModals.openSessionHelpOverlay(), - openChangelogModal: () => overlayModals.openChangelogOverlay(), - openTexthookerInBrowser: () => - handleCliCommand(parseArgs(['--texthooker', '--open-browser'])), - showTexthookerPage: () => shouldShowTexthookerTrayEntry(configService.getConfig()), - showFirstRunSetup: () => !firstRunSetup.service.isSetupCompleted(), - openFirstRunSetupWindow: (force?: boolean) => firstRunSetup.openWindow(force), - showWindowsMpvLauncherSetup: () => process.platform === 'win32', - getDictionaryBackend: () => activeDictionaryBackend, - openHachidoriSettings: () => openHachidoriSettings(), - openYomitanSettings: () => openYomitanSettings(), - openConfigSettingsWindow: () => settings.window.openWindow(), - openSyncUiWindow: () => sync.openWindow(), - openYoutubeBrowserWindow: () => youtube.browserWindow.open(), - exportLogs: () => { - void exportLogsFromTray(); - }, - openJellyfinSetupWindow: () => jellyfin.openJellyfinSetupWindow(), - isJellyfinConfigured: () => - isJellyfinConfiguredForTrayRuntime(jellyfin.getTrayDiscoveryDeps()), - isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession), - toggleJellyfinDiscovery: (checked: boolean) => - toggleJellyfinDiscoveryFromTrayRuntime(jellyfin.getTrayDiscoveryDeps(), { - desiredActive: checked, - }), - openAnilistSetupWindow: () => anilist.openSetupWindow(), - checkForUpdates: () => { - void getUpdateService().checkForUpdates({ source: 'manual' }); - }, - 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: - activeDictionaryBackend === 'yomitan' - ? configService.getConfig().yomitan.externalProfilePath - : '', - logInfo: (message) => logger.info(message), -}); -const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath; -const yomitanExtensionRuntime = createYomitanExtensionRuntime({ - loadYomitanExtensionCore: async (deps) => { - if (activeDictionaryBackend === 'yomitan') return loadYomitanExtensionCore(deps); - try { - const extension = await hachidoriExtensionRuntime.ensureLoaded(); - deps.setYomitanExtension(extension); - deps.setYomitanSession(getHachidoriSession()); - return extension; - } catch (error) { - logger.error('Failed to load Hachidori extension:', error); - deps.setYomitanExtension(null); - deps.setYomitanSession(null); - return null; - } - }, - userDataPath: USER_DATA_PATH, - externalProfilePath: configuredExternalYomitanProfilePath, - getYomitanParserWindow: () => appState.yomitanParserWindow, - setYomitanParserWindow: (window) => { - appState.yomitanParserWindow = window as BrowserWindow | null; - }, - setYomitanParserReadyPromise: (promise) => { - appState.yomitanParserReadyPromise = promise; - }, - setYomitanParserInitPromise: (promise) => { - appState.yomitanParserInitPromise = promise; - }, - setYomitanExtension: (extension) => { - appState.yomitanExt = extension; - }, - setYomitanSession: (nextSession) => { - appState.yomitanSession = nextSession; - }, - getYomitanExtension: () => appState.yomitanExt, - getLoadInFlight: () => yomitanLoadInFlight, - setLoadInFlight: (promise) => { - yomitanLoadInFlight = promise; - }, - onYomitanExtensionLoaded: () => { - const reloaded = reloadOverlayWindowsForYomitanContentScripts( - overlayManager.getOverlayWindows(), - (message, error) => logger.warn(message, error), - ); - if (reloaded > 0) { - logger.debug(`Reloaded ${reloaded} overlay window(s) after Yomitan extension load.`); - } - }, -}); -const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = - runtimeRegistry.overlay.createOverlayRuntimeBootstrapHandlers({ - initializeOverlayRuntimeMainDeps: { - appState, - overlayManager: { - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - }, - overlayVisibilityRuntime: { - updateVisibleOverlayVisibility: () => - overlayVisibilityRuntime.updateVisibleOverlayVisibility(), - }, - refreshCurrentSubtitle: () => { - subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); - }, - 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) => - visibleOverlayInteractionRuntime.createOverlayWindowTracker(override, targetMpvSocketPath), - updateVisibleOverlayBounds: (geometry: WindowGeometry) => - overlayGeometryRuntime.updateVisibleOverlayBounds(geometry), - bindOverlayOwner: () => visibleOverlayInteractionRuntime.bindVisibleOverlayOwner(), - releaseOverlayOwner: () => visibleOverlayInteractionRuntime.releaseVisibleOverlayOwner(), - getOverlayWindows: () => overlayManager.getOverlayWindows(), - getResolvedConfig: () => configService.getConfig(), - showDesktopNotification, - showOverlayNotification: (payload) => - overlayNotificationsRuntime.showOverlayNotification(payload), - dismissOverlayNotification: (id) => - overlayNotificationsRuntime.dismissOverlayNotification(id), - createFieldGroupingCallback: () => createFieldGroupingCallback(), - getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), - getCachedMediaPath: (currentVideoPath, kind) => - youtube.getCachedMediaPathForCurrentPlayback(currentVideoPath, kind), - shouldRequireRemoteMediaCache: () => youtube.shouldRequireMediaCacheForCurrentPlayback(), - getYoutubeMediaSourceUrl: () => youtube.getCurrentMediaCacheSourceUrl(), - 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(), - getYomitanExtension: () => appState.yomitanExt, - getYomitanExtensionLoadInFlight: () => yomitanLoadInFlight, - 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; - }, - logWarn: (message) => logger.warn(message), - logError: (message, error) => logger.error(message, error), -}); - registerIpcRuntimeHandlers(); registerSubtitleSelectionIpc({ ipc: ipcMain, diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index 8c38c797..de926806 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -52,7 +52,7 @@ export interface AppReadyRuntimeDepsFactoryInput { handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup']; prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries']; startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups']; - texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode']; + isTexthookerOnlyMode: AppReadyRuntimeDeps['isTexthookerOnlyMode']; shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig']; setVisibleOverlayVisible: AppReadyRuntimeDeps['setVisibleOverlayVisible']; initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime']; @@ -123,7 +123,7 @@ export function createAppReadyRuntimeDeps( handleFirstRunSetup: params.handleFirstRunSetup, prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries, startBackgroundWarmups: params.startBackgroundWarmups, - texthookerOnlyMode: params.texthookerOnlyMode, + isTexthookerOnlyMode: params.isTexthookerOnlyMode, shouldAutoInitializeOverlayRuntimeFromConfig: params.shouldAutoInitializeOverlayRuntimeFromConfig, setVisibleOverlayVisible: params.setVisibleOverlayVisible, diff --git a/src/main/assemblies/anilist.ts b/src/main/assemblies/anilist.ts index 7f2186e5..f88e92db 100644 --- a/src/main/assemblies/anilist.ts +++ b/src/main/assemblies/anilist.ts @@ -29,6 +29,7 @@ import { } from '../runtime/domains/anilist'; import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime'; import { createCreateAnilistSetupWindowHandler } from '../runtime/setup-window-factory'; +import type { CliSlice, IpcSlice } from './slices'; import { type AnilistMediaGuessRuntimeState, type AppState, @@ -507,7 +508,39 @@ export function createAnilistAssembly(deps: AnilistAssemblyDeps) { ); }; + const ipc: IpcSlice< + | 'getAnilistStatus' + | 'clearAnilistToken' + | 'openAnilistSetup' + | 'getAnilistQueueStatus' + | 'retryAnilistQueueNow' + | 'runAnilistPostWatchUpdateOnManualMark' + > = { + getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), + clearAnilistToken: () => anilistStateRuntime.clearTokenState(), + openAnilistSetup: () => openAnilistSetupWindowHandler(), + getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), + retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), + runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }), + }; + + const cli: CliSlice< + | 'getAnilistStatus' + | 'clearAnilistToken' + | 'openAnilistSetupWindow' + | 'getAnilistQueueStatus' + | 'processNextAnilistRetryUpdate' + > = { + getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), + clearAnilistToken: () => anilistStateRuntime.clearTokenState(), + openAnilistSetupWindow: () => openAnilistSetupWindowHandler(), + getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), + processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), + }; + return { + ipc, + cli, stateRuntime: anilistStateRuntime, openSetupWindow: openAnilistSetupWindowHandler, handleAnilistSetupProtocolUrl, diff --git a/src/main/assemblies/character-dictionary.test.ts b/src/main/assemblies/character-dictionary.test.ts index d6f6b672..7434a605 100644 --- a/src/main/assemblies/character-dictionary.test.ts +++ b/src/main/assemblies/character-dictionary.test.ts @@ -38,6 +38,7 @@ function makeDeps(): CharacterDictionaryAssemblyDeps { refreshCurrentSubtitle: lazy('refreshCurrentSubtitle'), refreshSubtitlePrefetch: lazy('refreshSubtitlePrefetch'), refreshCurrentSubtitleAnnotations: lazy('refreshCurrentSubtitleAnnotations'), + resetAnilistMediaGuessState: lazy('resetAnilistMediaGuessState'), }; } @@ -46,3 +47,21 @@ test('createCharacterDictionaryAssembly does not call its deps while constructin const characterDictionary = createCharacterDictionaryAssembly(makeDeps()); assert.equal(characterDictionary.autoSync.getCurrentMediaId(), null); }); + +test('character dictionary generation from the CLI refuses while the dictionary is disabled', async () => { + const deps = makeDeps(); + const characterDictionary = createCharacterDictionaryAssembly({ + ...deps, + getYomitanProfilePolicy: () => ({ + isCharacterDictionaryEnabled: () => false, + getCharacterDictionaryDisabledReason: () => 'external profile is read-only', + isExternalReadOnlyMode: () => true, + logSkippedWrite: () => {}, + }), + }); + + await assert.rejects( + characterDictionary.cli.generateCharacterDictionary(), + /external profile is read-only/, + ); +}); diff --git a/src/main/assemblies/character-dictionary.ts b/src/main/assemblies/character-dictionary.ts index d44ef67b..045c29ee 100644 --- a/src/main/assemblies/character-dictionary.ts +++ b/src/main/assemblies/character-dictionary.ts @@ -11,7 +11,14 @@ import type { ResolvedConfig } from '../../types'; import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime'; import { createCharacterDictionaryImageLookup } from '../character-dictionary-runtime/image-lookup'; import { createCharacterNameCandidateLookup } from '../character-dictionary-runtime/name-candidates'; -import { createCharacterDictionaryAutoSyncRuntimeService } from '../runtime/character-dictionary-auto-sync'; +import { applyCharacterDictionarySelection } from '../character-dictionary-selection'; +import { + createCharacterDictionaryAutoSyncRuntimeService, + getCharacterDictionaryManagerSnapshot, + moveCharacterDictionaryManagedEntry, + removeCharacterDictionaryManagedEntry, + replaceCharacterDictionaryManagedEntry, +} from '../runtime/character-dictionary-auto-sync'; import { handleCharacterDictionaryAutoSyncComplete } from '../runtime/character-dictionary-auto-sync-completion'; import { notifyCharacterDictionaryAutoSyncStatus, @@ -22,6 +29,7 @@ import { assertYomitanDictionaryMutationSafe } from '../runtime/yomitan-dictiona import type { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy'; import { formatSkippedYomitanWriteAction } from '../runtime/yomitan-read-only-log'; import type { AppState } from '../state'; +import type { CliSlice, IpcSlice } from './slices'; type YomitanParserRuntimeDeps = Parameters[0]; @@ -46,7 +54,10 @@ export interface CharacterDictionaryAssemblyDeps { resolveMediaPathForJimaku: (mediaPath: string | null) => string | null; getYomitanProfilePolicy: () => Pick< ReturnType, - 'isCharacterDictionaryEnabled' | 'isExternalReadOnlyMode' | 'logSkippedWrite' + | 'isCharacterDictionaryEnabled' + | 'getCharacterDictionaryDisabledReason' + | 'isExternalReadOnlyMode' + | 'logSkippedWrite' >; isYoutubePlaybackActive: () => boolean; waitForMediaTokenizationReady: (mediaPath: string | null) => Promise; @@ -63,6 +74,8 @@ export interface CharacterDictionaryAssemblyDeps { /** Re-runs subtitle prefetch from the current playback position. */ refreshSubtitlePrefetch: () => void; refreshCurrentSubtitleAnnotations: () => void; + /** A manual selection invalidates AniList's guess for the current media. */ + resetAnilistMediaGuessState: () => void; } export type CharacterDictionaryAssembly = ReturnType; @@ -243,5 +256,94 @@ export function createCharacterDictionaryAssembly(deps: CharacterDictionaryAssem getCurrentMediaId: () => autoSync.getCurrentMediaId(), }); - return { runtime, autoSync, imageLookup, nameCandidates }; + const selectionDeps = { + setManualSelection: (request: Parameters[0]) => + runtime.setManualSelection(request), + resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(), + runSyncNow: () => autoSync.runSyncNow(), + warn: (message: string, error?: unknown) => logger.warn(message, error), + }; + + /** Manager edits that change the merged dictionary rebuild it; a failed rebuild is logged. */ + async function rebuildAfterManagerChange( + result: T, + change: 'override' | 'removal' | 'reorder', + ): Promise { + if (result.ok && result.rebuildRequired) { + try { + await autoSync.runSyncNow(); + } catch (error) { + logger.warn(`Failed to rebuild character dictionary after manager ${change}:`, error); + } + } + return result; + } + + const ipc: IpcSlice< + | 'getCharacterDictionarySelection' + | 'setCharacterDictionarySelection' + | 'getCharacterDictionaryManagerSnapshot' + | 'removeCharacterDictionaryManagedEntry' + | 'moveCharacterDictionaryManagedEntry' + > = { + getCharacterDictionarySelection: (searchTitle?: string) => + runtime.getManualSelectionSnapshot(undefined, searchTitle), + setCharacterDictionarySelection: async ( + mediaId: number, + replaceManagedMediaId?: number, + mediaTitle?: string, + ) => { + if (replaceManagedMediaId !== undefined && mediaTitle) { + return rebuildAfterManagerChange( + replaceCharacterDictionaryManagedEntry(deps.userDataPath, replaceManagedMediaId, { + mediaId, + mediaTitle, + }), + 'override', + ); + } + return await applyCharacterDictionarySelection({ mediaId }, selectionDeps); + }, + getCharacterDictionaryManagerSnapshot: async () => + getCharacterDictionaryManagerSnapshot(deps.userDataPath, autoSync.getCurrentMediaId()), + removeCharacterDictionaryManagedEntry: async (mediaId: number) => + rebuildAfterManagerChange( + removeCharacterDictionaryManagedEntry( + deps.userDataPath, + mediaId, + autoSync.getCurrentMediaId(), + ), + 'removal', + ), + moveCharacterDictionaryManagedEntry: async (mediaId: number, direction: 1 | -1) => + rebuildAfterManagerChange( + moveCharacterDictionaryManagedEntry( + deps.userDataPath, + mediaId, + direction, + autoSync.getCurrentMediaId(), + ), + 'reorder', + ), + }; + + const cli: CliSlice< + | 'generateCharacterDictionary' + | 'getCharacterDictionarySelection' + | 'setCharacterDictionarySelection' + > = { + generateCharacterDictionary: async (targetPath?: string) => { + const disabledReason = deps.getYomitanProfilePolicy().getCharacterDictionaryDisabledReason(); + if (disabledReason) { + throw new Error(disabledReason); + } + return await runtime.generateForCurrentMedia(targetPath); + }, + getCharacterDictionarySelection: async (targetPath?: string) => + runtime.getManualSelectionSnapshot(targetPath), + setCharacterDictionarySelection: async (request) => + applyCharacterDictionarySelection(request, selectionDeps), + }; + + return { runtime, autoSync, imageLookup, nameCandidates, ipc, cli }; } diff --git a/src/main/assemblies/mining.test.ts b/src/main/assemblies/mining.test.ts new file mode 100644 index 00000000..65991ab9 --- /dev/null +++ b/src/main/assemblies/mining.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { SubtitleMiningContext } from '../../types'; +import type { AppState } from '../state'; +import { createMiningAssembly, type MiningAssemblyDeps } from './mining'; + +type AnkiIntegrationLike = NonNullable; +type MpvClientLike = NonNullable; + +// Every callback throws, so a test fails if the assembly calls a dep it should only call later. +function makeDeps(overrides: Partial = {}): MiningAssemblyDeps { + const lazy = (name: string) => (): never => { + throw new Error(`${name} called unexpectedly`); + }; + return { + appState: { + mpvClient: null, + currentMediaPath: null, + activeParsedSubtitleCues: [], + ankiIntegration: null, + subtitleTimingTracker: null, + fieldGroupingResolver: null, + fieldGroupingResolverSequence: 0, + }, + getConfig: lazy('getConfig'), + logger: { warn: lazy('warn'), error: lazy('error') }, + overlayManager: { + getMainWindow: lazy('getMainWindow'), + getModalWindow: lazy('getModalWindow'), + getVisibleOverlayVisible: lazy('getVisibleOverlayVisible'), + }, + overlayModalRuntime: { + getRestoreVisibleOverlayOnModalClose: lazy('getRestoreVisibleOverlayOnModalClose'), + waitForModalOpen: lazy('waitForModalOpen'), + handleOverlayModalClosed: lazy('handleOverlayModalClosed'), + sendToActiveOverlayWindow: lazy('sendToActiveOverlayWindow'), + }, + youtube: { + getCachedMediaPathForCurrentPlayback: lazy('getCachedMediaPathForCurrentPlayback'), + shouldRequireMediaCacheForCurrentPlayback: lazy('shouldRequireMediaCacheForCurrentPlayback'), + }, + clipboard: { readText: lazy('readText'), writeText: lazy('writeText') }, + getOverlayNotifications: lazy('getOverlayNotifications'), + getOverlayModals: lazy('getOverlayModals'), + setVisibleOverlayVisible: lazy('setVisibleOverlayVisible'), + ensureOverlayStartupPrereqs: lazy('ensureOverlayStartupPrereqs'), + ensureOverlayWindowsReadyForVisibilityActions: lazy( + 'ensureOverlayWindowsReadyForVisibilityActions', + ), + ankiIntegrationCallbacks: { + recordCardsMined: lazy('recordCardsMined'), + getMiningProvenance: lazy('getMiningProvenance'), + onCardsReady: lazy('onCardsReady'), + onKnownWordCacheUpdated: lazy('onKnownWordCacheUpdated'), + generateSentenceFurigana: lazy('generateSentenceFurigana'), + }, + ...overrides, + }; +} + +const lookupContext: SubtitleMiningContext = { + source: 'overlay', + text: '今日はいい天気', + startTime: 3, + endTime: 5, + capturedAtMs: 1, +}; + +// main.ts builds the assembly before most siblings exist, so construction must stay lazy. +test('createMiningAssembly does not call its deps while constructing', () => { + const mining = createMiningAssembly(makeDeps()); + assert.equal(typeof mining.mineSentenceCard, 'function'); + assert.equal(typeof mining.attachToAnkiIntegration, 'function'); +}); + +test('a recorded lookup context reaches exactly one mined card', () => { + let consumeContext: (() => SubtitleMiningContext | null) | null = null; + const setters: string[] = []; + const integration = new Proxy( + {}, + { + get: (_target, name: string) => (callback: unknown) => { + setters.push(name); + if (name === 'setSubtitleMiningContextConsumer') { + consumeContext = callback as () => SubtitleMiningContext | null; + } + }, + }, + ) as AnkiIntegrationLike; + const mining = createMiningAssembly(makeDeps()); + + mining.attachToAnkiIntegration(integration); + mining.recordSubtitleMiningContext(lookupContext); + + assert.equal(setters.length, 7); + assert.ok(consumeContext); + const consume = consumeContext as () => SubtitleMiningContext | null; + assert.deepEqual(consume(), lookupContext); + assert.equal(consume(), null); +}); + +test('mining without parsed cues falls back to the live mpv line on the video clock', () => { + const mpvClient = { + currentSubText: 'こんにちは', + currentSubStart: 10, + currentSubEnd: 12, + currentSubDelay: 0.5, + currentTimePos: 11, + } as MpvClientLike; + const mining = createMiningAssembly( + makeDeps({ appState: { ...makeDeps().appState, mpvClient } }), + ); + + const context = mining.captureCurrentPrimarySubtitleMiningContext(); + + assert.equal(context?.text, 'こんにちは'); + assert.equal(context?.startTime, 10.5); + assert.equal(context?.endTime, 12.5); +}); diff --git a/src/main/assemblies/mining.ts b/src/main/assemblies/mining.ts new file mode 100644 index 00000000..33270ea3 --- /dev/null +++ b/src/main/assemblies/mining.ts @@ -0,0 +1,406 @@ +import { resolveMediaGenerationInput } from '../../anki-integration/media-source'; +import { + captureLiveSubtitleMiningContext, + copyCurrentSubtitle as copyCurrentSubtitleCore, + createFieldGroupingOverlayRuntime, + handleMineSentenceDigit as handleMineSentenceDigitCore, + handleMultiCopyDigit as handleMultiCopyDigitCore, + markLastCardAsAudioCard as markLastCardAsAudioCardCore, + mineSentenceCard as mineSentenceCardCore, + shiftSubtitleRangeToVideoClock, + toSubtitleClock, + triggerFieldGrouping as triggerFieldGroupingCore, + updateLastCardFromClipboard as updateLastCardFromClipboardCore, + type createOverlayManager, +} from '../../core/services'; +import { createMediaTimingFrameExtractor } from '../../core/services/media-timing-frame'; +import { MediaTimingPreviewSession } from '../../core/services/media-timing-preview'; +import { generateSpeechWaveform } from '../../core/services/media-timing-waveform'; +import { getSharedRemoteMediaWindowCache } from '../../core/services/remote-media-window-cache'; +import type { Logger } from '../../logger'; +import type { PluginMinedNote, PluginMiningProvenance } from '../../plugin-api'; +import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts'; +import type { ResolvedConfig, SubtitleMiningContext } from '../../types'; +import type { OverlayModalRuntime } from '../overlay-runtime'; +import { + createCopyCurrentSubtitleHandler, + createHandleMineSentenceDigitHandler, + createHandleMultiCopyDigitHandler, + createMarkLastCardAsAudioCardHandler, + createMineSentenceCardHandler, + createRefreshKnownWordCacheHandler, + createTriggerFieldGroupingHandler, + createUpdateLastCardFromClipboardHandler, +} from '../runtime/domains/mining'; +import { + createGetFieldGroupingResolverHandler, + createSetFieldGroupingResolverHandler, +} from '../runtime/domains/overlay'; +import { + collectMediaTimingContextLines, + createMediaTimingReviewRuntime, +} from '../runtime/media-timing-review'; +import { openMediaTimingReviewModal } from '../runtime/media-timing-review-open'; +import { findMpvExecutablePath } from '../runtime/mpv-process'; +import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime'; +import { resolvePrimarySubtitle } from '../runtime/primary-subtitle-text'; +import type { AppState } from '../state'; +import type { OverlayModalsAssembly } from './overlay-modals'; +import type { CliSlice, IpcSlice } from './slices'; +import type { YoutubeAssembly } from './youtube'; + +type OverlayManager = ReturnType; +type AnkiIntegrationLike = NonNullable; + +/** + * What card mining needs from the rest of the app. Siblings built after this assembly in + * `main.ts` are reached through callbacks. + */ +export interface MiningAssemblyDeps { + appState: Pick< + AppState, + | 'mpvClient' + | 'currentMediaPath' + | 'activeParsedSubtitleCues' + | 'ankiIntegration' + | 'subtitleTimingTracker' + | 'fieldGroupingResolver' + | 'fieldGroupingResolverSequence' + >; + getConfig: () => ResolvedConfig; + logger: Pick; + overlayManager: Pick< + OverlayManager, + 'getMainWindow' | 'getModalWindow' | 'getVisibleOverlayVisible' + >; + overlayModalRuntime: Pick< + OverlayModalRuntime, + | 'getRestoreVisibleOverlayOnModalClose' + | 'waitForModalOpen' + | 'handleOverlayModalClosed' + | 'sendToActiveOverlayWindow' + >; + youtube: Pick< + YoutubeAssembly, + 'getCachedMediaPathForCurrentPlayback' | 'shouldRequireMediaCacheForCurrentPlayback' + >; + /** Electron's clipboard, injected so the assembly loads under `bun test`. */ + clipboard: { readText: () => string; writeText: (text: string) => void }; + getOverlayNotifications: () => Pick< + ReturnType, + 'showConfiguredStatusNotification' | 'showConfiguredPlaybackFeedback' + >; + getOverlayModals: () => Pick; + setVisibleOverlayVisible: (visible: boolean) => void; + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + /** Callbacks the Anki integration reports back through once mining finishes. */ + ankiIntegrationCallbacks: { + recordCardsMined: (count: number, noteIds?: number[]) => void; + getMiningProvenance: () => PluginMiningProvenance; + onCardsReady: (noteIds: number[], notes: PluginMinedNote[]) => void; + onKnownWordCacheUpdated: () => void; + generateSentenceFurigana: (text: string, highlightedText?: string) => Promise; + }; +} + +export type MiningAssembly = ReturnType; + +/** + * Owns card mining: the subtitle context captured for the next card, the mine/copy/update + * handlers behind shortcuts, CLI and session actions, field grouping, and the media timing + * review the Anki integration opens before writing media. + */ +export function createMiningAssembly(deps: MiningAssemblyDeps) { + const { appState, logger, overlayManager, overlayModalRuntime } = deps; + const showStatus = (text: string) => + deps.getOverlayNotifications().showConfiguredStatusNotification(text); + const showPlaybackFeedback = (text: string) => + deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text); + let pendingSubtitleMiningContext: SubtitleMiningContext | null = null; + + function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null { + // Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active + // event, so a finished caption row lingering beside a fresh line would end up on the + // card. The parsed view also carries the cue's own timings for the clip range. + // Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first. + const subDelay = appState.mpvClient?.currentSubDelay ?? 0; + const resolved = resolvePrimarySubtitle({ + liveText: appState.mpvClient?.currentSubText ?? '', + currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay), + cues: appState.activeParsedSubtitleCues, + }); + // Same validity bar as the live capture path: an unusable resolved span must fall + // back rather than hand mining an empty line or an inverted range. + const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim(); + if ( + !resolved || + !resolvedText || + !Number.isFinite(resolved.startTime) || + !Number.isFinite(resolved.endTime) || + resolved.endTime <= resolved.startTime + ) { + return captureLiveSubtitleMiningContext(appState.mpvClient); + } + return shiftSubtitleRangeToVideoClock( + { + source: 'overlay', + text: resolvedText, + startTime: resolved.startTime, + endTime: resolved.endTime, + capturedAtMs: Date.now(), + }, + subDelay, + ); + } + + const recordSubtitleMiningContext = (context: SubtitleMiningContext | null): void => { + pendingSubtitleMiningContext = context; + }; + /** Hands the recorded context to exactly one card, then forgets it. */ + const consumePendingSubtitleMiningContext = (): SubtitleMiningContext | null => { + const context = pendingSubtitleMiningContext; + pendingSubtitleMiningContext = null; + return context; + }; + + const getFieldGroupingResolver = createGetFieldGroupingResolverHandler({ + getResolver: () => appState.fieldGroupingResolver, + }); + + const setFieldGroupingResolver = createSetFieldGroupingResolverHandler({ + setResolver: (resolver) => { + appState.fieldGroupingResolver = resolver; + }, + nextSequence: () => { + appState.fieldGroupingResolverSequence += 1; + return appState.fieldGroupingResolverSequence; + }, + getSequence: () => appState.fieldGroupingResolverSequence, + }); + + const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime({ + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + setVisibleOverlayVisible: (visible) => deps.setVisibleOverlayVisible(visible), + getResolver: () => getFieldGroupingResolver(), + setResolver: (resolver) => setFieldGroupingResolver(resolver), + getRestoreVisibleOverlayOnModalClose: () => + overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), + // These are optional on the runtime options, so a missing forward compiles silently, but + // dropping them leaves the field grouping modal with no modal-open ack/retry, no teardown on + // failure, and no warn logging. + waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), + handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal), + logWarn: (message) => logger.warn(message), + ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(), + ensureOverlayWindowsReadyForVisibilityActions: () => + deps.ensureOverlayWindowsReadyForVisibilityActions(), + sendToVisibleOverlay: (channel, payload, runtimeOptions) => + overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), + }); + + const sendToOverlayWindows = (channel: string, payload: unknown) => { + // The review may live in either overlay window; the renderer ignores foreign review ids. + for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) { + if (window && !window.isDestroyed()) { + window.webContents.send(channel, payload); + } + } + }; + const getMediaGenerationOptions = () => ({ + getCachedMediaPath: (currentVideoPath: string, kind: 'audio' | 'video') => + deps.youtube.getCachedMediaPathForCurrentPlayback(currentVideoPath, kind), + remoteCacheMode: deps.youtube.shouldRequireMediaCacheForCurrentPlayback() + ? ('required' as const) + : ('optional' as const), + }); + + const mediaTimingFrameExtractor = createMediaTimingFrameExtractor(); + const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({ + getMpvClient: () => appState.mpvClient, + getCurrentMediaPath: () => + appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, + // An invalid configured path is passed through so the preview fails instead of using PATH mpv. + getMpvExecutablePath: () => { + const configuredPath = deps.getConfig().mpv.executablePath; + return findMpvExecutablePath(configuredPath) || configuredPath; + }, + createPreviewSession: () => new MediaTimingPreviewSession(), + generateWaveform: (options) => generateSpeechWaveform(options), + generateFrame: (options) => mediaTimingFrameExtractor.generate(options), + clearFrameCache: () => mediaTimingFrameExtractor.clear(), + resolveVideoSource: () => + resolveMediaGenerationInput(appState.mpvClient, 'video', getMediaGenerationOptions()), + resolveMediaSource: async () => { + const resolved = await resolveMediaGenerationInput( + appState.mpvClient, + 'audio', + getMediaGenerationOptions(), + ); + return resolved + ? { + path: resolved.path, + ...(resolved.inputOptions ? { inputOptions: resolved.inputOptions } : {}), + singleResolvedStream: resolved.singleResolvedStream, + } + : null; + }, + acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range), + getSubtitleContextLines: (range) => + collectMediaTimingContextLines({ + // Review ranges and tracker entries are on the video clock; parsed cues are not. + cues: appState.activeParsedSubtitleCues.map((cue) => + shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0), + ), + fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [], + startTime: range.startTime, + endTime: range.endTime, + }), + openModal: (payload, signal) => + openMediaTimingReviewModal( + deps.getOverlayModals().createOverlayHostedModalOpenDeps(), + payload, + signal, + ), + onPreviewEnded: (reviewId) => { + sendToOverlayWindows(IPC_CHANNELS.event.mediaTimingReviewPreviewEnded, reviewId); + }, + onPreviewPosition: (position) => { + sendToOverlayWindows(IPC_CHANNELS.event.mediaTimingReviewPreviewPosition, position); + }, + showStatus: (message) => + deps + .getOverlayNotifications() + .showConfiguredStatusNotification(message, { variant: 'warning' }), + }); + + const updateLastCardFromClipboard = createUpdateLastCardFromClipboardHandler({ + getAnkiIntegration: () => appState.ankiIntegration, + readClipboardText: () => deps.clipboard.readText(), + showMpvOsd: showStatus, + updateLastCardFromClipboardCore, + }); + + const refreshKnownWordCache = createRefreshKnownWordCacheHandler({ + getAnkiIntegration: () => appState.ankiIntegration, + missingIntegrationMessage: 'AnkiConnect integration not enabled', + }); + + const triggerFieldGrouping = createTriggerFieldGroupingHandler({ + getAnkiIntegration: () => appState.ankiIntegration, + showMpvOsd: showStatus, + triggerFieldGroupingCore, + }); + + const markLastCardAsAudioCard = createMarkLastCardAsAudioCardHandler({ + getAnkiIntegration: () => appState.ankiIntegration, + showMpvOsd: showStatus, + markLastCardAsAudioCardCore, + }); + + const mineSentenceCard = createMineSentenceCardHandler({ + getAnkiIntegration: () => appState.ankiIntegration, + getMpvClient: () => appState.mpvClient, + getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(), + showMpvOsd: showStatus, + mineSentenceCardCore, + }); + + const copySubtitleCount = createHandleMultiCopyDigitHandler({ + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + writeClipboardText: (text) => deps.clipboard.writeText(text), + showMpvOsd: showPlaybackFeedback, + handleMultiCopyDigitCore, + }); + + const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({ + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + writeClipboardText: (text) => deps.clipboard.writeText(text), + showMpvOsd: showStatus, + copyCurrentSubtitleCore, + }); + + const mineSentenceCount = createHandleMineSentenceDigitHandler({ + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + getAnkiIntegration: () => appState.ankiIntegration, + getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined, + showMpvOsd: showStatus, + logError: (message, err) => { + logger.error(message, err); + }, + handleMineSentenceDigitCore, + }); + + /** Points a freshly created Anki integration at mining, stats, plugins and furigana. */ + function attachToAnkiIntegration(integration: AnkiIntegrationLike): void { + const callbacks = deps.ankiIntegrationCallbacks; + integration.setRecordCardsMinedCallback(callbacks.recordCardsMined); + integration.setMiningProvenanceProvider(callbacks.getMiningProvenance); + integration.setCardsReadyCallback(callbacks.onCardsReady); + integration.setKnownWordCacheUpdatedCallback(callbacks.onKnownWordCacheUpdated); + integration.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext); + integration.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview); + integration.setSentenceFuriganaGenerator(callbacks.generateSentenceFurigana); + } + + const ipc: IpcSlice< + | 'recordSubtitleMiningContext' + | 'previewMediaTimingReview' + | 'getMediaTimingReviewFrame' + | 'getMediaTimingReviewWaveform' + | 'stopMediaTimingReviewPreview' + | 'resolveMediaTimingReview' + > = { + // Overlay lookups carry no cue context of their own; fall back to snapshotting the + // live mpv sub timings at lookup time so media generation clips the mined line even + // when extraction finishes long after playback has moved on. + recordSubtitleMiningContext: (context) => + recordSubtitleMiningContext( + context + ? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0) + : captureCurrentPrimarySubtitleMiningContext(), + ), + previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request), + getMediaTimingReviewFrame: (request) => mediaTimingReviewRuntime.getFrame(request), + getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request), + stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId), + resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request), + }; + + const cli: CliSlice< + | 'copyCurrentSubtitle' + | 'mineSentenceCard' + | 'updateLastCardFromClipboard' + | 'refreshKnownWordCache' + | 'triggerFieldGrouping' + | 'markLastCardAsAudioCard' + > = { + copyCurrentSubtitle: () => copyCurrentSubtitle(), + mineSentenceCard: () => mineSentenceCard(), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + refreshKnownWordCache: () => refreshKnownWordCache(), + triggerFieldGrouping: () => triggerFieldGrouping(), + markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + }; + + return { + ipc, + cli, + captureCurrentPrimarySubtitleMiningContext, + recordSubtitleMiningContext, + getFieldGroupingResolver, + setFieldGroupingResolver, + createFieldGroupingCallback: fieldGroupingOverlayRuntime.createFieldGroupingCallback, + mediaTimingReviewRuntime, + attachToAnkiIntegration, + updateLastCardFromClipboard, + refreshKnownWordCache, + triggerFieldGrouping, + markLastCardAsAudioCard, + mineSentenceCard, + mineSentenceCount, + copyCurrentSubtitle, + copySubtitleCount, + }; +} diff --git a/src/main/assemblies/overlay.test.ts b/src/main/assemblies/overlay.test.ts index 911f1fc0..76f8356d 100644 --- a/src/main/assemblies/overlay.test.ts +++ b/src/main/assemblies/overlay.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test, { type TestContext } from 'node:test'; import type { BrowserWindow } from 'electron'; import type { HyprlandPlacementStatus } from '../../core/services/hyprland-window-placement'; -import type { WindowGeometry } from '../../types'; +import type { OverlayContentMeasurement, WindowGeometry } from '../../types'; import { createAppState } from '../state'; import { createOverlayAssembly, type OverlayAssemblyDeps } from './overlay'; @@ -31,8 +31,15 @@ type FixtureState = { trackedGeometry: WindowGeometry | null; /** Window returned by window creation; null makes creation throw after recording options. */ window: BrowserWindow | null; + /** The overlay manager's current main window. */ + mainWindow: BrowserWindow | null; + /** Whether the measurement store accepts a reported content measurement. */ + measurementAccepted: boolean; + /** Whether the plugin panel recognizes a closed panel id as the one showing. */ + pluginPanelCurrent: boolean; }; +const ACCEPTED_MEASUREMENT = {} as OverlayContentMeasurement; const WINDOW_CREATION_STOPPED = 'window creation stopped by test'; const NO_PLACEMENT: HyprlandPlacementStatus = { applicable: false, @@ -56,7 +63,7 @@ function makeDeps(recorder: Recorder, state: FixtureState) { warn: spy('logger.warn', noop), }, overlayManager: { - getMainWindow: spy('overlay.getMainWindow', () => null), + getMainWindow: spy('overlay.getMainWindow', () => state.mainWindow), setMainWindow: spy('overlay.setMainWindow', noop), getModalWindow: spy('overlay.getModalWindow', () => null), setModalWindow: spy('overlay.setModalWindow', noop), @@ -68,6 +75,15 @@ function makeDeps(recorder: Recorder, state: FixtureState) { setModalWindowBounds: spy('overlay.setModalWindowBounds', () => NO_PLACEMENT), }, overlayModalInputState: { getModalInputExclusive: spy('modal.getInputExclusive', () => false) }, + overlayModalRuntime: { + notifyOverlayModalOpened: spy('modal.notifyOpened', noop), + handleOverlayModalClosed: spy('modal.handleClosed', noop), + }, + overlayContentMeasurementStore: { + report: spy('measurement.report', () => + state.measurementAccepted ? ACCEPTED_MEASUREMENT : null, + ), + }, autoplayReadyGate: { markCurrentMediaAutoplayReady: spy('autoplay.markCurrentMediaReady', noop), flushPendingAutoplayReadySignal: spy('autoplay.flushPending', noop), @@ -89,6 +105,14 @@ function makeDeps(recorder: Recorder, state: FixtureState) { getLinuxOverlayInputShapeActive: spy('interaction.getLinuxInputShapeActive', () => false), getMacOSVisibleOverlayForegroundProbeActive: spy('interaction.getMacProbe', () => false), getVisibleOverlayInteractionActive: spy('interaction.getActive', () => false), + setVisibleOverlayInteractionActive: spy('interaction.setActive', noop), + setLinuxOverlayInteractiveHint: spy('interaction.setLinuxInteractiveHint', noop), + applyLinuxOverlayInputShapeFromLatestMeasurement: spy( + 'interaction.applyLinuxShape', + () => false, + ), + tickLinuxOverlayPointerInteractionNow: spy('interaction.tickLinuxNow', noop), + tickWindowsOverlayPointerInteractionNow: spy('interaction.tickWindowsNow', noop), getWindowsNativeWindowHandleNumber: spy('interaction.getWindowsHandle', () => 0), primeLinuxOverlayPointerInteractionAfterFirstMeasurement: spy( 'interaction.primePointerAfterFirstMeasurement', @@ -141,7 +165,13 @@ function makeDeps(recorder: Recorder, state: FixtureState) { getAutoplaySubtitlePriming: spy('getAutoplaySubtitlePriming', () => ({ cancelVisibleOverlaySubtitleRefreshAfterFirstPaint: spy('priming.cancelFirstPaint', noop), primeCurrentSubtitleForVisibleOverlay: spy('priming.prime', resolved), + scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint: spy('priming.scheduleFirstPaint', noop), })), + getPluginPanel: spy('getPluginPanel', () => ({ + handleModalClosed: spy('pluginPanel.handleModalClosed', () => state.pluginPanelCurrent), + })), + setSubtitleSidebarRequestedOpen: spy('setSubtitleSidebarRequestedOpen', noop), + stealAppFocus: spy('stealAppFocus', noop), ensureOverlayMpvSubtitlesHidden: spy('ensureOverlayMpvSubtitlesHidden', resolved), syncOverlayMpvSubtitleSuppression: spy('syncOverlayMpvSubtitleSuppression', noop), initializeOverlayRuntime: spy('initializeOverlayRuntime', noop), @@ -161,6 +191,9 @@ function setup(t: TestContext, options: { platform?: NodeJS.Platform; visible?: visible: options.visible ?? false, trackedGeometry: null, window: null, + mainWindow: null, + measurementAccepted: true, + pluginPanelCurrent: true, }; const { deps, windowOptions } = makeDeps(recorder, state); const overlay = createOverlayAssembly(deps); @@ -329,3 +362,54 @@ for (const trackedGeometry of [null, { x: 10, y: 20, width: 1280, height: 720 }] assert.ok(!names.includes('geometry.getCurrentOverlayGeometry')); }); } + +test('an accepted content measurement refreshes pointer input, then releases autoplay, then schedules the first-paint refresh', (t) => { + const { recorder, overlay } = setup(t); + + overlay.ipc.reportOverlayContentBounds({ layer: 'visible' }); + + assertCalledInOrder(recorder.names(), [ + 'measurement.report', + 'interaction.tickLinuxNow', + 'interaction.tickWindowsNow', + 'interaction.primePointerAfterFirstMeasurement', + 'autoplay.flushPending', + 'priming.scheduleFirstPaint', + ]); +}); + +test('a rejected content measurement changes nothing', (t) => { + const { recorder, state, overlay } = setup(t); + state.measurementAccepted = false; + + overlay.ipc.reportOverlayContentBounds({ layer: 'visible' }); + + assert.deepEqual(recorder.names(), ['measurement.report']); +}); + +test('only the main overlay window opens and closes the subtitle sidebar state', (t) => { + const { recorder, state, overlay } = setup(t); + const mainWindow = { isDestroyed: () => false } as BrowserWindow; + const otherWindow = { isDestroyed: () => false } as BrowserWindow; + state.mainWindow = mainWindow; + const sidebarStates = () => + recorder.calls + .filter((call) => call.name === 'setSubtitleSidebarRequestedOpen') + .map((call) => call.args[0]); + + overlay.ipc.onOverlayModalOpened('subtitle-sidebar', otherWindow); + overlay.ipc.onOverlayModalOpened('subtitle-sidebar', mainWindow); + overlay.ipc.onOverlayModalClosed('subtitle-sidebar', otherWindow); + overlay.ipc.onOverlayModalClosed('subtitle-sidebar', mainWindow); + + assert.deepEqual(sidebarStates(), [true, false]); +}); + +test('a late close for a replaced plugin panel leaves the showing panel open', (t) => { + const { recorder, state, overlay } = setup(t); + state.pluginPanelCurrent = false; + + overlay.ipc.onOverlayModalClosed('plugin-panel', null, 7); + + assert.equal(recorder.names().includes('modal.handleClosed'), false); +}); diff --git a/src/main/assemblies/overlay.ts b/src/main/assemblies/overlay.ts index 2b5e2b8d..86e78594 100644 --- a/src/main/assemblies/overlay.ts +++ b/src/main/assemblies/overlay.ts @@ -1,14 +1,17 @@ import type { BrowserWindow, Screen, Session } from 'electron'; import { + type createOverlayContentMeasurementStore, type createOverlayWindow as createOverlayWindowCoreFn, sendMpvCommandRuntime, setVisibleOverlayVisible as setVisibleOverlayVisibleCore, type createOverlayManager, } from '../../core/services'; +import { applyOverlayClickThrough } from '../../core/services/overlay-click-through'; import type { Logger } from '../../logger'; import type { WindowGeometry } from '../../types'; import { ensureWindowsOverlayTransparency } from '../../window-trackers/windows-helper'; import type { createOverlayShortcutsRuntimeService } from '../overlay-shortcuts-runtime'; +import type { OverlayModalRuntime } from '../overlay-runtime'; import { createOverlayVisibilityRuntimeService } from '../overlay-visibility-runtime'; import type { createAutoplayReadyGate } from '../runtime/autoplay-ready-gate'; import type { composeOverlayVisibilityRuntime } from '../runtime/composers'; @@ -20,6 +23,7 @@ import { } from '../runtime/linux-mpv-fullscreen-overlay-refresh'; import { createLinuxOverlayModeRuntime } from '../runtime/linux-overlay-mode-runtime'; import { shouldRunLinuxOverlayZOrderKeepAlive } from '../runtime/linux-overlay-zorder-keepalive'; +import { focusMacOSOverlayWindow } from '../runtime/macos-overlay-window-focus'; import type { createMediaTimingReviewRuntime } from '../runtime/media-timing-review'; import type { createOverlayModalInputState } from '../runtime/overlay-modal-input-state'; import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime'; @@ -28,6 +32,7 @@ import { createOverlayWindowRuntimeHandlers } from '../runtime/overlay-window-ru import type { VisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime'; import type { AppState } from '../state'; import type { PlaybackAssembly } from './playback'; +import type { IpcSlice } from './slices'; type OverlayManager = ReturnType; @@ -54,6 +59,14 @@ export interface OverlayAssemblyDeps { ReturnType, 'getModalInputExclusive' >; + overlayModalRuntime: Pick< + OverlayModalRuntime, + 'notifyOverlayModalOpened' | 'handleOverlayModalClosed' + >; + overlayContentMeasurementStore: Pick< + ReturnType, + 'report' + >; autoplayReadyGate: Pick< ReturnType, 'markCurrentMediaAutoplayReady' | 'flushPendingAutoplayReadySignal' @@ -69,6 +82,11 @@ export interface OverlayAssemblyDeps { | 'getLinuxOverlayInputShapeActive' | 'getMacOSVisibleOverlayForegroundProbeActive' | 'getVisibleOverlayInteractionActive' + | 'setVisibleOverlayInteractionActive' + | 'setLinuxOverlayInteractiveHint' + | 'applyLinuxOverlayInputShapeFromLatestMeasurement' + | 'tickLinuxOverlayPointerInteractionNow' + | 'tickWindowsOverlayPointerInteractionNow' | 'getWindowsNativeWindowHandleNumber' | 'primeLinuxOverlayPointerInteractionAfterFirstMeasurement' | 'requestLinuxOverlayZOrderFollow' @@ -113,8 +131,16 @@ export interface OverlayAssemblyDeps { >; getAutoplaySubtitlePriming: () => Pick< PlaybackAssembly['autoplaySubtitlePrimingRuntime'], - 'cancelVisibleOverlaySubtitleRefreshAfterFirstPaint' | 'primeCurrentSubtitleForVisibleOverlay' + | 'cancelVisibleOverlaySubtitleRefreshAfterFirstPaint' + | 'primeCurrentSubtitleForVisibleOverlay' + | 'scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint' >; + /** A late close for a replaced plugin panel reports false and must be ignored. */ + getPluginPanel: () => { handleModalClosed: (panelId: number) => boolean }; + /** The renderer reports the subtitle sidebar as a modal; playback remembers its state. */ + setSubtitleSidebarRequestedOpen: (open: boolean) => void; + /** Brings SubMiner to the foreground (`app.focus({ steal: true })`). */ + stealAppFocus: () => void; ensureOverlayMpvSubtitlesHidden: () => Promise; syncOverlayMpvSubtitleSuppression: () => void; initializeOverlayRuntime: () => void; @@ -426,7 +452,122 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) { clearLinuxMpvFullscreenOverlayRefreshTimeouts(); } + /** Renderer reports about overlay windows: focus, modal lifecycle, pointer and measurement. */ + const ipc: IpcSlice< + | 'getMainWindow' + | 'focusMainWindow' + | 'activatePlaybackWindowForOverlayInteraction' + | 'onOverlayModalClosed' + | 'onOverlayModalOpened' + | 'onOverlayMouseInteractionChanged' + | 'onOverlayInteractiveHint' + | 'reportOverlayContentBounds' + > = { + getMainWindow: () => overlayManager.getMainWindow(), + focusMainWindow: () => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + if (process.platform === 'darwin') { + focusMacOSOverlayWindow({ + platform: process.platform, + getOverlayWindow: () => mainWindow, + stealAppFocus: () => deps.stealAppFocus(), + warn: (message, details) => logger.warn(message, details), + }); + return; + } + if (!mainWindow.isFocused()) { + mainWindow.focus(); + } + }, + activatePlaybackWindowForOverlayInteraction: async () => { + try { + const raised = (await appState.windowTracker?.raiseTargetWindow?.()) ?? false; + deps.getOverlayGeometry().enforceOverlayLayerOrder(); + return raised; + } catch (error) { + logger.debug( + 'Failed to raise tracked mpv window for overlay interaction:', + error instanceof Error ? error.message : String(error), + ); + deps.getOverlayGeometry().enforceOverlayLayerOrder(); + return false; + } + }, + onOverlayModalClosed: (modal, senderWindow, instanceId) => { + // A late close for a replaced plugin panel must not hide the panel now showing. + if ( + modal === 'plugin-panel' && + instanceId !== undefined && + !deps.getPluginPanel().handleModalClosed(instanceId) + ) { + return; + } + if (modal === 'media-timing-review') { + void deps.getMediaTimingReviewRuntime().dispose(); + } + if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) { + deps.setSubtitleSidebarRequestedOpen(false); + } + const modalWindow = overlayManager.getModalWindow(); + if ( + senderWindow && + modalWindow && + senderWindow === modalWindow && + !senderWindow.isDestroyed() + ) { + applyOverlayClickThrough(senderWindow); + senderWindow.hide(); + } + deps.overlayModalRuntime.handleOverlayModalClosed(modal); + }, + onOverlayModalOpened: (modal, senderWindow) => { + if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) { + deps.setSubtitleSidebarRequestedOpen(true); + } + deps.overlayModalRuntime.notifyOverlayModalOpened(modal); + }, + onOverlayMouseInteractionChanged: (active, senderWindow) => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || senderWindow !== mainWindow) { + return; + } + const interaction = deps.getVisibleOverlayInteraction(); + const previousActive = interaction.getVisibleOverlayInteractionActive(); + interaction.setVisibleOverlayInteractionActive(active); + if (previousActive === active) { + if (active && process.platform === 'darwin' && !mainWindow.isFocused()) { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + } + return; + } + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, + onOverlayInteractiveHint: (interactive, senderWindow) => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || senderWindow !== mainWindow) { + return; + } + const interaction = deps.getVisibleOverlayInteraction(); + interaction.setLinuxOverlayInteractiveHint(interactive); + interaction.applyLinuxOverlayInputShapeFromLatestMeasurement(); + }, + // An accepted measurement refreshes pointer passthrough at once, then releases autoplay, + // then schedules the first-paint subtitle refresh. + reportOverlayContentBounds: (payload: unknown) => { + if (deps.overlayContentMeasurementStore.report(payload)) { + const interaction = deps.getVisibleOverlayInteraction(); + interaction.tickLinuxOverlayPointerInteractionNow(); + interaction.tickWindowsOverlayPointerInteractionNow(); + interaction.primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); + deps.autoplayReadyGate.flushPendingAutoplayReadySignal(); + deps.getAutoplaySubtitlePriming().scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(); + } + }, + }; + return { + ipc, linuxOverlayModeRuntime, overlayVisibilityRuntime, createMainWindow, diff --git a/src/main/assemblies/playback.test.ts b/src/main/assemblies/playback.test.ts index 62bae1c5..f39fb25e 100644 --- a/src/main/assemblies/playback.test.ts +++ b/src/main/assemblies/playback.test.ts @@ -88,10 +88,13 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps { }, overlayNotificationsRuntime: { showConfiguredStatusNotification: spy('notifications.showStatus', noop), + showConfiguredPlaybackFeedback: spy('notifications.showPlaybackFeedback', noop), getConfiguredStatusNotificationType: spy('notifications.getType', () => 'osd' as const), }, + overlayModalRuntime: { handleOverlayModalClosed: spy('overlayModal.handleClosed', noop) }, youtube: { isPlaybackActiveNow: spy('youtube.isPlaybackActiveNow', () => false), + shouldQuitWhenMpvCloses: spy('youtube.shouldQuitWhenMpvCloses', () => false), playback: { getQuitOnDisconnectArmed: spy('youtube.getQuitOnDisconnectArmed', () => false) }, primarySubtitleNotification: { handleMediaPathChange: spy('youtube.notification.handleMediaPathChange', noop), @@ -99,8 +102,16 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps { handleSubtitleTrackListChange: spy('youtube.notification.handleTrackListChange', noop), }, mediaCachePlayback: { handleMediaPathChange: spy('youtube.mediaCache', resolved) }, - browserPlayback: { handleMediaPathChange: spy('youtube.browser', noop) }, - flow: { handleMediaPathChange: spy('youtube.flow', noop) }, + browserPlayback: { + handleMediaPathChange: spy('youtube.browser', noop), + handleMpvDisconnected: spy('youtube.browser.handleMpvDisconnected', noop), + }, + flow: { + handleMediaPathChange: spy('youtube.flow', noop), + cancelWhisperGeneration: spy('youtube.flow.cancelWhisperGeneration', noop), + hasActiveSession: spy('youtube.flow.hasActiveSession', () => false), + cancelActivePicker: spy('youtube.flow.cancelActivePicker', () => false), + }, }, jellyfin: { getQuitOnDisconnectArmed: spy('jellyfin.getQuitOnDisconnectArmed', () => false), @@ -133,6 +144,7 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps { getSubtitleGenerationRuntime: spy('getSubtitleGenerationRuntime', () => ({ handleMediaPathChange: noop, })), + getPluginHost: spy('getPluginHost', () => ({ attachMpvClient: noop })), isCharacterDictionaryEnabled: spy('isCharacterDictionaryEnabled', () => false), hasTray: spy('hasTray', () => false), loadSubtitleSourceText: spy('loadSubtitleSourceText', async () => ''), @@ -145,7 +157,6 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps { refreshMpvSessionBindings: spy('refreshMpvSessionBindings', resolved), ensureImmersionTrackerStarted: spy('ensureImmersionTrackerStarted', noop), restoreOverlayMpvSubtitles: spy('restoreOverlayMpvSubtitles', noop), - resetSubtitleSidebarEmbeddedLayoutRuntime: spy('resetSubtitleSidebarLayout', noop), syncOverlayMpvSubtitleSuppression: spy('syncOverlayMpvSubtitleSuppression', noop), setOverlayVisible: spy('setOverlayVisible', noop), ensureYomitanExtensionLoaded: spy('ensureYomitanExtensionLoaded', async () => null), @@ -393,3 +404,42 @@ test('warm tokenization releases autoplay with the current subtitle when it is s const signals = recorder.calls.filter((call) => call.name === 'autoplay.maybeSignal'); assert.deepEqual(signals.at(-1)?.args, [current, { forceWhilePaused: true }]); }); + +/** An mpv client that answers the sidebar's property reads from a table. */ +function fakeSidebarMpvClient(properties: Record) { + return { + connected: true, + requestProperty: async (name: string) => properties[name] ?? null, + } as unknown as NonNullable['appState']['mpvClient']>; +} + +test('sidebar snapshot keeps cached YouTube cues instead of re-resolving the subtitle source', async () => { + const { appState, playback, recorder } = setup(); + const cues = [{ startTime: 1, endTime: 2, text: '字幕' }]; + appState.activeParsedSubtitleCues = cues; + appState.activeParsedSubtitleMediaPath = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; + appState.mpvClient = fakeSidebarMpvClient({ + path: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30', + // Without the YouTube cache check this source would be re-read and re-parsed. + 'current-tracks/sub/external-filename': '/tmp/youtube-subs/dQw4w9WgXcQ.ja.vtt', + 'track-list': [], + sid: 1, + }); + + const snapshot = await playback.ipc.getSubtitleSidebarSnapshot(); + + assert.equal(snapshot.cues, cues); + assert.equal(recorder.names().includes('loadSubtitleSourceText'), false); +}); + +test('sidebar snapshot falls back to cached cues while mpv is disconnected', async () => { + const { appState, playback, recorder } = setup(); + const cues = [{ startTime: 1, endTime: 2, text: '字幕' }]; + appState.activeParsedSubtitleCues = cues; + + const snapshot = await playback.ipc.getSubtitleSidebarSnapshot(); + + assert.equal(snapshot.cues, cues); + assert.equal(snapshot.config, DEFAULT_CONFIG.subtitleSidebar); + assert.equal(recorder.names().includes('loadSubtitleSourceText'), false); +}); diff --git a/src/main/assemblies/playback.ts b/src/main/assemblies/playback.ts index 77380471..1b739efc 100644 --- a/src/main/assemblies/playback.ts +++ b/src/main/assemblies/playback.ts @@ -9,6 +9,10 @@ import { tokenizeSubtitle as tokenizeSubtitleCore, } from '../../core/services'; import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser'; +import { resolveAniSkipMetadataForFile } from '../runtime/aniskip-metadata'; +import { createAniSkipRuntime } from '../runtime/aniskip-runtime'; +import { resolveCurrentSubtitleForRenderer } from '../runtime/current-subtitle-snapshot'; +import { resolveFreshPlaybackPaused } from '../runtime/playback-paused-state'; import { createAutoplaySubtitlePrimingRuntime } from '../runtime/autoplay-subtitle-priming-runtime'; import { createAutoplayTokenizationWarmRelease } from '../runtime/autoplay-tokenization-warm-release'; import { composeMpvRuntimeHandlers } from '../runtime/composers'; @@ -28,8 +32,13 @@ import { createResolveActiveSubtitleSidebarSourceHandler, } from '../runtime/subtitle-prefetch-runtime'; import { createPrefetchAwareSubtitleProcessingController } from '../runtime/subtitle-processing-runtime'; -import { isSameYoutubeMediaPath, isYoutubeMediaPath } from '../runtime/youtube-playback'; +import { + isSameYoutubeMediaPath, + isYoutubeMediaPath, + shouldUseCachedYoutubeParsedCues, +} from '../runtime/youtube-playback'; import { MecabTokenizer } from '../../mecab-tokenizer'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import type { MpvSubtitleRenderMetrics, SubtitleCue, @@ -56,11 +65,13 @@ import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notif import type { createStartupOsdSequencer } from '../runtime/startup-osd-sequencer'; import type { createSubtitleGenerationRuntime } from '../runtime/subtitle-generation-runtime'; import type { RuntimeOptionId } from '../../types/runtime-options'; +import type { OverlayModalRuntime } from '../overlay-runtime'; import type { AppState } from '../state'; import type { AnilistAssembly } from './anilist'; import type { CharacterDictionaryAssembly } from './character-dictionary'; import type { DictionariesAssembly } from './dictionaries'; import type { JellyfinAssembly } from './jellyfin'; +import type { IpcSlice } from './slices'; import type { YoutubeAssembly } from './youtube'; type SubtitleWsBroadcaster = Pick; @@ -128,17 +139,29 @@ export interface PlaybackAssemblyDeps { >; overlayNotificationsRuntime: Pick< OverlayNotificationsRuntime, - 'showConfiguredStatusNotification' | 'getConfiguredStatusNotificationType' + | 'showConfiguredStatusNotification' + | 'showConfiguredPlaybackFeedback' + | 'getConfiguredStatusNotificationType' >; - youtube: Pick & { + overlayModalRuntime: Pick; + youtube: Pick & { playback: Pick; primarySubtitleNotification: Pick< YoutubeAssembly['primarySubtitleNotification'], 'handleMediaPathChange' | 'handleSubtitleTrackChange' | 'handleSubtitleTrackListChange' >; mediaCachePlayback: Pick; - browserPlayback: Pick; - flow: Pick; + browserPlayback: Pick< + YoutubeAssembly['browserPlayback'], + 'handleMediaPathChange' | 'handleMpvDisconnected' + >; + flow: Pick< + YoutubeAssembly['flow'], + | 'handleMediaPathChange' + | 'cancelWhisperGeneration' + | 'hasActiveSession' + | 'cancelActivePicker' + >; }; jellyfin: Pick< JellyfinAssembly, @@ -171,6 +194,8 @@ export interface PlaybackAssemblyDeps { ReturnType, 'handleMediaPathChange' >; + /** Plugins observe every mpv client the app creates. */ + getPluginHost: () => { attachMpvClient: (client: MpvIpcClient) => void }; isCharacterDictionaryEnabled: () => boolean; /** Whether the tray icon exists; mpv shutdown keeps the app alive when it does. */ hasTray: () => boolean; @@ -184,7 +209,6 @@ export interface PlaybackAssemblyDeps { refreshMpvSessionBindings: () => Promise; ensureImmersionTrackerStarted: () => void; restoreOverlayMpvSubtitles: () => void; - resetSubtitleSidebarEmbeddedLayoutRuntime: () => void; syncOverlayMpvSubtitleSuppression: () => void; setOverlayVisible: (visible: boolean) => void; ensureYomitanExtensionLoaded: () => Promise; @@ -229,6 +253,13 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) { } = deps; let backgroundWarmupsStarted = false; let tokenizeSubtitleDeferred: ((text: string) => Promise) | null = null; + // Set from the renderer's modal open/close reports; replacement overlay windows restore it. + let subtitleSidebarRequestedOpen = false; + + function resetSubtitleSidebarEmbeddedLayout(): void { + sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-margin-ratio-right', 0]); + sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-pan-x', 0]); + } function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData { const canonical = resolveCanonicalPrimarySubtitle({ @@ -664,7 +695,7 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) { deps.restoreOverlayMpvSubtitles(); }, resetSubtitleSidebarEmbeddedLayout: () => { - deps.resetSubtitleSidebarEmbeddedLayoutRuntime(); + resetSubtitleSidebarEmbeddedLayout(); }, getCurrentAnilistMediaKey: () => anilist.getCurrentAnilistMediaKey(), resetAnilistMediaTracking: (mediaKey) => { @@ -955,7 +986,171 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) { }); tokenizeSubtitleDeferred = tokenizeSubtitle; + const aniSkipRuntime = createAniSkipRuntime({ + getAniSkipConfig: () => ({ + aniskipEnabled: configService.getConfig().mpv.aniskipEnabled, + aniskipButtonKey: configService.getConfig().mpv.aniskipButtonKey, + }), + resolveMetadataForFile: (mediaPath) => resolveAniSkipMetadataForFile(mediaPath), + sendMpvCommand: (command) => { + appState.mpvClient?.send({ command }); + }, + requestMpvProperty: (name) => { + if (!appState.mpvClient) { + return Promise.reject(new Error('MPV not connected')); + } + return appState.mpvClient.requestProperty(name); + }, + isMpvConnected: () => appState.mpvClient?.connected === true, + getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? Number.NaN, + showMpvOsd: (text, durationMs) => { + appState.mpvClient?.send({ command: ['show-text', text, durationMs] }); + }, + showPlaybackFeedback: (text) => + overlayNotificationsRuntime.showConfiguredPlaybackFeedback(text), + logInfo: (message) => logger.info(message), + logWarn: (message, error) => logger.warn(message, error), + logDebug: (message) => logger.debug(message), + }); + + /** Creates the app's mpv client with YouTube, AniSkip and plugin listeners attached. */ + function createMpvClient(): MpvIpcClient { + const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient; + client.on('connection-change', ({ connected }) => { + if (connected) { + return; + } + youtube.browserPlayback.handleMpvDisconnected(); + youtube.flow.cancelWhisperGeneration(); + deps.getSubtitleGenerationRuntime().handleMediaPathChange(null); + if (youtube.shouldQuitWhenMpvCloses()) { + deps.requestAppQuit(); + return; + } + if (!youtube.flow.hasActiveSession()) { + return; + } + youtube.flow.cancelActivePicker(); + overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null); + deps.overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker'); + }); + client.on('connection-change', aniSkipRuntime.handleConnectionChange); + client.on('media-path-change', aniSkipRuntime.handleMediaPathChange); + client.on('time-pos-change', aniSkipRuntime.handleTimePosChange); + client.on('client-message', aniSkipRuntime.handleClientMessage); + deps.getPluginHost().attachMpvClient(client); + return client; + } + + const ipc: IpcSlice< + | 'tokenizeCurrentSubtitle' + | 'getCurrentSubtitleRaw' + | 'getSubtitleSidebarOpen' + | 'getSubtitleSidebarPlaybackState' + | 'getSubtitleSidebarSnapshot' + | 'getPlaybackPaused' + | 'getMpvClient' + > = { + tokenizeCurrentSubtitle: async () => { + const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred; + return resolveCurrentSubtitleForRenderer({ + currentSubText: appState.currentSubText, + currentSubtitleData: appState.currentSubtitleData, + withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload), + tokenizeUncached: false, + tokenizeSubtitle: tokenizeSubtitleForCurrent + ? (text) => tokenizeSubtitleForCurrent(text) + : undefined, + onResolvedSubtitle: (payload) => { + appState.currentSubtitleData = payload; + autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true }); + }, + }); + }, + getCurrentSubtitleRaw: () => appState.currentSubText, + getSubtitleSidebarOpen: () => subtitleSidebarRequestedOpen, + getSubtitleSidebarPlaybackState: () => getSubtitleSidebarPlaybackState(), + // Full snapshot: resolves the active source over mpv IPC and re-parses it when it changed. + // The renderer fetches it on open, on source/cue changes, and on a slow fallback cadence. + getSubtitleSidebarSnapshot: async () => { + const config = configService.getConfig().subtitleSidebar; + const cachedSnapshot = () => ({ + ...getSubtitleSidebarPlaybackState(), + cues: appState.activeParsedSubtitleCues, + config, + }); + const client = appState.mpvClient; + if (!client?.connected) { + return cachedSnapshot(); + } + + 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 cachedSnapshot(); + } + if ( + shouldUseCachedYoutubeParsedCues({ + videoPath, + cachedMediaPath: appState.activeParsedSubtitleMediaPath, + cachedCueCount: appState.activeParsedSubtitleCues.length, + }) + ) { + return cachedSnapshot(); + } + + const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({ + currentExternalFilenameRaw, + currentTrackRaw, + trackListRaw, + sidRaw, + videoPath, + }); + if (!resolvedSource) { + return cachedSnapshot(); + } + + try { + if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) { + return cachedSnapshot(); + } + + const content = await loadSubtitleSourceText(resolvedSource.path); + const cues = parseSubtitleCues(content, resolvedSource.path); + appState.activeParsedSubtitleCues = cues; + appState.activeParsedSubtitleSource = resolvedSource.sourceKey; + appState.activeParsedSubtitleMediaPath = videoPath || null; + return cachedSnapshot(); + } finally { + await resolvedSource.cleanup?.(); + } + } catch { + return cachedSnapshot(); + } + }, + getPlaybackPaused: () => + resolveFreshPlaybackPaused({ + getCachedPlaybackPaused: () => appState.playbackPaused, + getMpvClient: () => appState.mpvClient, + }), + getMpvClient: () => appState.mpvClient, + }; + return { + ipc, + createMpvClient, + aniSkipRuntime, + setSubtitleSidebarRequestedOpen: (open: boolean): void => { + subtitleSidebarRequestedOpen = open; + }, subtitleProcessingController, autoplaySubtitlePrimingRuntime, secondarySubtitleTrackController, diff --git a/src/main/assemblies/shortcuts.test.ts b/src/main/assemblies/shortcuts.test.ts new file mode 100644 index 00000000..18b6e83e --- /dev/null +++ b/src/main/assemblies/shortcuts.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { GlobalShortcut } from 'electron'; +import { DEFAULT_CONFIG } from '../../config/definitions'; +import { createShortcutsAssembly, type ShortcutsAssemblyDeps } from './shortcuts'; + +// Every callback throws, so a test fails if the assembly calls a dep it should only call later. +function makeDeps(overrides: Partial = {}): ShortcutsAssemblyDeps { + const lazy = (name: string) => (): never => { + throw new Error(`${name} called unexpectedly`); + }; + return { + appState: { + shortcutsRegistered: false, + overlayRuntimeInitialized: false, + windowTracker: null, + keybindings: [], + mpvClient: null, + sessionBindings: [], + sessionBindingsInitialized: false, + }, + getConfig: lazy('getConfig'), + logger: { warn: lazy('warn') }, + configDir: '/tmp/subminer-shortcuts-assembly-test', + isDev: false, + overlayManager: { + getMainWindow: lazy('getMainWindow'), + getVisibleOverlayVisible: lazy('getVisibleOverlayVisible'), + broadcastToOverlayWindows: lazy('broadcastToOverlayWindows'), + }, + globalShortcut: new Proxy({}, { get: (_target, name: string) => lazy(name) }) as GlobalShortcut, + getOverlayNotifications: lazy('getOverlayNotifications'), + getOverlayModals: lazy('getOverlayModals'), + getMining: lazy('getMining'), + triggerSubsyncFromConfig: lazy('triggerSubsyncFromConfig'), + cycleSecondarySubMode: lazy('cycleSecondarySubMode'), + toggleVisibleOverlay: lazy('toggleVisibleOverlay'), + openDictionarySettings: lazy('openDictionarySettings'), + ...overrides, + }; +} + +// main.ts builds the assembly before most siblings exist, so construction must stay lazy. +test('createShortcutsAssembly does not call its deps while constructing', () => { + const shortcuts = createShortcutsAssembly(makeDeps()); + assert.equal(typeof shortcuts.registerGlobalShortcuts, 'function'); + assert.equal(typeof shortcuts.syncOverlayShortcutsForModal, 'function'); +}); + +test('opening a modal releases the registered overlay shortcuts', () => { + const deps = makeDeps({ getConfig: () => DEFAULT_CONFIG }); + deps.appState.shortcutsRegistered = true; + const shortcuts = createShortcutsAssembly(deps); + + shortcuts.syncOverlayShortcutsForModal(true); + + assert.equal(deps.appState.shortcutsRegistered, false); +}); diff --git a/src/main/assemblies/shortcuts.ts b/src/main/assemblies/shortcuts.ts new file mode 100644 index 00000000..923205c3 --- /dev/null +++ b/src/main/assemblies/shortcuts.ts @@ -0,0 +1,289 @@ +import type { GlobalShortcut } from 'electron'; +import { DEFAULT_CONFIG } from '../../config/definitions'; +import { + registerGlobalShortcuts as registerGlobalShortcutsCore, + type createOverlayManager, +} from '../../core/services'; +import { resolveConfiguredShortcuts } from '../../core/utils'; +import type { Logger } from '../../logger'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import type { ResolvedConfig } from '../../types'; +import { createOverlayShortcutsRuntimeService } from '../overlay-shortcuts-runtime'; +import { composeShortcutRuntimes } from '../runtime/composers'; +import { tryBeginVisibleOverlayNumericSelection } from '../runtime/overlay-numeric-selection'; +import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime'; +import { readMpvInputBindings } from '../runtime/mpv-input-bindings'; +import { createSessionBindingsRuntime } from '../runtime/session-bindings-runtime'; +import type { AppState } from '../state'; +import type { MiningAssembly } from './mining'; +import type { OverlayModalsAssembly } from './overlay-modals'; +import type { CliSlice, IpcSlice } from './slices'; + +type OverlayManager = ReturnType; + +/** + * What shortcut handling needs from the rest of the app. Siblings built after this assembly + * in `main.ts` are reached through callbacks. + */ +export interface ShortcutsAssemblyDeps { + appState: Pick< + AppState, + | 'shortcutsRegistered' + | 'overlayRuntimeInitialized' + | 'windowTracker' + | 'keybindings' + | 'mpvClient' + | 'sessionBindings' + | 'sessionBindingsInitialized' + >; + getConfig: () => ResolvedConfig; + logger: Pick; + configDir: string; + isDev: boolean; + overlayManager: Pick< + OverlayManager, + 'getMainWindow' | 'getVisibleOverlayVisible' | 'broadcastToOverlayWindows' + >; + /** Electron's globalShortcut, injected so the assembly loads under `bun test`. */ + globalShortcut: GlobalShortcut; + getOverlayNotifications: () => Pick< + ReturnType, + 'showConfiguredStatusNotification' | 'showOverlayNotification' + >; + getOverlayModals: () => Pick< + OverlayModalsAssembly, + | 'openRuntimeOptionsPalette' + | 'openCharacterDictionaryManagerOverlay' + | 'openJimakuOverlay' + | 'openTsukihimeOverlay' + >; + getMining: () => Pick< + MiningAssembly, + | 'markLastCardAsAudioCard' + | 'copyCurrentSubtitle' + | 'copySubtitleCount' + | 'updateLastCardFromClipboard' + | 'triggerFieldGrouping' + | 'mineSentenceCard' + | 'mineSentenceCount' + >; + triggerSubsyncFromConfig: () => Promise; + cycleSecondarySubMode: () => void; + toggleVisibleOverlay: () => void; + openDictionarySettings: () => void; +} + +export type ShortcutsAssembly = ReturnType; + +/** + * Owns keyboard input outside the renderer: the overlay's local shortcuts, global shortcuts, + * the numeric multi-copy/multi-mine sessions, and the session bindings shared with mpv. + */ +export function createShortcutsAssembly(deps: ShortcutsAssemblyDeps) { + const { appState, overlayManager } = deps; + const showStatus = (text: string) => + deps.getOverlayNotifications().showConfiguredStatusNotification(text); + + const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({ + 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: showStatus, + openRuntimeOptionsPalette: () => { + deps.getOverlayModals().openRuntimeOptionsPalette(); + }, + openCharacterDictionaryManager: () => { + deps.getOverlayModals().openCharacterDictionaryManagerOverlay(); + }, + openJimaku: () => { + deps.getOverlayModals().openJimakuOverlay(); + }, + openTsukihime: () => { + deps.getOverlayModals().openTsukihimeOverlay(); + }, + markAudioCard: () => deps.getMining().markLastCardAsAudioCard(), + copySubtitleMultiple: (timeoutMs: number) => { + startPendingMultiCopy(timeoutMs); + }, + copySubtitle: () => { + deps.getMining().copyCurrentSubtitle(); + }, + toggleSecondarySubMode: () => deps.cycleSecondarySubMode(), + updateLastCardFromClipboard: () => deps.getMining().updateLastCardFromClipboard(), + triggerFieldGrouping: () => deps.getMining().triggerFieldGrouping(), + triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), + mineSentenceCard: () => deps.getMining().mineSentenceCard(), + mineSentenceMultiple: (timeoutMs: number) => { + startPendingMineSentenceMultiple(timeoutMs); + }, + cancelPendingMultiCopy: () => { + cancelPendingMultiCopy(); + }, + cancelPendingMineSentenceMultiple: () => { + cancelPendingMineSentenceMultiple(); + }, + }); + + const { + getConfiguredShortcuts, + registerGlobalShortcuts, + refreshGlobalAndOverlayShortcuts, + cancelPendingMultiCopy, + startPendingMultiCopy, + cancelPendingMineSentenceMultiple, + startPendingMineSentenceMultiple, + syncOverlayShortcuts, + refreshOverlayShortcuts, + } = composeShortcutRuntimes({ + globalShortcuts: { + getConfiguredShortcutsMainDeps: { + getResolvedConfig: () => deps.getConfig(), + defaultConfig: DEFAULT_CONFIG, + resolveConfiguredShortcuts, + }, + buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcutsHandler) => ({ + getConfiguredShortcuts: () => getConfiguredShortcutsHandler(), + registerGlobalShortcutsCore, + onToggleVisibleOverlay: () => deps.toggleVisibleOverlay(), + onOpenYomitanSettings: () => deps.openDictionarySettings(), + isDev: deps.isDev, + getMainWindow: () => overlayManager.getMainWindow(), + }), + buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcutsHandler) => ({ + unregisterAllGlobalShortcuts: () => deps.globalShortcut.unregisterAll(), + registerGlobalShortcuts: () => registerGlobalShortcutsHandler(), + syncOverlayShortcuts: () => syncOverlayShortcuts(), + }), + }, + numericShortcutRuntimeMainDeps: { + globalShortcut: deps.globalShortcut, + showMpvOsd: showStatus, + setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), + clearTimer: (timer) => clearTimeout(timer), + }, + numericSessions: { + onMultiCopyDigit: (count) => deps.getMining().copySubtitleCount(count), + onMineSentenceDigit: (count) => deps.getMining().mineSentenceCount(count), + tryBeginMultiCopyOverlaySelection: (timeoutMs) => + tryBeginVisibleOverlayNumericSelection({ + actionId: 'copySubtitleMultiple', + timeoutMs, + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + }), + tryBeginMineSentenceOverlaySelection: (timeoutMs) => + tryBeginVisibleOverlayNumericSelection({ + actionId: 'mineSentenceMultiple', + timeoutMs, + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + }), + }, + overlayShortcutsRuntimeMainDeps: { + overlayShortcutsRuntime, + }, + }); + + const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } = + createSessionBindingsRuntime({ + configDir: deps.configDir, + getKeybindings: () => appState.keybindings, + getConfiguredShortcuts: () => getConfiguredShortcuts(), + getResolvedConfig: () => deps.getConfig(), + getMpvClient: () => appState.mpvClient, + setSessionBindings: (bindings) => { + appState.sessionBindings = bindings; + }, + setSessionBindingsInitialized: (initialized) => { + appState.sessionBindingsInitialized = initialized; + }, + logWarn: (message) => deps.logger.warn(message), + onBindingsChanged: (bindings) => + overlayManager.broadcastToOverlayWindows( + IPC_CHANNELS.event.sessionBindingsChanged, + bindings, + ), + onMpvInputBindingsChanged: () => + overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.mpvInputBindingsChanged), + onWarning: (warning) => { + if (warning.kind !== 'conflict') return; + deps.getOverlayNotifications().showOverlayNotification({ + id: `session-binding-conflict:${warning.path}`, + title: 'Shortcut conflict', + body: warning.message, + variant: 'warning', + }); + }, + }); + + const ipc: IpcSlice<'getMpvInputBindings' | 'getSessionBindings' | 'getConfiguredShortcuts'> = { + getMpvInputBindings: async () => { + await refreshMpvSessionBindings(); + return readMpvInputBindings({ + getMpvClient: () => appState.mpvClient, + getConfiguredKeybindings: () => deps.getConfig().keybindings ?? [], + platform: + process.platform === 'darwin' + ? 'darwin' + : process.platform === 'win32' + ? 'win32' + : 'linux', + }); + }, + getSessionBindings: async () => { + await refreshMpvSessionBindings(); + return appState.sessionBindings; + }, + getConfiguredShortcuts: () => getConfiguredShortcuts(), + }; + + const cli: CliSlice< + 'startPendingMultiCopy' | 'startPendingMineSentenceMultiple' | 'getMultiCopyTimeoutMs' + > = { + startPendingMultiCopy: (timeoutMs) => startPendingMultiCopy(timeoutMs), + startPendingMineSentenceMultiple: (timeoutMs) => startPendingMineSentenceMultiple(timeoutMs), + getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, + }; + + return { + ipc, + cli, + overlayShortcutsRuntime, + getConfiguredShortcuts, + registerGlobalShortcuts, + refreshGlobalAndOverlayShortcuts, + startPendingMultiCopy, + startPendingMineSentenceMultiple, + refreshOverlayShortcuts, + persistSessionBindings, + refreshCurrentSessionBindings, + refreshMpvSessionBindings, + /** Overlay shortcuts are released while a modal owns keyboard input. */ + syncOverlayShortcutsForModal: (isActive: boolean): void => { + if (isActive) { + overlayShortcutsRuntime.unregisterOverlayShortcuts(); + } else { + overlayShortcutsRuntime.syncOverlayShortcuts(); + } + }, + }; +} diff --git a/src/main/assemblies/slices.ts b/src/main/assemblies/slices.ts new file mode 100644 index 00000000..3e1ed74c --- /dev/null +++ b/src/main/assemblies/slices.ts @@ -0,0 +1,19 @@ +import type { RegisterIpcRuntimeServicesParams } from '../ipc-runtime'; +import type { CliCommandContextMainDeps } from '../runtime/cli-command-context-factory'; + +/** Renderer IPC handlers, minus the two the IPC composer builds itself. */ +export type IpcHandlerDeps = Omit< + RegisterIpcRuntimeServicesParams['mainDeps'], + 'handleMpvCommand' | 'runSubsyncManual' +>; + +/** + * The IPC handlers one assembly provides. `Required` because most handlers are optional on + * the registration params, and a slice must not drop one silently. + */ +export type IpcSlice = Required>; + +/** The CLI command handlers one assembly provides. */ +export type CliSlice = Required< + Pick +>; diff --git a/src/main/assemblies/startup.test.ts b/src/main/assemblies/startup.test.ts new file mode 100644 index 00000000..cd70cba8 --- /dev/null +++ b/src/main/assemblies/startup.test.ts @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DEFAULT_CONFIG } from '../../config/definitions'; +import type { ResolvedConfig } from '../../types'; +import { createAppState } from '../state'; +import { createStartupAssembly, type StartupAssemblyDeps } from './startup'; + +type MpvClient = NonNullable['mpvClient']>; + +// Every callback throws, so a test fails if the assembly calls a dep it should only call later. +function lazy(name: string) { + return (): never => { + throw new Error(`${name} called unexpectedly`); + }; +} + +/** Builds deps whose every function throws unless a test overrides it. */ +function makeDeps(overrides: Partial = {}): StartupAssemblyDeps { + const thrower = (name: string): T => + new Proxy({}, { get: (_target, key: string) => lazy(`${name}.${key}`) }) as T; + return { + appState: createAppState({ mpvSocketPath: '/tmp/subminer-startup.sock', texthookerPort: 0 }), + configService: thrower('configService'), + logger: thrower('logger'), + logConfigWarning: lazy('logConfigWarning'), + configDir: '/tmp/subminer-startup-assembly-test', + userDataPath: '/tmp/subminer-startup-assembly-test', + defaultTexthookerPort: 5174, + showErrorBox: lazy('showErrorBox'), + reportFatalError: lazy('reportFatalError'), + requestAppQuit: lazy('requestAppQuit'), + logging: { + setLogLevel: lazy('setLogLevel'), + setLogRotation: lazy('setLogRotation'), + setLogFileToggles: lazy('setLogFileToggles'), + }, + overlayManager: thrower('overlayManager'), + overlayModalRuntime: thrower('overlayModalRuntime'), + subtitleWsService: thrower('subtitleWsService'), + annotationSubtitleWsService: thrower('annotationSubtitleWsService'), + texthookerService: thrower('texthookerService'), + configDerivedRuntime: thrower('configDerivedRuntime'), + hasMpvWebsocketPlugin: lazy('hasMpvWebsocketPlugin'), + loadSubtitlePosition: lazy('loadSubtitlePosition'), + setSecondarySubMode: lazy('setSecondarySubMode'), + syncOverlayMpvSubtitleSuppression: lazy('syncOverlayMpvSubtitleSuppression'), + yomitan: thrower('yomitan'), + stats: { ensureImmersionTrackerStarted: lazy('ensureImmersionTrackerStarted') } as never, + firstRunSetup: thrower('firstRunSetup'), + youtube: thrower('youtube'), + overlay: thrower('overlay'), + shortcuts: thrower('shortcuts'), + settings: { hotReload: thrower('settings.hotReload') }, + mining: thrower('mining'), + getAnilist: lazy('getAnilist'), + getJellyfin: lazy('getJellyfin'), + getPlayback: lazy('getPlayback'), + getPluginHost: lazy('getPluginHost'), + getOverlayNotifications: lazy('getOverlayNotifications'), + getOverlayVisibilityComposer: lazy('getOverlayVisibilityComposer'), + getVisibleOverlayInteraction: lazy('getVisibleOverlayInteraction'), + getOverlayGeometry: lazy('getOverlayGeometry'), + handleInitialArgs: lazy('handleInitialArgs'), + ...overrides, + }; +} + +/** Deps for the overlay prerequisites, recording what each step does. */ +function makePrereqDeps(calls: string[]): Partial { + const record = (name: string) => () => { + calls.push(name); + }; + return { + configService: { getConfig: () => DEFAULT_CONFIG } as StartupAssemblyDeps['configService'], + loadSubtitlePosition: record('loadSubtitlePosition'), + shortcuts: { + refreshCurrentSessionBindings: record('refreshCurrentSessionBindings'), + refreshOverlayShortcuts: record('refreshOverlayShortcuts'), + } as unknown as StartupAssemblyDeps['shortcuts'], + getPlayback: () => + ({ + createMpvClient: () => { + calls.push('createMpvClient'); + return {} as MpvClient; + }, + subtitleProcessingController: { + invalidateTokenizationCache: record('invalidateTokenizationCache'), + }, + getSubtitlePrefetchService: () => ({ onSeek: record('prefetch.onSeek') }), + getLastObservedTimePos: () => 12, + }) as unknown as ReturnType, + getOverlayVisibilityComposer: () => ({ + broadcastRuntimeOptionsChanged: record('broadcastRuntimeOptionsChanged'), + }), + }; +} + +// main.ts builds the assembly before most siblings exist, so construction must stay lazy. +test('createStartupAssembly does not call its deps while constructing', () => { + const startup = createStartupAssembly(makeDeps()); + assert.equal(typeof startup.runAppReady, 'function'); +}); + +test('overlay startup prerequisites are created once', () => { + const calls: string[] = []; + const deps = makeDeps(makePrereqDeps(calls)); + const startup = createStartupAssembly(deps); + + startup.ensureOverlayStartupPrereqs(); + deps.appState.subtitlePosition = { yPercent: 10 }; + startup.ensureOverlayStartupPrereqs(); + + assert.deepEqual(calls, [ + 'loadSubtitlePosition', + 'refreshCurrentSessionBindings', + 'createMpvClient', + 'refreshCurrentSessionBindings', + ]); + assert.ok(deps.appState.runtimeOptionsManager); + assert.ok(deps.appState.subtitleTimingTracker); +}); + +test('a runtime option change refreshes tokenization, prefetch, overlay state and shortcuts', () => { + const calls: string[] = []; + const deps = makeDeps(makePrereqDeps(calls)); + createStartupAssembly(deps).ensureOverlayStartupPrereqs(); + calls.length = 0; + + const manager = deps.appState.runtimeOptionsManager; + assert.ok(manager); + manager.setOptionValue('subtitle.annotation.jlpt', true); + + assert.deepEqual(calls, [ + 'invalidateTokenizationCache', + 'prefetch.onSeek', + 'broadcastRuntimeOptionsChanged', + 'refreshOverlayShortcuts', + ]); +}); + +test('a headless launch without --refresh-known-words runs the initial CLI command', async () => { + let handled = 0; + const deps = makeDeps({ + handleInitialArgs: () => { + handled += 1; + }, + }); + + await createStartupAssembly(deps).runHeadlessInitialCommand(); + + assert.equal(handled, 1); +}); + +test('a headless known-word refresh fails and quits when AnkiConnect is disabled', async (t) => { + const previousExitCode = process.exitCode; + t.after(() => { + process.exitCode = previousExitCode ?? 0; + }); + const config: ResolvedConfig = { + ...DEFAULT_CONFIG, + ankiConnect: { ...DEFAULT_CONFIG.ankiConnect, enabled: false }, + }; + const errors: string[] = []; + let quits = 0; + const deps = makeDeps({ + configService: { getConfig: () => config } as StartupAssemblyDeps['configService'], + logger: { error: (message: string) => errors.push(message) } as never, + requestAppQuit: () => { + quits += 1; + }, + }); + deps.appState.initialArgs = { refreshKnownWords: true } as never; + + await createStartupAssembly(deps).runHeadlessInitialCommand(); + + assert.deepEqual(errors, [ + 'Headless known-word refresh failed: AnkiConnect integration not enabled', + ]); + assert.equal(process.exitCode, 1); + assert.equal(quits, 1); +}); diff --git a/src/main/assemblies/startup.ts b/src/main/assemblies/startup.ts new file mode 100644 index 00000000..c525b271 --- /dev/null +++ b/src/main/assemblies/startup.ts @@ -0,0 +1,518 @@ +import path from 'node:path'; +import { AnkiIntegration } from '../../anki-integration'; +import { isHeadlessInitialCommand } from '../../cli/args'; +import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, generateConfigTemplate } from '../../config'; +import type { ConfigService } from '../../config/service'; +import { + initializeOverlayRuntime as initializeOverlayRuntimeCore, + type createOverlayManager, + type SubtitleWebSocket, + type Texthooker, +} from '../../core/services'; +import { resolveKeybindings, showDesktopNotification } from '../../core/utils'; +import type { Logger } from '../../logger'; +import { RuntimeOptionsManager } from '../../runtime-options'; +import { ensureDefaultConfigBootstrap, getDefaultConfigFilePaths } from '../../shared/setup-state'; +import { SubtitleTimingTracker } from '../../subtitle-timing-tracker'; +import type { SecondarySubMode, WindowGeometry } from '../../types'; +import { mergeAiConfig } from '../../ai/config'; +import type { OverlayModalRuntime } from '../overlay-runtime'; +import { + composeAppReadyRuntime, + type AppReadyComposerOptions, + type composeOverlayVisibilityRuntime, +} from '../runtime/composers'; +import type { createConfigDerivedRuntime } from '../runtime/domains/startup'; +import { shouldAutoOpenFirstRunSetup } from '../runtime/first-run-setup-service'; +import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime'; +import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime'; +import { createOverlayRuntimeBootstrapHandlers } from '../runtime/overlay-runtime-bootstrap-handlers'; +import { + getStartupModeFlags, + shouldHandleInitialArgsBeforeDeferredOverlayWarmup, + shouldRefreshAnilistOnConfigReload, +} from '../runtime/startup-mode-flags'; +import type { VisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime'; +import type { AppState } from '../state'; +import type { AnilistAssembly } from './anilist'; +import type { FirstRunSetupAssembly } from './first-run-setup'; +import type { JellyfinAssembly } from './jellyfin'; +import type { MiningAssembly } from './mining'; +import type { OverlayAssembly } from './overlay'; +import type { PlaybackAssembly } from './playback'; +import type { SettingsAssembly } from './settings'; +import type { ShortcutsAssembly } from './shortcuts'; +import type { StatsAssembly } from './stats'; +import type { YomitanAssembly } from './yomitan'; +import type { YoutubeAssembly } from './youtube'; + +type AppReadyMainDeps = AppReadyComposerOptions['appReadyRuntimeMainDeps']; +type OverlayManager = ReturnType; +type FatalErrorReporter = (error: unknown, options: { title: string; context: string }) => void; + +/** + * What startup needs from the rest of the app. It touches nearly every domain, so most deps are + * sibling assemblies; those built after this one are reached through getters. + */ +export interface StartupAssemblyDeps { + appState: AppState; + configService: Pick< + ConfigService, + 'getConfig' | 'getConfigPath' | 'getWarnings' | 'reloadConfigStrict' + >; + logger: Pick; + logConfigWarning: AppReadyMainDeps['logConfigWarning']; + configDir: string; + userDataPath: string; + defaultTexthookerPort: number; + /** Electron's `dialog.showErrorBox`, injected so the assembly loads under `bun test`. */ + showErrorBox: (title: string, content: string) => void; + reportFatalError: FatalErrorReporter; + requestAppQuit: () => void; + logging: Pick; + overlayManager: Pick; + overlayModalRuntime: Pick; + subtitleWsService: Pick; + annotationSubtitleWsService: Pick; + texthookerService: Pick; + configDerivedRuntime: Pick< + ReturnType, + 'shouldAutoInitializeOverlayRuntimeFromConfig' + >; + hasMpvWebsocketPlugin: () => boolean; + loadSubtitlePosition: () => void; + setSecondarySubMode: (mode: SecondarySubMode) => void; + syncOverlayMpvSubtitleSuppression: () => void; + yomitan: Pick; + stats: Pick; + firstRunSetup: Pick; + youtube: Pick< + YoutubeAssembly, + | 'getCachedMediaPathForCurrentPlayback' + | 'shouldRequireMediaCacheForCurrentPlayback' + | 'getCurrentMediaCacheSourceUrl' + >; + overlay: Pick< + OverlayAssembly, + | 'overlayVisibilityRuntime' + | 'createMainWindow' + | 'setVisibleOverlayVisible' + | 'ensureOverlayWindowsReadyForVisibilityActions' + >; + shortcuts: Pick< + ShortcutsAssembly, + | 'overlayShortcutsRuntime' + | 'refreshCurrentSessionBindings' + | 'registerGlobalShortcuts' + | 'refreshOverlayShortcuts' + >; + settings: { hotReload: Pick }; + mining: Pick; + getAnilist: () => Pick; + getJellyfin: () => Pick; + getPlayback: () => Pick< + PlaybackAssembly, + | 'createMpvClient' + | 'createMecabTokenizerAndCheck' + | 'prewarmSubtitleDictionaries' + | 'startBackgroundWarmups' + | 'subtitleProcessingController' + | 'getSubtitlePrefetchService' + | 'getLastObservedTimePos' + >; + getPluginHost: () => { loadAll: () => Promise }; + getOverlayNotifications: () => Pick< + ReturnType, + 'showOverlayNotification' | 'dismissOverlayNotification' + >; + getOverlayVisibilityComposer: () => Pick< + ReturnType, + 'broadcastRuntimeOptionsChanged' + >; + /** Pointer interaction and geometry stay in `main.ts`: both import Electron at module load. */ + getVisibleOverlayInteraction: () => Pick< + VisibleOverlayInteractionRuntime, + 'createOverlayWindowTracker' | 'bindVisibleOverlayOwner' | 'releaseVisibleOverlayOwner' + >; + getOverlayGeometry: () => Pick< + ReturnType, + 'updateVisibleOverlayBounds' + >; + /** Runs the CLI command the app was launched with. */ + handleInitialArgs: () => void; +} + +export type StartupAssembly = ReturnType; + +/** + * Owns how the app starts: the app-ready sequence, the headless known-word refresh, and bringing + * up the overlay runtime and its prerequisites on demand (CLI commands, YouTube playback, the + * tray). `main.ts` hands `runAppReady` to the app lifecycle. + */ +export function createStartupAssembly(deps: StartupAssemblyDeps) { + const { appState, configService, logger } = deps; + const isHeadlessLaunch = (): boolean => + Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)); + const knownWordCacheStatePath = path.join(deps.userDataPath, 'known-words-cache.json'); + + function createRuntimeOptionsManager(): RuntimeOptionsManager { + return new RuntimeOptionsManager(() => configService.getConfig().ankiConnect, { + applyAnkiPatch: (patch) => { + if (appState.ankiIntegration) { + appState.ankiIntegration.applyRuntimeConfigPatch(patch); + } + }, + getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle, + onOptionsChanged: () => { + const playback = deps.getPlayback(); + playback.subtitleProcessingController.invalidateTokenizationCache(); + playback.getSubtitlePrefetchService()?.onSeek(playback.getLastObservedTimePos()); + deps.getOverlayVisibilityComposer().broadcastRuntimeOptionsChanged(); + deps.shortcuts.refreshOverlayShortcuts(); + }, + }); + } + + function resolveSessionKeybindings(): void { + appState.keybindings = resolveKeybindings(configService.getConfig(), DEFAULT_KEYBINDINGS); + deps.shortcuts.refreshCurrentSessionBindings(); + } + + /** What the subtitle websockets send a client that connects mid-line. */ + function getCurrentSubtitleForWebsocket() { + return ( + appState.currentSubtitleData ?? + (appState.currentSubText + ? { + text: appState.currentSubText, + tokens: null, + startTime: appState.mpvClient?.currentSubStart ?? null, + endTime: appState.mpvClient?.currentSubEnd ?? null, + } + : null) + ); + } + function getFrequencyOptionsForWebsocket() { + const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; + return { + enabled: frequencyDictionary.enabled, + topX: frequencyDictionary.topX, + mode: frequencyDictionary.mode, + }; + } + + const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = + createOverlayRuntimeBootstrapHandlers({ + initializeOverlayRuntimeMainDeps: { + appState, + overlayManager: { + getVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(), + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => + deps.overlay.overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + }, + refreshCurrentSubtitle: () => { + deps + .getPlayback() + .subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => deps.shortcuts.overlayShortcutsRuntime.syncOverlayShortcuts(), + }, + createMainWindow: () => { + if (isHeadlessLaunch()) { + return; + } + deps.overlay.createMainWindow(); + }, + registerGlobalShortcuts: () => { + if (isHeadlessLaunch()) { + return; + } + deps.shortcuts.registerGlobalShortcuts(); + }, + createWindowTracker: (override, targetMpvSocketPath) => + deps + .getVisibleOverlayInteraction() + .createOverlayWindowTracker(override, targetMpvSocketPath), + updateVisibleOverlayBounds: (geometry: WindowGeometry) => + deps.getOverlayGeometry().updateVisibleOverlayBounds(geometry), + bindOverlayOwner: () => deps.getVisibleOverlayInteraction().bindVisibleOverlayOwner(), + releaseOverlayOwner: () => deps.getVisibleOverlayInteraction().releaseVisibleOverlayOwner(), + getOverlayWindows: () => deps.overlayManager.getOverlayWindows(), + getResolvedConfig: () => configService.getConfig(), + showDesktopNotification, + showOverlayNotification: (payload) => + deps.getOverlayNotifications().showOverlayNotification(payload), + dismissOverlayNotification: (id) => + deps.getOverlayNotifications().dismissOverlayNotification(id), + createFieldGroupingCallback: () => deps.mining.createFieldGroupingCallback(), + getKnownWordCacheStatePath: () => knownWordCacheStatePath, + getCachedMediaPath: (currentVideoPath, kind) => + deps.youtube.getCachedMediaPathForCurrentPlayback(currentVideoPath, kind), + shouldRequireRemoteMediaCache: () => + deps.youtube.shouldRequireMediaCacheForCurrentPlayback(), + getYoutubeMediaSourceUrl: () => deps.youtube.getCurrentMediaCacheSourceUrl(), + shouldStartAnkiIntegration: () => !isHeadlessLaunch(), + }, + initializeOverlayRuntimeBootstrapDeps: { + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + initializeOverlayRuntimeCore, + setOverlayRuntimeInitialized: (initialized) => { + appState.overlayRuntimeInitialized = initialized; + }, + startBackgroundWarmups: () => { + if (isHeadlessLaunch()) { + return; + } + deps.getPlayback().startBackgroundWarmups(); + }, + }, + }); + + /** Creates the overlay windows and runtime once, then wires the Anki integration it built. */ + function initializeOverlayRuntime(): void { + initializeOverlayRuntimeHandler(); + if (!isHeadlessLaunch()) { + deps.overlayModalRuntime.primeModalWindow(); + } + if (appState.ankiIntegration) { + deps.mining.attachToAnkiIntegration(appState.ankiIntegration); + } + deps.syncOverlayMpvSubtitleSuppression(); + } + + /** Creates whatever overlay commands need that app-ready skipped on a deferred start. */ + function ensureOverlayStartupPrereqs(): void { + if (appState.subtitlePosition === null) { + deps.loadSubtitlePosition(); + } + if (appState.keybindings.length === 0) { + resolveSessionKeybindings(); + } else if (!appState.sessionBindingsInitialized) { + deps.shortcuts.refreshCurrentSessionBindings(); + } + if (!appState.mpvClient) { + appState.mpvClient = deps.getPlayback().createMpvClient(); + } + if (!appState.runtimeOptionsManager) { + appState.runtimeOptionsManager = createRuntimeOptionsManager(); + } + if (!appState.subtitleTimingTracker) { + appState.subtitleTimingTracker = new SubtitleTimingTracker(); + } + } + + async function ensureYoutubePlaybackRuntimeReady(): Promise { + ensureOverlayStartupPrereqs(); + await deps.yomitan.ensureExtensionLoaded(); + if (!appState.overlayRuntimeInitialized) { + initializeOverlayRuntime(); + return; + } + deps.overlay.ensureOverlayWindowsReadyForVisibilityActions(); + } + + /** `--refresh-known-words` without the overlay: refresh the cache, then quit. */ + async function runHeadlessInitialCommand(): Promise { + if (!appState.initialArgs?.refreshKnownWords) { + deps.handleInitialArgs(); + return; + } + + const resolvedConfig = configService.getConfig(); + if (resolvedConfig.ankiConnect.enabled !== true) { + logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled'); + process.exitCode = 1; + deps.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, + }), + knownWordCacheStatePath, + 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(); + deps.requestAppQuit(); + } + } + + const failHandlers = { + logError: (details: string) => logger.error(details), + showErrorBox: (title: string, details: string) => deps.showErrorBox(title, details), + quit: () => deps.requestAppQuit(), + }; + + const { appReadyRuntimeRunner } = composeAppReadyRuntime({ + reloadConfigMainDeps: { + reloadConfigStrict: () => configService.reloadConfigStrict(), + logInfo: (message) => logger.info(message), + logDebug: (message) => logger.debug(message), + logWarning: (message) => logger.warn(message), + showDesktopNotification: (title, options) => showDesktopNotification(title, options), + startConfigHotReload: () => deps.settings.hotReload.start(), + shouldRefreshAnilistClientSecretState: () => + shouldRefreshAnilistOnConfigReload(appState.initialArgs), + refreshAnilistClientSecretState: (options) => + deps.getAnilist().refreshClientSecretStateIfEnabled(options), + failHandlers, + }, + criticalConfigErrorMainDeps: { + getConfigPath: () => configService.getConfigPath(), + failHandlers, + }, + appReadyRuntimeMainDeps: { + ensureDefaultConfigBootstrap: () => { + ensureDefaultConfigBootstrap({ + configDir: deps.configDir, + configFilePaths: getDefaultConfigFilePaths(deps.configDir), + generateTemplate: () => generateConfigTemplate(DEFAULT_CONFIG), + }); + }, + loadSubtitlePosition: () => deps.loadSubtitlePosition(), + resolveKeybindings: () => resolveSessionKeybindings(), + createMpvClient: () => { + appState.mpvClient = deps.getPlayback().createMpvClient(); + }, + startPlugins: () => { + void deps.getPluginHost().loadAll(); + }, + getResolvedConfig: () => configService.getConfig(), + getConfigWarnings: () => configService.getWarnings(), + logConfigWarning: (warning) => deps.logConfigWarning(warning), + ...deps.logging, + initRuntimeOptionsManager: () => { + appState.runtimeOptionsManager = createRuntimeOptionsManager(); + }, + setSecondarySubMode: (mode: SecondarySubMode) => { + deps.setSecondarySubMode(mode); + }, + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, + defaultAnnotationWebsocketPort: DEFAULT_CONFIG.annotationWebsocket.port, + defaultTexthookerPort: deps.defaultTexthookerPort, + hasMpvWebsocketPlugin: () => deps.hasMpvWebsocketPlugin(), + startSubtitleWebsocket: (port: number) => { + deps.subtitleWsService.start( + port, + getCurrentSubtitleForWebsocket, + getFrequencyOptionsForWebsocket, + ); + }, + startAnnotationWebsocket: (port: number) => { + deps.annotationSubtitleWsService.start( + port, + getCurrentSubtitleForWebsocket, + getFrequencyOptionsForWebsocket, + ); + }, + startTexthooker: (port: number, websocketUrl?: string) => { + if (!deps.texthookerService.isRunning()) { + deps.texthookerService.start(port, websocketUrl); + } + }, + log: (message) => logger.info(message), + createMecabTokenizerAndCheck: async () => { + await deps.getPlayback().createMecabTokenizerAndCheck(); + }, + createSubtitleTimingTracker: () => { + appState.subtitleTimingTracker = new SubtitleTimingTracker(); + }, + loadYomitanExtension: async () => { + await deps.yomitan.loadExtension(); + }, + ensureYomitanExtensionLoaded: async () => { + await deps.yomitan.ensureExtensionLoaded(); + }, + handleFirstRunSetup: async () => { + const snapshot = await deps.firstRunSetup.service.ensureSetupStateInitialized(); + appState.firstRunSetupCompleted = snapshot.state.status === 'completed'; + const args = appState.initialArgs; + if (args && shouldAutoOpenFirstRunSetup(args)) { + const force = Boolean(args.setup); + if (force || snapshot.state.status !== 'completed') { + deps.firstRunSetup.openWindow(force); + } + } + }, + startJellyfinRemoteSession: async () => { + await deps.getJellyfin().startJellyfinRemoteSession(); + }, + prewarmSubtitleDictionaries: async () => { + await deps.getPlayback().prewarmSubtitleDictionaries(); + }, + startBackgroundWarmups: () => { + deps.getPlayback().startBackgroundWarmups(); + }, + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: () => + appState.backgroundMode + ? false + : deps.configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(), + setVisibleOverlayVisible: (visible: boolean) => + deps.overlay.setVisibleOverlayVisible(visible), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + runHeadlessInitialCommand: () => runHeadlessInitialCommand(), + handleInitialArgs: () => deps.handleInitialArgs(), + shouldRunHeadlessInitialCommand: () => isHeadlessLaunch(), + shouldUseMinimalStartup: () => + getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup, + shouldSkipHeavyStartup: () => + getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup, + shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => + shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs), + createImmersionTracker: () => { + deps.stats.ensureImmersionTrackerStarted(); + }, + logDebug: (message: string) => { + logger.debug(message); + }, + now: () => Date.now(), + }, + immersionTrackerStartupMainDeps: deps.stats.immersionTrackerStartupDeps, + }); + + /** The app-ready sequence; a failure is reported as fatal and quits the app. */ + async function runAppReady(): Promise { + try { + await appReadyRuntimeRunner(); + } catch (error) { + deps.reportFatalError(error, { + title: 'SubMiner startup failed', + context: 'SubMiner failed during app-ready startup.', + }); + process.exitCode = 1; + deps.requestAppQuit(); + } + } + + return { + runAppReady, + runHeadlessInitialCommand, + initializeOverlayRuntime, + ensureOverlayStartupPrereqs, + ensureYoutubePlaybackRuntimeReady, + }; +} diff --git a/src/main/assemblies/stats.test.ts b/src/main/assemblies/stats.test.ts new file mode 100644 index 00000000..d4a56192 --- /dev/null +++ b/src/main/assemblies/stats.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DEFAULT_CONFIG } from '../../config/definitions'; +import type { ResolvedConfig } from '../../types'; +import type { AppState } from '../state'; +import { createStatsAssembly, type StatsAssemblyDeps } from './stats'; + +const trackingDisabledConfig: ResolvedConfig = { + ...DEFAULT_CONFIG, + immersionTracking: { ...DEFAULT_CONFIG.immersionTracking, enabled: false }, +}; + +// Every callback throws, so a test fails if the assembly calls a dep it should only call later. +function makeDeps(overrides: Partial = {}): StatsAssemblyDeps { + const lazy = (name: string) => (): never => { + throw new Error(`${name} called unexpectedly`); + }; + return { + appState: { + immersionTracker: null, + statsServer: null, + statsStartupInProgress: false, + mpvClient: null, + mpvSocketPath: '/tmp/subminer-stats-test.sock', + mecabTokenizer: null, + ankiIntegration: null, + currentMediaPath: null, + currentMediaTitle: null, + }, + getConfig: lazy('getConfig'), + logger: { info: lazy('info'), warn: lazy('warn'), error: lazy('error'), debug: lazy('debug') }, + userDataPath: '/tmp/subminer-stats-assembly-test', + mainDirname: '/tmp/subminer-stats-assembly-test/dist', + defaultImmersionDbPath: '/tmp/subminer-stats-assembly-test/immersion.sqlite', + anilistRateLimiter: { acquire: lazy('acquire'), recordResponse: lazy('recordResponse') }, + statsWindow: { registerToggle: lazy('registerToggle'), toggle: lazy('toggle') }, + yomitanParserRuntimeDeps: { + getYomitanExt: lazy('getYomitanExt'), + getYomitanSession: lazy('getYomitanSession'), + getYomitanParserWindow: lazy('getYomitanParserWindow'), + setYomitanParserWindow: lazy('setYomitanParserWindow'), + getYomitanParserReadyPromise: lazy('getYomitanParserReadyPromise'), + setYomitanParserReadyPromise: lazy('setYomitanParserReadyPromise'), + getYomitanParserInitPromise: lazy('getYomitanParserInitPromise'), + setYomitanParserInitPromise: lazy('setYomitanParserInitPromise'), + }, + getYomitanAnkiDeckName: lazy('getYomitanAnkiDeckName'), + getTokenizeSubtitle: lazy('getTokenizeSubtitle'), + createMecabTokenizerAndCheck: lazy('createMecabTokenizerAndCheck'), + getOverlayNotifications: lazy('getOverlayNotifications'), + getOverlayBounds: lazy('getOverlayBounds'), + onStatsOverlayVisibilityChanged: lazy('onStatsOverlayVisibilityChanged'), + runAnilistPostWatchUpdate: lazy('runAnilistPostWatchUpdate'), + openExternal: lazy('openExternal'), + requestAppQuit: lazy('requestAppQuit'), + ...overrides, + }; +} + +function withTracker( + deps: StatsAssemblyDeps, + markActiveVideoWatched: () => Promise, +): StatsAssemblyDeps { + const tracker = { markActiveVideoWatched } as unknown as NonNullable< + AppState['immersionTracker'] + >; + return { ...deps, appState: { ...deps.appState, immersionTracker: tracker } }; +} + +// main.ts builds the assembly before most siblings exist, so construction must stay lazy. +test('createStatsAssembly does not call its deps while constructing', () => { + const stats = createStatsAssembly(makeDeps()); + assert.equal(typeof stats.ensureImmersionTrackerStarted, 'function'); + assert.equal(typeof stats.toggleStatsOverlay, 'function'); +}); + +test('a manual watched mark tries to start the immersion tracker first', async () => { + const infoLogs: string[] = []; + const stats = createStatsAssembly( + makeDeps({ + getConfig: () => trackingDisabledConfig, + logger: { + info: (message: string) => { + infoLogs.push(message); + }, + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + }), + ); + + // With tracking disabled, the startup attempt logs and leaves no tracker to mark. + assert.equal(await stats.markActiveVideoWatched(), false); + assert.deepEqual(infoLogs, ['Immersion tracking disabled in config']); +}); + +for (const c of [ + { name: 'runs the AniList update after a successful mark', marked: true, anilistRuns: 1 }, + { name: 'skips the AniList update when nothing was marked', marked: false, anilistRuns: 0 }, +]) { + test(`a manual watched mark ${c.name}`, async () => { + let anilistRuns = 0; + const stats = createStatsAssembly( + withTracker( + makeDeps({ + runAnilistPostWatchUpdate: async () => { + anilistRuns += 1; + }, + }), + async () => c.marked, + ), + ); + + assert.equal(await stats.markActiveVideoWatched(), c.marked); + assert.equal(anilistRuns, c.anilistRuns); + }); +} + +test('a failed AniList update does not undo a manual watched mark', async () => { + const warnings: string[] = []; + const stats = createStatsAssembly( + withTracker( + makeDeps({ + logger: { + info: () => {}, + warn: (message: string) => { + warnings.push(message); + }, + error: () => {}, + debug: () => {}, + }, + runAnilistPostWatchUpdate: async () => { + throw new Error('AniList unavailable'); + }, + }), + async () => true, + ), + ); + + assert.equal(await stats.markActiveVideoWatched(), true); + assert.deepEqual(warnings, [ + 'Failed to run AniList post-watch update after manual watched mark:', + ]); +}); diff --git a/src/main/assemblies/stats.ts b/src/main/assemblies/stats.ts new file mode 100644 index 00000000..03bbb85f --- /dev/null +++ b/src/main/assemblies/stats.ts @@ -0,0 +1,353 @@ +import path from 'node:path'; +import { ImmersionTrackerService } from '../../core/services'; +import { createCoverArtFetcher } from '../../core/services/anilist/cover-art-fetcher'; +import type { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter'; +import { resolveLegacyVocabularyPosFromTokens } from '../../core/services/immersion-tracker/legacy-vocabulary-pos'; +import type { StatsWindowOptions } from '../../core/services/stats-window'; +import { readBundledTmdbApiKey } from '../../core/services/tmdb/bundled-api-key'; +import { createLiveActionMetadataResolver } from '../../core/services/tmdb/live-action-resolver'; +import { createTmdbApiKeyResolver, createTmdbClient } from '../../core/services/tmdb/tmdb-client'; +import { createLogger, type Logger } from '../../logger'; +import type { ResolvedConfig } from '../../types'; +import { createEnsureBackgroundStatsServerHandler } from '../runtime/background-stats-startup'; +import { createImmersionMediaRuntime } from '../runtime/domains/startup'; +import { + createImmersionTrackerStartupHandler, + type ImmersionTrackerStartupDeps, +} from '../runtime/immersion-startup'; +import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime'; +import { + createRunStatsCliCommandHandler, + writeStatsCliCommandResponse, +} from '../runtime/stats-cli-command'; +import { + createStatsServerRuntime, + type StatsServerRuntimeDeps, +} from '../runtime/stats-server-runtime'; +import type { AppState } from '../state'; +import type { PlaybackAssembly } from './playback'; +import type { CliSlice, IpcSlice } from './slices'; +import type { YomitanAssembly } from './yomitan'; + +/** + * What the stats domain needs from the rest of the app. Siblings built after this assembly in + * `main.ts` are reached through callbacks. + */ +export interface StatsAssemblyDeps { + appState: Pick< + AppState, + | 'immersionTracker' + | 'statsServer' + | 'statsStartupInProgress' + | 'mpvClient' + | 'mpvSocketPath' + | 'mecabTokenizer' + | 'ankiIntegration' + | 'currentMediaPath' + | 'currentMediaTitle' + >; + getConfig: () => ResolvedConfig; + logger: Pick; + userDataPath: string; + /** Compiled main-process directory; the stats bundle, preload and TMDB key sit beside it. */ + mainDirname: string; + defaultImmersionDbPath: string; + anilistRateLimiter: ReturnType; + /** The stats window module reaches Electron's `dialog`, so it is injected. */ + statsWindow: { + registerToggle: (options: StatsWindowOptions) => void; + toggle: (options: StatsWindowOptions) => Promise; + }; + yomitanParserRuntimeDeps: ReturnType; + getYomitanAnkiDeckName: () => Promise; + getTokenizeSubtitle: PlaybackAssembly['getTokenizeSubtitleDeferred']; + createMecabTokenizerAndCheck: () => Promise; + getOverlayNotifications: () => Pick< + ReturnType, + 'showConfiguredStatusNotification' + >; + getOverlayBounds: () => ReturnType; + onStatsOverlayVisibilityChanged: (visible: boolean) => void; + /** Forced AniList post-watch update after a manual watched mark. */ + runAnilistPostWatchUpdate: () => Promise; + openExternal: (url: string) => Promise; + requestAppQuit: () => void; +} + +export type StatsAssembly = ReturnType; + +/** + * Owns the immersion tracker's startup, the stats HTTP server (in-process or background), the + * stats overlay window options, and the `--stats` CLI command. + */ +export function createStatsAssembly(deps: StatsAssemblyDeps) { + const { appState, logger } = deps; + // Release builds stage a project TMDB key next to the compiled main process. + const bundledTmdbApiKey = readBundledTmdbApiKey(deps.mainDirname); + let hasAttemptedImmersionTrackerStartup = false; + + async function resolveSentenceSearchHeadwords(term: string): Promise { + const fallback = term.trim() ? [term.trim()] : []; + try { + const tokenizeSubtitleDeferred = deps.getTokenizeSubtitle(); + const tokenized = tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(term) : null; + const tokens = tokenized?.tokens ?? []; + if (tokens.length === 0) return fallback; + + const seen = new Set(); + const headwords: string[] = []; + for (const token of tokens) { + const headword = (token.headword || token.surface).trim(); + if (!headword || seen.has(headword)) continue; + seen.add(headword); + headwords.push(headword); + } + return headwords.length > 0 ? headwords : fallback; + } catch (error) { + logger.debug( + 'Failed to resolve sentence-search headwords:', + error instanceof Error ? error.message : String(error), + ); + return fallback; + } + } + + const statsServerRuntime = createStatsServerRuntime({ + userDataPath: deps.userDataPath, + statsDistPath: path.join(deps.mainDirname, '..', 'stats', 'dist'), + getResolvedConfig: () => deps.getConfig(), + getImmersionTracker: () => appState.immersionTracker, + setAppStateStatsServer: (server) => { + appState.statsServer = server; + }, + getMpvSocketPath: () => appState.mpvSocketPath, + ...(deps.yomitanParserRuntimeDeps satisfies Pick< + StatsServerRuntimeDeps, + keyof ReturnType + >), + getYomitanAnkiDeckName: () => deps.getYomitanAnkiDeckName(), + getAnilistRateLimiter: () => deps.anilistRateLimiter, + getBundledTmdbApiKey: () => bundledTmdbApiKey, + resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, + trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => { + appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds); + }, + resolveSentenceSearchHeadwords: (term) => resolveSentenceSearchHeadwords(term), + ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(), + setStatsStartupInProgress: (inProgress) => { + appState.statsStartupInProgress = inProgress; + }, + }); + const { + stopStatsServer, + ensureStatsServerStarted, + ensureBackgroundStatsServerStarted, + stopBackgroundStatsServer, + } = statsServerRuntime; + + const immersionMediaRuntime = createImmersionMediaRuntime({ + getResolvedConfig: () => deps.getConfig(), + defaultImmersionDbPath: deps.defaultImmersionDbPath, + getTracker: () => appState.immersionTracker, + getMpvClient: () => appState.mpvClient, + getCurrentMediaPath: () => appState.currentMediaPath, + getCurrentMediaTitle: () => appState.currentMediaTitle, + logDebug: (message) => logger.debug(message), + logInfo: (message) => logger.info(message), + }); + + const coverArtFetcher = createCoverArtFetcher( + deps.anilistRateLimiter, + createLogger('main:stats-cover-art'), + { + liveAction: createLiveActionMetadataResolver( + createTmdbClient({ + resolveApiKey: createTmdbApiKeyResolver( + () => deps.getConfig().tmdb, + () => bundledTmdbApiKey, + ), + }), + createLogger('main:tmdb'), + ), + }, + ); + + // One options object serves the overlay's IPC toggle and the session-action toggle. + const statsOverlayOptions: StatsWindowOptions = { + preloadPath: path.join(deps.mainDirname, 'preload-stats.js'), + getApiBaseUrl: async () => (await ensureStatsServerStarted()).url, + onStartupError: (error) => + deps + .getOverlayNotifications() + .showConfiguredStatusNotification( + `Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`, + { title: 'Stats' }, + ), + getToggleKey: () => deps.getConfig().stats.toggleKey, + resolveBounds: () => deps.getOverlayBounds(), + onVisibilityChanged: (visible) => { + deps.onStatsOverlayVisibilityChanged(visible); + }, + }; + + 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 immersionTrackerStartupDeps: ImmersionTrackerStartupDeps = { + getResolvedConfig: () => deps.getConfig(), + getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), + createTrackerService: (params) => + new ImmersionTrackerService({ + ...params, + resolveLegacyVocabularyPos, + }), + setTracker: (tracker) => { + const trackerHasChanged = + appState.immersionTracker !== null && appState.immersionTracker !== tracker; + if (trackerHasChanged && appState.statsServer) { + void stopStatsServer().catch((error: unknown) => { + logger.warn('Failed to stop stats server while replacing immersion tracker.', error); + }); + appState.statsServer = null; + } + + appState.immersionTracker = tracker as ImmersionTrackerService | null; + appState.immersionTracker?.setCoverArtFetcher(coverArtFetcher); + if (tracker) { + // Start HTTP stats server + if (!appState.statsServer) { + const config = deps.getConfig(); + if (config.stats.autoStartServer) { + void ensureStatsServerStarted().catch((error: unknown) => { + logger.warn('Failed to auto-start stats server.', error); + }); + } + } + + // Register stats overlay toggle IPC handler (idempotent) + deps.statsWindow.registerToggle(statsOverlayOptions); + } + }, + 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( + immersionTrackerStartupDeps, + ); + + /** Starts the tracker at most once per process; later calls are no-ops. */ + function ensureImmersionTrackerStarted(): void { + if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) { + return; + } + hasAttemptedImmersionTrackerStartup = true; + createImmersionTrackerStartup(); + } + + // Stats startup must not auto-connect mpv, so the tracker starts under this flag. + function ensureImmersionTrackerStartedForStats(): void { + appState.statsStartupInProgress = true; + try { + ensureImmersionTrackerStarted(); + } finally { + appState.statsStartupInProgress = false; + } + } + + const ensureBackgroundStatsServer = createEnsureBackgroundStatsServerHandler({ + isStatsAutoStartEnabled: () => deps.getConfig().stats.autoStartServer, + isImmersionTrackingEnabled: () => deps.getConfig().immersionTracking?.enabled !== false, + ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(), + logInfo: (message) => logger.info(message), + logWarn: (message, error) => logger.warn(message, error), + }); + + const runStatsCliCommand = createRunStatsCliCommandHandler({ + getResolvedConfig: () => deps.getConfig(), + ensureImmersionTrackerStarted: () => ensureImmersionTrackerStartedForStats(), + ensureVocabularyCleanupTokenizerReady: async () => { + await deps.createMecabTokenizerAndCheck(); + }, + getImmersionTracker: () => appState.immersionTracker, + ensureStatsServerStarted: async () => (await ensureStatsServerStarted()).url, + ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(), + stopBackgroundStatsServer: () => stopBackgroundStatsServer(), + openExternal: (url: string) => deps.openExternal(url), + writeResponse: (responsePath, payload) => { + writeStatsCliCommandResponse(responsePath, payload); + }, + exitAppWithCode: (code) => { + process.exitCode = code; + deps.requestAppQuit(); + }, + logInfo: (message) => logger.info(message), + logWarn: (message, error) => logger.warn(message, error), + logError: (message, error) => logger.error(message, error), + }); + + const ipc: IpcSlice<'getImmersionTracker'> = { + getImmersionTracker: () => appState.immersionTracker, + }; + + const cli: CliSlice<'runStatsCommand' | 'ensureBackgroundStatsServer'> = { + runStatsCommand: (args, source) => runStatsCliCommand(args, source), + ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(), + }; + + return { + ipc, + cli, + immersionMediaRuntime, + immersionTrackerStartupDeps, + ensureImmersionTrackerStarted, + stopStatsServer, + ensureBackgroundStatsServer, + runStatsCliCommand, + toggleStatsOverlay: () => deps.statsWindow.toggle(statsOverlayOptions), + recordCardsMined: (count: number, noteIds?: number[]): void => { + ensureImmersionTrackerStarted(); + appState.immersionTracker?.recordCardsMined(count, noteIds); + }, + /** Marks the active video watched, then forces the AniList post-watch update. */ + markActiveVideoWatched: async (): Promise => { + ensureImmersionTrackerStarted(); + const marked = (await appState.immersionTracker?.markActiveVideoWatched()) ?? false; + if (marked) { + try { + await deps.runAnilistPostWatchUpdate(); + } catch (error) { + logger.warn('Failed to run AniList post-watch update after manual watched mark:', error); + } + } + return marked; + }, + }; +} diff --git a/src/main/assemblies/tray.test.ts b/src/main/assemblies/tray.test.ts new file mode 100644 index 00000000..ba7988b9 --- /dev/null +++ b/src/main/assemblies/tray.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Menu, NativeImage, Tray } from 'electron'; +import { createTrayAssembly, type TrayAssemblyDeps, type TrayMenuActions } from './tray'; + +const MENU_ACTIONS = [ + 'openSessionHelpModal', + 'openChangelogModal', + 'openTexthookerInBrowser', + 'showTexthookerPage', + 'showFirstRunSetup', + 'openFirstRunSetupWindow', + 'getDictionaryBackend', + 'openHachidoriSettings', + 'openYomitanSettings', + 'openConfigSettingsWindow', + 'openSyncUiWindow', + 'openYoutubeBrowserWindow', + 'exportLogs', + 'openJellyfinSetupWindow', + 'isJellyfinConfigured', + 'isJellyfinDiscoveryActive', + 'toggleJellyfinDiscovery', + 'openAnilistSetupWindow', + 'checkForUpdates', + 'quitApp', +] satisfies Array; + +function menuActions(makeAction: (name: string) => () => unknown): TrayMenuActions { + return Object.fromEntries( + MENU_ACTIONS.map((name) => [name, makeAction(name)]), + ) as unknown as TrayMenuActions; +} + +// Every callback throws, so a test fails if the assembly calls a dep it should only call later. +function makeDeps(overrides: Partial = {}): TrayAssemblyDeps { + const lazy = (name: string) => (): never => { + throw new Error(`${name} called unexpectedly`); + }; + return { + appState: { overlayRuntimeInitialized: false }, + logger: { warn: lazy('warn') }, + iconRoots: { resourcesPath: '/missing', appPath: '/missing', mainDirname: '/missing' }, + electron: { + createTray: lazy('createTray'), + createImageFromPath: lazy('createImageFromPath'), + createEmptyImage: lazy('createEmptyImage'), + buildMenuFromTemplate: lazy('buildMenuFromTemplate'), + }, + menu: menuActions(lazy), + initializeOverlayRuntime: lazy('initializeOverlayRuntime'), + setVisibleOverlayVisible: lazy('setVisibleOverlayVisible'), + ...overrides, + }; +} + +// main.ts builds the assembly before most siblings exist, so construction must stay lazy. +test('createTrayAssembly does not call its deps while constructing', () => { + const tray = createTrayAssembly(makeDeps()); + assert.equal(tray.hasTray(), false); +}); + +test('refreshMenu rebuilds the menu only once the tray exists', () => { + let menusBuilt = 0; + let menusSet = 0; + const fakeTray = { + setToolTip: () => {}, + setContextMenu: () => { + menusSet += 1; + }, + on: () => {}, + } as unknown as Tray; + const tray = createTrayAssembly( + makeDeps({ + logger: { warn: () => {} }, + electron: { + createTray: () => fakeTray, + createImageFromPath: () => ({ isEmpty: () => true }) as NativeImage, + createEmptyImage: () => ({ isEmpty: () => true }) as NativeImage, + buildMenuFromTemplate: () => { + menusBuilt += 1; + return {} as Menu; + }, + }, + menu: menuActions(() => () => false), + }), + ); + + tray.refreshMenu(); + assert.equal(menusBuilt, 0); + + tray.ensureTray(); + assert.equal(tray.hasTray(), true); + const menusAfterCreate = menusSet; + tray.refreshMenu(); + assert.equal(menusSet, menusAfterCreate + 1); + assert.equal(menusBuilt, menusSet); +}); diff --git a/src/main/assemblies/tray.ts b/src/main/assemblies/tray.ts new file mode 100644 index 00000000..c66ee8ce --- /dev/null +++ b/src/main/assemblies/tray.ts @@ -0,0 +1,107 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import type { Menu, MenuItemConstructorOptions, NativeImage, Tray } from 'electron'; +import type { Logger } from '../../logger'; +import { + buildTrayMenuTemplateRuntime, + resolveTrayIconPathRuntime, +} from '../runtime/domains/overlay'; +import type { createBuildTrayMenuTemplateHandler } from '../runtime/tray-main-actions'; +import { createTrayRuntimeHandlers } from '../runtime/tray-runtime-handlers'; +import type { AppState } from '../state'; + +type TrayMenuTemplateDeps = Parameters< + typeof createBuildTrayMenuTemplateHandler +>[0]; + +/** The tray menu's entries; each one calls into the domain that owns it. */ +export type TrayMenuActions = Omit< + TrayMenuTemplateDeps, + | 'buildTrayMenuTemplateRuntime' + | 'platform' + | 'initializeOverlayRuntime' + | 'isOverlayRuntimeInitialized' + | 'showWindowsMpvLauncherSetup' +>; + +export interface TrayAssemblyDeps { + appState: Pick; + logger: Pick; + /** Where the packaged and dev builds keep the tray icon. */ + iconRoots: { resourcesPath: string; appPath: string; mainDirname: string }; + /** Electron tray primitives, injected so the assembly loads under `bun test`. */ + electron: { + createTray: (icon: NativeImage) => Tray; + createImageFromPath: (iconPath: string) => NativeImage; + createEmptyImage: () => NativeImage; + buildMenuFromTemplate: (template: MenuItemConstructorOptions[]) => Menu; + }; + menu: TrayMenuActions; + initializeOverlayRuntime: () => void; + setVisibleOverlayVisible: (visible: boolean) => void; +} + +export type TrayAssembly = ReturnType; + +/** + * Owns the system tray icon and its menu. The tray exists in background mode and for managed + * playback; clicking it shows the overlay. + */ +export function createTrayAssembly(deps: TrayAssemblyDeps) { + const { appState } = deps; + let tray: Tray | null = null; + + const { ensureTray, destroyTray } = createTrayRuntimeHandlers({ + resolveTrayIconPathDeps: { + resolveTrayIconPathRuntime, + platform: process.platform, + resourcesPath: deps.iconRoots.resourcesPath, + appPath: deps.iconRoots.appPath, + dirname: deps.iconRoots.mainDirname, + joinPath: (...parts) => path.join(...parts), + fileExists: (candidate) => fs.existsSync(candidate), + }, + buildTrayMenuTemplateDeps: { + ...deps.menu, + buildTrayMenuTemplateRuntime, + platform: process.platform, + initializeOverlayRuntime: () => deps.initializeOverlayRuntime(), + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + showWindowsMpvLauncherSetup: () => process.platform === 'win32', + }, + ensureTrayDeps: { + getTray: () => tray, + setTray: (nextTray) => { + tray = nextTray as Tray | null; + }, + createImageFromPath: (iconPath) => deps.electron.createImageFromPath(iconPath), + createEmptyImage: () => deps.electron.createEmptyImage(), + createTray: (icon) => deps.electron.createTray(icon as NativeImage), + trayTooltip: 'SubMiner', + platform: process.platform, + logWarn: (message) => deps.logger.warn(message), + initializeOverlayRuntime: () => deps.initializeOverlayRuntime(), + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + setVisibleOverlayVisible: (visible) => deps.setVisibleOverlayVisible(visible), + }, + destroyTrayDeps: { + getTray: () => tray, + setTray: (nextTray) => { + tray = nextTray as Tray | null; + }, + }, + buildMenuFromTemplate: (template) => deps.electron.buildMenuFromTemplate(template), + }); + + return { + ensureTray, + destroyTray, + hasTray: (): boolean => Boolean(tray), + /** Rebuilds the menu so state-dependent entries (setup, Jellyfin) stay current. */ + refreshMenu: (): void => { + if (tray) { + ensureTray(); + } + }, + }; +} diff --git a/src/main/assemblies/yomitan.test.ts b/src/main/assemblies/yomitan.test.ts new file mode 100644 index 00000000..32567a3c --- /dev/null +++ b/src/main/assemblies/yomitan.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'; +import { DEFAULT_CONFIG } from '../../config/definitions'; +import type { ResolvedConfig } from '../../types'; +import type { DictionaryBackend } from '../../types/config'; +import { createYomitanAssembly, type YomitanAssemblyDeps } from './yomitan'; + +type InvokeHandler = (event: IpcMainInvokeEvent, value: unknown) => Promise; + +function configWithExternalProfile(externalProfilePath: string): ResolvedConfig { + return { + ...DEFAULT_CONFIG, + yomitan: { ...DEFAULT_CONFIG.yomitan, externalProfilePath }, + }; +} + +// Every callback except getConfig throws, so a test fails if the assembly calls a dep it +// should only call later. getConfig is read once during construction for the profile policy. +function makeDeps( + overrides: Partial & { backend?: DictionaryBackend } = {}, +): YomitanAssemblyDeps { + const lazy = (name: string) => (): never => { + throw new Error(`${name} called unexpectedly`); + }; + const { backend = 'yomitan', ...rest } = overrides; + return { + appState: { + yomitanExt: null, + yomitanSession: null, + yomitanParserWindow: null, + yomitanParserReadyPromise: null, + yomitanParserInitPromise: null, + yomitanSettingsWindow: null, + }, + getConfig: () => DEFAULT_CONFIG, + logger: { info: lazy('info'), warn: lazy('warn'), error: lazy('error'), debug: lazy('debug') }, + userDataPath: '/tmp/subminer-yomitan-assembly-test', + dictionaryBackend: backend, + hachidori: { ensureLoaded: lazy('hachidori.ensureLoaded'), getSession: lazy('getSession') }, + getOverlayWindows: lazy('getOverlayWindows'), + getOverlayNotifications: lazy('getOverlayNotifications'), + ipcMain: { handle: lazy('ipcMain.handle') }, + openExternal: lazy('openExternal'), + ...rest, + }; +} + +test('createYomitanAssembly only reads config while constructing', () => { + const yomitan = createYomitanAssembly(makeDeps()); + assert.equal(typeof yomitan.ensureExtensionLoaded, 'function'); + assert.equal(yomitan.profilePolicy.isExternalReadOnlyMode(), false); +}); + +test('an external Yomitan profile is read-only for the Yomitan backend only', () => { + const getConfig = () => configWithExternalProfile('/profiles/main'); + assert.equal( + createYomitanAssembly(makeDeps({ getConfig })).profilePolicy.isExternalReadOnlyMode(), + true, + ); + assert.equal( + createYomitanAssembly( + makeDeps({ getConfig, backend: 'hachidori' }), + ).profilePolicy.isExternalReadOnlyMode(), + false, + ); +}); + +test('Yomitan settings refuse to open in read-only external-profile mode', () => { + const notifications: string[] = []; + const yomitan = createYomitanAssembly( + makeDeps({ + getConfig: () => configWithExternalProfile('/profiles/main'), + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + getOverlayNotifications: () => ({ + showConfiguredStatusNotification: (message: string) => { + notifications.push(message); + }, + }), + }), + ); + + assert.equal(yomitan.openDictionarySettings(), false); + assert.deepEqual(notifications, [ + 'Yomitan settings unavailable while using read-only external-profile mode.', + ]); +}); + +test('dictionary links open only from the main frame of an overlay window', async () => { + const overlaySender = { mainFrame: 'overlay-main-frame' }; + const overlayWindow = { webContents: overlaySender } as unknown as BrowserWindow; + let handler: InvokeHandler | null = null; + const opened: string[] = []; + const yomitan = createYomitanAssembly( + makeDeps({ + backend: 'hachidori', + getOverlayWindows: () => [overlayWindow], + ipcMain: { + handle: (_channel: string, listener: InvokeHandler) => { + handler = listener; + }, + } as YomitanAssemblyDeps['ipcMain'], + openExternal: async (url) => { + opened.push(url); + }, + }), + ); + yomitan.registerDictionaryExternalLinkHandler(); + assert.ok(handler); + const invoke = handler as InvokeHandler; + const event = (sender: unknown, senderFrame: unknown) => + ({ sender, senderFrame }) as unknown as IpcMainInvokeEvent; + + await invoke(event(overlaySender, 'overlay-main-frame'), 'https://example.com/entry'); + await assert.rejects(invoke(event(overlaySender, 'child-frame'), 'https://example.com/x')); + await assert.rejects( + invoke(event({ mainFrame: 'other' }, 'other'), 'https://example.com/x'), + /only available from the active overlay/, + ); + assert.deepEqual(opened, ['https://example.com/entry']); +}); diff --git a/src/main/assemblies/yomitan.ts b/src/main/assemblies/yomitan.ts new file mode 100644 index 00000000..156608e5 --- /dev/null +++ b/src/main/assemblies/yomitan.ts @@ -0,0 +1,335 @@ +import type { BrowserWindow, Extension, IpcMain, Session } from 'electron'; +import { + clearYomitanParserCachesForWindow, + destroyYomitanSettingsWindow, + getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore, + loadYomitanExtension as loadYomitanExtensionCore, + openYomitanSettingsWindow, +} from '../../core/services'; +import { generateSentenceFurigana } from '../../core/services/tokenizer/sentence-furigana'; +import type { Logger } from '../../logger'; +import { + DICTIONARY_EXTERNAL_LINK_CHANNEL, + parseDictionaryExternalUrl, +} from '../../shared/dictionary-external-link'; +import type { ResolvedConfig } from '../../types'; +import type { DictionaryBackend } from '../../types/config'; +import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime'; +import { createYomitanAnkiServerSyncRuntime } from '../runtime/yomitan-anki-server-sync'; +import { reloadOverlayWindowsForYomitanContentScripts } from '../runtime/yomitan-extension-overlay-reload'; +import { createYomitanExtensionRuntime } from '../runtime/yomitan-extension-runtime'; +import { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy'; +import { createYomitanSettingsRuntime } from '../runtime/yomitan-settings-runtime'; +import type { AppState } from '../state'; + +/** The bundled Hachidori extension. Injected because it reaches Electron's session API. */ +export interface HachidoriExtensionLike { + ensureLoaded: () => Promise; + getSession: () => Session; +} + +/** + * What the dictionary-extension domain needs from the rest of the app. Siblings built after + * this assembly in `main.ts` are reached through callbacks. + */ +export interface YomitanAssemblyDeps { + appState: Pick< + AppState, + | 'yomitanExt' + | 'yomitanSession' + | 'yomitanParserWindow' + | 'yomitanParserReadyPromise' + | 'yomitanParserInitPromise' + | 'yomitanSettingsWindow' + >; + getConfig: () => ResolvedConfig; + logger: Pick; + userDataPath: string; + /** Fixed for the process lifetime; backend changes apply on restart. */ + dictionaryBackend: DictionaryBackend; + hachidori: HachidoriExtensionLike; + getOverlayWindows: () => BrowserWindow[]; + getOverlayNotifications: () => Pick< + ReturnType, + 'showConfiguredStatusNotification' + >; + ipcMain: Pick; + openExternal: (url: string) => Promise; +} + +export type YomitanAssembly = ReturnType; + +/** + * Owns the Yomitan/Hachidori extension: loading, the parser runtime deps every tokenizer + * caller shares, the external-profile policy, and the dictionary settings windows. The + * inactive backend's settings window stays openable so users can manage both. + */ +export function createYomitanAssembly(deps: YomitanAssemblyDeps) { + const { appState, logger, dictionaryBackend, hachidori } = deps; + let yomitanLoadInFlight: Promise | null = null; + let hachidoriSettingsWindow: BrowserWindow | null = null; + let inactiveYomitanExtension: Extension | null = null; + let inactiveYomitanSettingsWindow: BrowserWindow | null = null; + let inactiveYomitanLoad: Promise | null = null; + + const profilePolicy = createYomitanProfilePolicy({ + externalProfilePath: + dictionaryBackend === 'yomitan' ? deps.getConfig().yomitan.externalProfilePath : '', + logInfo: (message) => logger.info(message), + }); + + function getParserRuntimeDeps() { + 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; + }, + }; + } + + const extensionRuntime = createYomitanExtensionRuntime({ + loadYomitanExtensionCore: async (loadDeps) => { + if (dictionaryBackend === 'yomitan') return loadYomitanExtensionCore(loadDeps); + try { + const extension = await hachidori.ensureLoaded(); + loadDeps.setYomitanExtension(extension); + loadDeps.setYomitanSession(hachidori.getSession()); + return extension; + } catch (error) { + logger.error('Failed to load Hachidori extension:', error); + loadDeps.setYomitanExtension(null); + loadDeps.setYomitanSession(null); + return null; + } + }, + userDataPath: deps.userDataPath, + externalProfilePath: profilePolicy.externalProfilePath, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (window) => { + appState.yomitanParserWindow = window as BrowserWindow | null; + }, + setYomitanParserReadyPromise: (promise) => { + appState.yomitanParserReadyPromise = promise; + }, + setYomitanParserInitPromise: (promise) => { + appState.yomitanParserInitPromise = promise; + }, + setYomitanExtension: (extension) => { + appState.yomitanExt = extension; + }, + setYomitanSession: (nextSession) => { + appState.yomitanSession = nextSession; + }, + getYomitanExtension: () => appState.yomitanExt, + getLoadInFlight: () => yomitanLoadInFlight, + setLoadInFlight: (promise) => { + yomitanLoadInFlight = promise; + }, + onYomitanExtensionLoaded: () => { + const reloaded = reloadOverlayWindowsForYomitanContentScripts( + deps.getOverlayWindows(), + (message, error) => logger.warn(message, error), + ); + if (reloaded > 0) { + logger.debug(`Reloaded ${reloaded} overlay window(s) after Yomitan extension load.`); + } + }, + }); + + const { syncYomitanDefaultProfileAnkiServer } = createYomitanAnkiServerSyncRuntime({ + isExternalReadOnlyMode: () => profilePolicy.isExternalReadOnlyMode(), + getResolvedConfig: () => deps.getConfig(), + getYomitanParserRuntimeDeps: () => getParserRuntimeDeps(), + logError: (message, ...args) => { + logger.error(message, ...args); + }, + logInfo: (message, ...args) => { + logger.info(message, ...args); + }, + }); + + async function loadExtension(): Promise { + const extension = await extensionRuntime.loadYomitanExtension(); + if (extension && !profilePolicy.isExternalReadOnlyMode()) { + await syncYomitanDefaultProfileAnkiServer(); + } + return extension; + } + + async function ensureExtensionLoaded(): Promise { + const extension = await extensionRuntime.ensureYomitanExtensionLoaded(); + if (extension && !profilePolicy.isExternalReadOnlyMode()) { + await syncYomitanDefaultProfileAnkiServer(); + } + return extension; + } + + const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({ + ensureYomitanExtensionLoaded: () => ensureExtensionLoaded(), + getYomitanExtension: () => appState.yomitanExt, + getYomitanExtensionLoadInFlight: () => yomitanLoadInFlight, + 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; + }, + logWarn: (message) => logger.warn(message), + logError: (message, error) => logger.error(message, error), + }); + + function openYomitanSettings(): boolean { + if (dictionaryBackend === 'hachidori') { + if (deps.getConfig().yomitan.externalProfilePath.trim()) { + logger.warn('Yomitan settings unavailable while using read-only external-profile mode.'); + return false; + } + inactiveYomitanLoad ??= inactiveYomitanExtension + ? Promise.resolve(inactiveYomitanExtension) + : loadYomitanExtensionCore({ + userDataPath: deps.userDataPath, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + setYomitanParserReadyPromise: () => {}, + setYomitanParserInitPromise: () => {}, + setYomitanExtension: (extension) => { + inactiveYomitanExtension = extension; + }, + setYomitanSession: () => {}, + }).finally(() => { + inactiveYomitanLoad = null; + }); + void ( + inactiveYomitanExtension ? Promise.resolve(inactiveYomitanExtension) : inactiveYomitanLoad + ) + .then((extension) => + openYomitanSettingsWindow({ + yomitanExt: extension, + getExistingWindow: () => inactiveYomitanSettingsWindow, + setWindow: (window) => { + inactiveYomitanSettingsWindow = window; + }, + }), + ) + .catch((error: unknown) => logger.error('Failed to open Yomitan settings', error)); + return true; + } + if (profilePolicy.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.', + ); + deps + .getOverlayNotifications() + .showConfiguredStatusNotification(message, { variant: 'warning' }); + return false; + } + openYomitanSettingsHandler(); + return true; + } + + function openHachidoriSettings(): void { + void (dictionaryBackend === 'hachidori' ? ensureExtensionLoaded() : hachidori.ensureLoaded()) + .then((extension) => + openYomitanSettingsWindow({ + backend: 'hachidori', + yomitanExt: extension, + yomitanSession: hachidori.getSession(), + getExistingWindow: () => hachidoriSettingsWindow, + setWindow: (window) => { + hachidoriSettingsWindow = window; + }, + onWindowClosed: () => { + if (dictionaryBackend === 'hachidori' && appState.yomitanParserWindow) { + clearYomitanParserCachesForWindow(appState.yomitanParserWindow); + } + }, + }), + ) + .catch((error: unknown) => logger.error('Failed to open Hachidori settings', error)); + } + + function openDictionarySettings(): boolean { + if (dictionaryBackend === 'hachidori') { + openHachidoriSettings(); + return true; + } + return openYomitanSettings(); + } + + async function getCurrentAnkiDeckName(): Promise { + await extensionRuntime.ensureYomitanExtensionLoaded(); + return getYomitanCurrentAnkiDeckNameCore(getParserRuntimeDeps(), { + error: (message, ...args) => { + logger.error(message, ...args); + }, + info: (message, ...args) => { + logger.info(message, ...args); + }, + }); + } + + return { + profilePolicy, + getParserRuntimeDeps, + loadExtension, + ensureExtensionLoaded, + syncYomitanDefaultProfileAnkiServer, + getCurrentAnkiDeckName, + openYomitanSettings, + openHachidoriSettings, + openDictionarySettings, + generateSentenceFurigana: (text: string, highlightedText?: string): Promise => + generateSentenceFurigana(text, highlightedText, getParserRuntimeDeps(), logger), + /** The session overlay windows load the active backend's content scripts from. */ + getOverlaySession: (): Session | null => + dictionaryBackend === 'hachidori' ? hachidori.getSession() : appState.yomitanSession, + /** Dictionary settings windows the overlay must not cover. */ + getSettingsWindows: (): Array => [ + appState.yomitanSettingsWindow, + hachidoriSettingsWindow, + inactiveYomitanSettingsWindow, + ], + destroySettingsWindows: (): void => { + destroyYomitanSettingsWindow(hachidoriSettingsWindow); + destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow); + destroyYomitanSettingsWindow(appState.yomitanSettingsWindow); + appState.yomitanSettingsWindow = null; + }, + /** Hachidori dictionary entries link out; only the active overlay's main frame may ask. */ + registerDictionaryExternalLinkHandler: (): void => { + deps.ipcMain.handle(DICTIONARY_EXTERNAL_LINK_CHANNEL, async (event, value: unknown) => { + if ( + dictionaryBackend !== 'hachidori' || + event.senderFrame !== event.sender.mainFrame || + !deps.getOverlayWindows().some((window) => window.webContents === event.sender) + ) { + throw new Error('Dictionary links are only available from the active overlay'); + } + await deps.openExternal(parseDictionaryExternalUrl(value)); + }); + }, + }; +} diff --git a/src/main/boot/services.test.ts b/src/main/boot/services.test.ts index 0525b515..6186f0bf 100644 --- a/src/main/boot/services.test.ts +++ b/src/main/boot/services.test.ts @@ -36,7 +36,6 @@ function baseParams() { createAnilistUpdateQueue: (targetPath: string) => ({ targetPath }), createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => ({ payloadMode }), createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }), - createMainRuntimeRegistry: () => ({}), createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }), createOverlayModalInputState: () => ({ getModalInputExclusive: () => false, diff --git a/src/main/boot/services.ts b/src/main/boot/services.ts index ef6014ab..fa371bd8 100644 --- a/src/main/boot/services.ts +++ b/src/main/boot/services.ts @@ -21,7 +21,6 @@ export interface MainBootServicesParams< TAnilistUpdateQueue, TSubtitleWebSocket, TLogger, - TRuntimeRegistry, TOverlayManager, TOverlayModalInputState, TOverlayContentMeasurementStore, @@ -71,7 +70,6 @@ export interface MainBootServicesParams< info: (message: string) => void; error: (message: string, details?: unknown) => void; }; - createMainRuntimeRegistry: () => TRuntimeRegistry; createOverlayManager: () => TOverlayManager; createOverlayModalInputState: (params: { getModalWindow: () => BrowserWindow | null; @@ -99,7 +97,6 @@ export interface MainBootServicesResult< TAnilistUpdateQueue, TSubtitleWebSocket, TLogger, - TRuntimeRegistry, TOverlayManager, TOverlayModalInputState, TOverlayContentMeasurementStore, @@ -118,7 +115,6 @@ export interface MainBootServicesResult< subtitleWsService: TSubtitleWebSocket; annotationSubtitleWsService: TSubtitleWebSocket; logger: TLogger; - runtimeRegistry: TRuntimeRegistry; overlayManager: TOverlayManager; overlayModalInputState: TOverlayModalInputState; overlayContentMeasurementStore: TOverlayContentMeasurementStore; @@ -134,7 +130,6 @@ export function createMainBootServices< TAnilistUpdateQueue, TSubtitleWebSocket, TLogger, - TRuntimeRegistry, TOverlayManager extends { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null; @@ -152,7 +147,6 @@ export function createMainBootServices< TAnilistUpdateQueue, TSubtitleWebSocket, TLogger, - TRuntimeRegistry, TOverlayManager, TOverlayModalInputState, TOverlayContentMeasurementStore, @@ -167,7 +161,6 @@ export function createMainBootServices< TAnilistUpdateQueue, TSubtitleWebSocket, TLogger, - TRuntimeRegistry, TOverlayManager, TOverlayModalInputState, TOverlayContentMeasurementStore, @@ -211,7 +204,6 @@ export function createMainBootServices< const subtitleWsService = params.createSubtitleWebSocket('plain'); const annotationSubtitleWsService = params.createSubtitleWebSocket('annotated'); const logger = params.createLogger('main'); - const runtimeRegistry = params.createMainRuntimeRegistry(); const overlayManager = params.createOverlayManager(); const overlayModalInputState = params.createOverlayModalInputState({ getModalWindow: () => overlayManager.getModalWindow(), @@ -292,7 +284,6 @@ export function createMainBootServices< subtitleWsService, annotationSubtitleWsService, logger, - runtimeRegistry, overlayManager, overlayModalInputState, overlayContentMeasurementStore, diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 70f914c1..3e02780d 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -15,20 +15,6 @@ function readSource(relPath: string): string { return fs.readFileSync(path.join(process.cwd(), relPath), 'utf8'); } -test('manual watched session action starts immersion tracker before marking watched', () => { - const source = readMainSource(); - const actionBlock = source.match( - /markActiveVideoWatched:\s*async\s*\(\)\s*=>\s*\{(?[\s\S]*?)\}\s*,/, - )?.groups?.body; - - assert.ok(actionBlock); - assert.match(actionBlock, /ensureImmersionTrackerStarted\(\);/); - assert.ok( - actionBlock.indexOf('ensureImmersionTrackerStarted();') < - actionBlock.indexOf('markActiveVideoWatched()'), - ); -}); - test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => { const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts'); const actionBlock = source.match( @@ -71,10 +57,6 @@ test('startup autoplay release is tied to visible overlay measurement readiness' const gateBlock = source.match( /const autoplayReadyGate = createAutoplayReadyGate\(\{(?[\s\S]*?)\n\}\);/, )?.groups?.body; - const measurementBlock = source.match( - /reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?[\s\S]*?)\n \},/, - )?.groups?.body; - assert.ok(gateBlock); assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/); // Untokenized signals are filtered by the gate's isTokenizationReady dep, not @@ -88,71 +70,6 @@ test('startup autoplay release is tied to visible overlay measurement readiness' assert.doesNotMatch(signalTargetReadyBlock, /isTokenizationWarmupReady\(\)/); assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/); assert.match(gateBlock, /getLatestVisibleMeasurement:/); - - assert.ok(measurementBlock); - assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/); - assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/); -}); - -test('visible overlay first measurement flushes autoplay before scheduling the first-paint refresh', () => { - const source = readMainSource(); - const measurementBlock = source.match( - /reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?[\s\S]*?)\n \},/, - )?.groups?.body; - - assert.ok(measurementBlock); - assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/); - assert.match(measurementBlock, /scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint\(\)/); - assert.ok( - measurementBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();') < - measurementBlock.indexOf('scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();'), - ); -}); - -test('accepted visible overlay measurement immediately refreshes pointer interaction', () => { - const source = readMainSource(); - const measurementBlock = source.match( - /reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?[\s\S]*?)\n \},/, - )?.groups?.body; - - assert.ok(measurementBlock); - assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/); - assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/); - assert.match(measurementBlock, /tickWindowsOverlayPointerInteractionNow\(\)/); - assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/); - assert.ok( - measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') < - measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'), - ); - assert.ok( - measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') < - measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();'), - ); - assert.ok( - measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();') < - measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'), - ); -}); - -test('subtitle sidebar open state is restored for replacement visible overlay windows', () => { - const source = readMainSource(); - const openedBlock = source.match( - /onOverlayModalOpened:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?[\s\S]*?)\n \},/, - )?.groups?.body; - const closedBlock = source.match( - /onOverlayModalClosed:\s*\(modal,\s*senderWindow(?:,\s*instanceId)?\)\s*=>\s*\{(?[\s\S]*?)\n \},/, - )?.groups?.body; - const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?[^\n,]+)/)?.groups - ?.body; - - assert.ok(openedBlock); - assert.ok(closedBlock); - assert.ok(depsBlock); - assert.match(openedBlock, /if \(modal === 'subtitle-sidebar'/); - assert.match(openedBlock, /subtitleSidebarRequestedOpen = true;/); - assert.match(closedBlock, /if \(modal === 'subtitle-sidebar'/); - assert.match(closedBlock, /subtitleSidebarRequestedOpen = false;/); - assert.match(depsBlock, /subtitleSidebarRequestedOpen/); }); test('stats server Yomitan note creation honors configured Anki server override policy', () => { @@ -229,19 +146,3 @@ test('Linux visible overlay bounds refresh restores X11 shape after applying mpv afterBoundsBlock.indexOf('ensureOverlayWindowLevel(mainWindow);'), ); }); - -test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active-source parsing', () => { - const source = readMainSource(); - const snapshotBlock = source.match( - /getSubtitleSidebarSnapshot:\s*async\s*\(\)\s*=>\s*\{(?[\s\S]*?const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler)/, - )?.groups?.body; - - assert.ok(snapshotBlock); - assert.match(snapshotBlock, /shouldUseCachedYoutubeParsedCues\(/); - assert.match(snapshotBlock, /cachedMediaPath:\s*appState\.activeParsedSubtitleMediaPath/); - assert.match(snapshotBlock, /cachedCueCount:\s*appState\.activeParsedSubtitleCues\.length/); - assert.ok( - snapshotBlock.indexOf('shouldUseCachedYoutubeParsedCues(') < - snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'), - ); -}); diff --git a/src/main/runtime/aniskip-runtime.ts b/src/main/runtime/aniskip-runtime.ts index edfca633..743f409a 100644 --- a/src/main/runtime/aniskip-runtime.ts +++ b/src/main/runtime/aniskip-runtime.ts @@ -28,7 +28,7 @@ export interface AniSkipRuntimeDeps { logDebug: (message: string) => void; } -interface AniSkipIntroWindow { +export interface AniSkipIntroWindow { start: number; end: number; malId: number | null; diff --git a/src/main/runtime/registry.ts b/src/main/runtime/registry.ts deleted file mode 100644 index 461d5959..00000000 --- a/src/main/runtime/registry.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as domains from './domains'; - -export type MainRuntimeRegistry = typeof domains; - -export function createMainRuntimeRegistry(): MainRuntimeRegistry { - return domains; -}