diff --git a/docs-site/architecture.md b/docs-site/architecture.md index c2d3ecdf..76c5e649 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 (YouTube, Jellyfin, AniList, settings, sync, dictionaries, overlay modals) + assemblies/ # per-domain assemblies main.ts builds once (overlay, playback, 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 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. +`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. 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 eaf96a56..04f44458 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -21,11 +21,14 @@ Read when: you need to find the owner module for a behavior or test surface `src/config/resolve/anki-connect/` Config hot-reload and the settings window: `src/main/assemblies/settings.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-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` 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` - 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/` diff --git a/docs/workflow/testing.md b/docs/workflow/testing.md index a00e4842..c14257cd 100644 --- a/docs/workflow/testing.md +++ b/docs/workflow/testing.md @@ -47,3 +47,7 @@ good test looks like here. - `src/main/main-wiring.test.ts` and `src/renderer/renderer-init-order.test.ts` still regex `main.ts` / `renderer.ts` to guard startup ordering. Replace each test with a behavioral one as the logic moves out of `main.ts` closures. +- `src/main/runtime/overlay-geometry-runtime.ts` and + `src/main/runtime/visible-overlay-interaction-runtime.ts` value-import Electron, so they + cannot load under `bun test` and stay outside `src/main/assemblies/overlay.ts`. Inject + `screen`/`dialog` to make them testable and movable. diff --git a/src/main.ts b/src/main.ts index 44610e0f..bbac7821 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,7 +37,6 @@ import { createHachidoriExtensionRuntime, getHachidoriSession, } from './core/services/hachidori-extension'; -import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parser-runtime'; import { DICTIONARY_EXTERNAL_LINK_CHANNEL, parseDictionaryExternalUrl, @@ -46,21 +45,9 @@ import { readMpvInputBindings } from './main/runtime/mpv-input-bindings'; import { createAniSkipRuntime } from './main/runtime/aniskip-runtime'; import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata'; import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js'; -import { observeYomitanDictionaryCount } from './main/runtime/yomitan-dictionary-integrity'; import { startAppControlServer } from './main/runtime/app-control-server'; import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup'; -import { - markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState, - shouldAutoLoadSecondarySubTrackForJellyfinPlayback, -} from './main/runtime/jellyfin-remote-playback'; import { getAppControlSocketPath } from './shared/app-control'; -import { - type CancelLinuxMpvFullscreenOverlayRefreshBurst, - clearLinuxMpvFullscreenOverlayRefreshTimeouts, - updateLinuxMpvFullscreenOverlayRefreshBurst, -} from './main/runtime/linux-mpv-fullscreen-overlay-refresh'; -import { createLinuxOverlayModeRuntime } from './main/runtime/linux-overlay-mode-runtime'; -import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive'; 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'; @@ -87,19 +74,14 @@ protocol.registerSchemesAsPrivileged([ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { MecabTokenizer } from './mecab-tokenizer'; import type { JimakuApiResponse, KikuFieldGroupingChoice, - MpvSubtitleRenderMetrics, ResolvedConfig, SessionActionDispatchRequest, SecondarySubMode, - SubtitleData, - SubtitleCue, SubtitleMiningContext, SubtitlePosition, - SubtitleSidebarPlaybackState, WindowGeometry, } from './types'; import { OPEN_ANKI_CARD_ACTION_ID } from './types'; @@ -121,7 +103,6 @@ import { resolveDefaultLogFilePath, } from './shared/log-files'; import { createFatalErrorReporter } from './main/fatal-error'; -import { ensureWindowsOverlayTransparency } from './window-trackers/windows-helper'; import { commandNeedsOverlayStartupPrereqs, commandNeedsOverlayRuntime, @@ -149,7 +130,6 @@ import { shouldShowTexthookerTrayEntry } from './main/runtime/tray-main-actions' import { createGetDefaultSocketPathHandler } from './main/runtime/domains/jellyfin'; import { createTrayRuntimeHandlers, - createOverlayVisibilityRuntime, createGetRuntimeOptionsStateHandler, createGetFieldGroupingResolverHandler, createSetFieldGroupingResolverHandler, @@ -173,11 +153,7 @@ import { createMpvOsdRuntimeHandlers, createCycleSecondarySubModeRuntimeHandler, } from './main/runtime/domains/mpv'; -import { buildSubtitleTrackDiagnostics } from './main/runtime/mpv-track-diagnostics'; -import { - resolveCanonicalPrimarySubtitle, - resolvePrimarySubtitle, -} from './main/runtime/primary-subtitle-text'; +import { resolvePrimarySubtitle } from './main/runtime/primary-subtitle-text'; import { createMarkLastCardAsAudioCardHandler, createMineSentenceCardHandler, @@ -196,17 +172,12 @@ import { resolveKeybindings, showDesktopNotification, } from './core/utils'; -import { - ensureDefaultConfigBootstrap, - getDefaultConfigFilePaths, - resolveDefaultMpvInstallPaths, -} from './shared/setup-state'; +import { ensureDefaultConfigBootstrap, getDefaultConfigFilePaths } from './shared/setup-state'; import { ImmersionTrackerService, MpvIpcClient, SubtitleWebSocket, Texthooker, - applyMpvSubtitleRenderMetricsPatch, broadcastRuntimeOptionsChangedRuntime, captureLiveSubtitleMiningContext, copyCurrentSubtitle as copyCurrentSubtitleCore, @@ -215,15 +186,12 @@ import { createOverlayContentMeasurementStore, createOverlayManager, createOverlayWindow as createOverlayWindowCore, - createTokenizerDepsRuntime, cycleSecondarySubMode as cycleSecondarySubModeCore, destroyYomitanSettingsWindow, - getYomitanDictionaryInfo, handleMineSentenceDigit as handleMineSentenceDigitCore, handleMultiCopyDigit as handleMultiCopyDigitCore, hasMpvWebsocketPlugin, initializeOverlayRuntime as initializeOverlayRuntimeCore, - isSubtitleAnnotationUpgrade, isOverlayWindowContentReady, loadSubtitlePosition as loadSubtitlePositionCore, loadYomitanExtension as loadYomitanExtensionCore, @@ -234,7 +202,6 @@ import { promoteSettingsWindowAboveOverlay, registerGlobalShortcuts as registerGlobalShortcutsCore, replayCurrentSubtitleRuntime, - resolveSanitizedSubtitleSeekCommand, toJellyfinOriginalStreamUrl, runStartupBootstrapRuntime, saveSubtitlePosition as saveSubtitlePositionCore, @@ -243,10 +210,8 @@ import { sendMpvCommandRuntime, setMpvSubVisibilityRuntime, setOverlayDebugVisualizationEnabledRuntime, - setVisibleOverlayVisible as setVisibleOverlayVisibleCore, shiftSubtitleRangeToVideoClock, showMpvOsdRuntime, - tokenizeSubtitle as tokenizeSubtitleCore, toSubtitleClock, triggerFieldGrouping as triggerFieldGroupingCore, updateLastCardFromClipboard as updateLastCardFromClipboardCore, @@ -258,12 +223,7 @@ import { toggleStatsOverlay as toggleStatsOverlayWindow, withStatsWindowLayerSuspendedForNativeDialog, } from './core/services/stats-window.js'; -import { - createFirstRunSetupService, - getFirstRunSetupCompletionMessage, - isStandaloneFirstRunSetupCommand, - shouldAutoOpenFirstRunSetup, -} from './main/runtime/first-run-setup-service'; +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'; @@ -272,44 +232,19 @@ import { createSettingsAssembly } from './main/assemblies/settings'; import { createSyncAssembly } from './main/assemblies/sync'; 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 { createPlaybackAssembly } from './main/assemblies/playback'; import { createAutoplayReadyGate, type AutoplayReadySignal, } from './main/runtime/autoplay-ready-gate'; -import { createAutoplaySubtitlePrimingRuntime } from './main/runtime/autoplay-subtitle-priming-runtime'; -import { createAutoplayTokenizationWarmRelease } from './main/runtime/autoplay-tokenization-warm-release'; import { isVisibleOverlayAutoplayTargetReady } from './main/runtime/visible-overlay-autoplay-readiness'; import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection'; -import { - buildFirstRunSetupHtml, - createMaybeFocusExistingFirstRunSetupWindowHandler, - createOpenFirstRunSetupWindowHandler, - parseFirstRunSetupSubmissionUrl, - type FirstRunSetupSubmission, -} from './main/runtime/first-run-setup-window'; -import { - detectInstalledFirstRunPlugin, - detectInstalledFirstRunPluginCandidates, - removeLegacyMpvPluginCandidates, -} from './main/runtime/first-run-setup-plugin'; import { createWindowsMpvPluginDetectionRuntime } from './main/runtime/windows-mpv-plugin-detection'; -import { - applyWindowsMpvShortcuts, - detectWindowsMpvShortcuts, - resolveWindowsMpvShortcutPaths, -} from './main/runtime/windows-mpv-shortcuts'; -import { - detectCommandLineLauncher, - installBun as installCommandLineBun, - installLauncher as installCommandLineLauncher, - refreshManagedCommandLineLauncher, -} from './main/runtime/command-line-launcher'; +import { refreshManagedCommandLineLauncher } from './main/runtime/command-line-launcher'; import { applyLoginShellPath } from './main/runtime/login-shell-path'; -import { - createWindowsMpvLaunchDeps, - getConfiguredWindowsMpvPathStatus, - launchWindowsMpv, -} from './main/runtime/windows-mpv-launch'; +import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; import { findMpvExecutablePath } from './main/runtime/mpv-process'; import { isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime, @@ -317,7 +252,6 @@ import { } from './main/runtime/jellyfin-tray-discovery'; import { shouldEnsureTrayOnStartupForInitialArgs, - shouldQuitOnMpvShutdownForTrayState, shouldQuitOnWindowAllClosedForTrayState, } from './main/runtime/startup-tray-policy'; import { createLogExportTrayRuntime } from './main/runtime/log-export-tray'; @@ -352,12 +286,10 @@ import { composeCliStartupHandlers, composeHeadlessStartupHandlers, composeIpcRuntimeHandlers, - composeMpvRuntimeHandlers, composeOverlayVisibilityRuntime, composeShortcutRuntimes, composeStartupLifecycleHandlers, } from './main/runtime/composers'; -import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers'; import { tryBeginVisibleOverlayNumericSelection } from './main/runtime/overlay-numeric-selection'; import { createStartupBootstrapRuntimeDeps } from './main/startup'; import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle'; @@ -408,8 +340,6 @@ import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser import { createSessionBindingsRuntime } from './main/runtime/session-bindings-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createMediaRuntimeService } from './main/media-runtime'; -import { createPrefetchAwareSubtitleProcessingController } from './main/runtime/subtitle-processing-runtime'; -import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime'; import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime'; import { getCharacterDictionaryManagerSnapshot, @@ -438,18 +368,8 @@ import { } from './main/runtime/linux-runtime-plugin-assets-cli-command'; import { ensureLinuxRuntimePluginAssets } from './main/runtime/linux-runtime-plugin-assets'; import { createUpdateServiceRuntime } from './main/runtime/update/update-service-runtime'; -import { - createRefreshSubtitlePrefetchFromActiveTrackHandler, - createResolveActiveSubtitleSidebarSourceHandler, -} from './main/runtime/subtitle-prefetch-runtime'; -import { createSecondarySubtitleTrackController } from './main/runtime/secondary-subtitle-track'; -import { createCreateFirstRunSetupWindowHandler } from './main/runtime/setup-window-factory'; import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order'; -import { - isSameYoutubeMediaPath, - isYoutubeMediaPath, - shouldUseCachedYoutubeParsedCues, -} from './main/runtime/youtube-playback'; +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'; @@ -462,16 +382,8 @@ import { } from './config'; import { resolveConfigDir } from './config/path-resolution'; import { parseSubtitleCues } from './core/services/subtitle-cue-parser'; -import { - createSubtitlePrefetchService, - type SubtitlePrefetchService, -} from './core/services/subtitle-prefetch'; -import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; -import { - createCachedInternalSubtitleTrackExtractor, - loadSubtitleSourceText, -} from './main/runtime/internal-subtitle-extraction'; -import { createRemoteMediaPathDetector } from './main/runtime/network-media-path'; +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') { @@ -495,7 +407,6 @@ const TRAY_TOOLTIP = 'SubMiner'; const DISCORD_PRESENCE_APP_ID = '1475264834730856619'; -let backgroundWarmupsStarted = false; let yomitanLoadInFlight: Promise | null = null; let notifyAnilistTokenStoreWarning: (message: string) => void = () => {}; @@ -1034,12 +945,6 @@ function getMpvPluginRuntimeConfig() { }; } -let firstRunSetupMessage: string | null = null; -const resolveWindowsMpvShortcutRuntimePaths = () => - resolveWindowsMpvShortcutPaths({ - appDataDir: app.getPath('appData'), - desktopDir: app.getPath('desktop'), - }); // Finder/Dock launches inherit launchd's minimal PATH; pick up the user's shell PATH so // launcher/Bun detection and spawned tools match what their terminal sees. const loginShellPathReady: Promise = @@ -1069,122 +974,29 @@ const createCommandLineLauncherRuntimeOptions = async () => { : undefined, }; }; -const firstRunSetupService = createFirstRunSetupService({ - getDictionaryBackend: () => activeDictionaryBackend, - getHachidoriHostStatus: async () => { - await ensureYomitanExtensionLoaded(); - return requestHachidoriSharing( - { type: 'hd_sharing_status' }, - getYomitanParserRuntimeDeps(), - logger, - ); - }, - platform: process.platform, +const firstRunSetup = createFirstRunSetupAssembly({ + appState, + configService, + logger, configDir: CONFIG_DIR, - getYomitanDictionaryCount: async () => { - await ensureYomitanExtensionLoaded(); - const dictionaries = await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), { - error: (message, ...args) => logger.error(message, ...args), - info: (message, ...args) => logger.info(message, ...args), - }); - const integrity = observeYomitanDictionaryCount(USER_DATA_PATH, dictionaries.length); - if (!integrity.safe) { - logger.error(`[dictionary:integrity] ${integrity.message}`); - } - return dictionaries.length; - }, - isExternalYomitanConfigured: () => - configService.getConfig().yomitan.externalProfilePath.trim().length > 0, - detectPluginInstalled: () => { - const candidates = detectInstalledFirstRunPluginCandidates({ - platform: process.platform, - homeDir: os.homedir(), - xdgConfigHome: process.env.XDG_CONFIG_HOME, - appDataDir: app.getPath('appData'), - mpvExecutablePath: configService.getConfig().mpv.executablePath, - }); - if (candidates.length > 0) { - return true; - } - const installPaths = resolveDefaultMpvInstallPaths( - process.platform, - os.homedir(), - process.env.XDG_CONFIG_HOME, - ); - return detectInstalledFirstRunPlugin(installPaths); - }, - detectLegacyMpvPluginCandidates: () => - detectInstalledFirstRunPluginCandidates({ - platform: process.platform, - homeDir: os.homedir(), - xdgConfigHome: process.env.XDG_CONFIG_HOME, - appDataDir: app.getPath('appData'), - mpvExecutablePath: configService.getConfig().mpv.executablePath, - }), - removeLegacyMpvPlugins: (candidates) => - removeLegacyMpvPluginCandidates({ - candidates, - trashItem: (candidatePath) => shell.trashItem(candidatePath), - }), - detectWindowsMpvShortcuts: () => { - if (process.platform !== 'win32') { - return { - startMenuInstalled: false, - desktopInstalled: false, - }; - } - return detectWindowsMpvShortcuts(resolveWindowsMpvShortcutRuntimePaths()); - }, - applyWindowsMpvShortcuts: async (preferences) => { - if (process.platform !== 'win32') { - return { - ok: true, - status: 'unknown' as const, - message: '', - }; - } - return applyWindowsMpvShortcuts({ - preferences, - paths: resolveWindowsMpvShortcutRuntimePaths(), - exePath: process.execPath, - writeShortcutLink: (shortcutPath, operation, details) => - shell.writeShortcutLink(shortcutPath, operation, details), - }); - }, - detectCommandLineLauncher: async () => - detectCommandLineLauncher(await createCommandLineLauncherRuntimeOptions()), - installBun: async () => { - const snapshot = await installCommandLineBun(await createCommandLineLauncherRuntimeOptions()); - return { - ok: snapshot.status === 'ready', - message: - snapshot.message ?? - (snapshot.status === 'ready' - ? 'Bun is ready. Open a new terminal.' - : 'Bun installation failed.'), - }; - }, - installCommandLineLauncher: async () => { - const snapshot = await installCommandLineLauncher( - await createCommandLineLauncherRuntimeOptions(), - ); - const ok = snapshot.status === 'ready' || snapshot.status === 'not_on_path'; - return { - ok, - installPath: snapshot.installPath, - message: - snapshot.message ?? - (ok - ? 'Command-line launcher installed. Open a new terminal.' - : 'Command-line launcher installation failed.'), - }; - }, - onStateChanged: (state) => { - appState.firstRunSetupCompleted = state.status === 'completed'; + userDataPath: USER_DATA_PATH, + dictionaryBackend: activeDictionaryBackend, + ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), + getYomitanParserRuntimeDeps: () => getYomitanParserRuntimeDeps(), + 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(), + openConfigSettingsWindow: () => settings.window.openWindow(), + onSetupStateChanged: () => { if (appTray) { ensureTrayHandler(); } }, + requestAppQuit: () => requestAppQuit(), }); const discordPresenceSessionStartedAtMs = Date.now(); let discordPresenceMediaDurationSec: number | null = null; @@ -1318,23 +1130,6 @@ const startupOsdSequencer = createStartupOsdSequencer({ showDesktopNotification: (title, options) => showDesktopNotification(title, options), }); let appTray: Tray | null = null; -let tokenizeSubtitleDeferred: ((text: string) => Promise) | null = null; -function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData { - const canonical = resolveCanonicalPrimarySubtitle({ - liveText: payload.text, - currentTimeSec: toSubtitleClock( - Number(appState.mpvClient?.currentTimePos), - appState.mpvClient?.currentSubDelay ?? 0, - ), - cues: appState.activeParsedSubtitleCues, - }); - return { - ...payload, - startTime: canonical?.startTime ?? appState.mpvClient?.currentSubStart ?? null, - endTime: canonical?.endTime ?? appState.mpvClient?.currentSubEnd ?? 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 @@ -1369,45 +1164,10 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n subDelay, ); } -function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void { - const timedPayload = withCurrentSubtitleTiming(payload); - const currentSubtitleData = appState.currentSubtitleData; - const isAnnotationUpgrade = isSubtitleAnnotationUpgrade(currentSubtitleData, timedPayload); - const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; - const frequencyOptions = { - enabled: frequencyDictionary.enabled, - topX: frequencyDictionary.topX, - mode: frequencyDictionary.mode, - }; - appState.currentSubtitleData = timedPayload; - overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload); - if (!isAnnotationUpgrade) { - subtitleWsService.broadcast(timedPayload, frequencyOptions); - } - annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions); - autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true }); - // resumePrefetch: false marks an emit that is not the end of the work for - // this line; prefetch stays paused until the subtitle processing controller - // settles so it does not compete with the on-screen line for the single - // Yomitan parser window. - if (options?.resumePrefetch !== false) { - subtitlePrefetchService?.resume(); - } -} -function getCurrentAutoplaySubtitlePayload(): SubtitleData | null { - const payload = appState.currentSubtitleData; - if (!payload?.text.trim()) { - return null; - } - if (payload.text !== appState.currentSubText) { - return null; - } - return payload; -} - 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; @@ -1430,260 +1190,45 @@ async function resolveSentenceSearchHeadwords(term: string): Promise { } } -function signalCurrentSubtitleAutoplayReady(): void { - autoplayReadyGate.flushPendingAutoplayReadySignal(); - const payload = getCurrentAutoplaySubtitlePayload(); - if (payload) { - autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true }); - return; - } - if (!appState.currentSubText.trim()) { - autoplayReadyGate.maybeSignalPluginAutoplayReady( - { text: '__warm__', tokens: null }, - { forceWhilePaused: true }, - ); - } -} -const subtitleProcessingController = createPrefetchAwareSubtitleProcessingController({ - tokenizeSubtitle: async (text: string) => - tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null }, - emitSubtitlePayload: (payload, options) => emitSubtitlePayload(payload, options), - resumePrefetch: () => { - subtitlePrefetchService?.resume(); +const { + linuxOverlayModeRuntime, + overlayVisibilityRuntime, + createMainWindow, + createModalWindow, + ensureOverlayWindowsReadyForVisibilityActions, + setVisibleOverlayVisible, + toggleVisibleOverlay, + setOverlayVisible, + handleMpvFullscreenChange, + clearLinuxFullscreenOverlayRefresh, +} = createOverlayAssembly({ + appState, + 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), }, - logDebug: (message) => { - logger.debug(`[subtitle-processing] ${message}`); - }, - now: () => Date.now(), -}); - -let subtitlePrefetchService: SubtitlePrefetchService | null = null; -let lastObservedTimePos = 0; -let lastObservedPrimarySubtitleTrackId: number | null = null; -let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null = - null; -const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({ - isEnabled: shouldRunLinuxOverlayZOrderKeepAlive, - isVisible: () => overlayManager.getVisibleOverlayVisible(), - getWindow: () => overlayManager.getMainWindow(), - clearWindow: () => overlayManager.setMainWindow(null), - createWindow: () => { - visibleOverlayInteractionRuntime.resetVisibleOverlayInputState(); - createMainWindow(); - }, - refreshWindow: () => { - const trackedGeometry = overlayGeometryRuntime.getCurrentTrackedOverlayGeometry(); - if (trackedGeometry) overlayManager.setOverlayWindowBounds(trackedGeometry); - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - void ensureOverlayMpvSubtitlesHidden(); - if (appState.currentSubText.trim()) { - subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); - } - }, - now: Date.now, - logDebug: (message) => logger.debug(message), + isDev, + createOverlayWindowCore, + getYomitanSession: () => + activeDictionaryBackend === 'hachidori' ? getHachidoriSession() : appState.yomitanSession, + getVisibleOverlayInteraction: () => visibleOverlayInteractionRuntime, + getOverlayGeometry: () => overlayGeometryRuntime, + getOverlayShortcuts: () => overlayShortcutsRuntime, + getOverlayNotifications: () => overlayNotificationsRuntime, + getOverlayVisibilityComposer: () => overlayVisibilityComposer, + getMediaTimingReviewRuntime: () => mediaTimingReviewRuntime, + getSubtitleProcessingController: () => subtitleProcessingController, + getAutoplaySubtitlePriming: () => autoplaySubtitlePrimingRuntime, + ensureOverlayMpvSubtitlesHidden: () => ensureOverlayMpvSubtitlesHidden(), + syncOverlayMpvSubtitleSuppression: () => syncOverlayMpvSubtitleSuppression(), + initializeOverlayRuntime: () => initializeOverlayRuntime(), }); let subtitleSidebarRequestedOpen = false; -let subtitleSidebarCuesRef: SubtitleCue[] | null = null; -let subtitleSidebarCuesRevision = 0; - -/** - * Cheap sidebar state polled ~12x/s while the sidebar is open. Reads only cached state, no - * mpv round-trips. `cuesRevision` bumps whenever a new parsed cue array is swapped in, so a - * re-parse of the same source still tells the renderer to fetch the full snapshot. - */ -function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState { - if (appState.activeParsedSubtitleCues !== subtitleSidebarCuesRef) { - subtitleSidebarCuesRef = appState.activeParsedSubtitleCues; - subtitleSidebarCuesRevision += 1; - } - const subDelay = appState.mpvClient?.currentSubDelay ?? 0; - return { - sourceKey: JSON.stringify([ - appState.activeParsedSubtitleMediaPath, - appState.activeParsedSubtitleSource, - ]), - cuesRevision: subtitleSidebarCuesRevision, - currentTimeSec: - appState.mpvClient?.currentTimePos === undefined - ? null - : toSubtitleClock(appState.mpvClient.currentTimePos, subDelay), - subDelay, - currentSubtitle: { - text: appState.currentSubText, - startTime: appState.mpvClient?.currentSubStart ?? null, - endTime: appState.mpvClient?.currentSubEnd ?? null, - }, - }; -} -const SEEK_THRESHOLD_SECONDS = 3; -const EXPLICIT_SEEK_INTENT_TTL_MS = 2000; -let explicitSeekIntentExpiresAtMs = 0; - -function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolean { - return command[0] === 'seek' || command[0] === 'sub-seek'; -} - -function getMpvPauseCommandValue(command: readonly (string | number)[]): 'yes' | 'no' | null { - if ((command[0] !== 'set_property' && command[0] !== 'set') || command[1] !== 'pause') { - return null; - } - return command[2] === 'yes' || command[2] === 'no' ? command[2] : null; -} - -function sendRendererMpvCommand(rawCommand: (string | number)[]): void { - // Overlay auto-pause releases (popup closed, hover left) must not resume playback - // behind an open timing review; the review applies them when it closes. A pause - // request during the review keeps playback paused after it closes. - const pauseValue = getMpvPauseCommandValue(rawCommand); - if (pauseValue === 'no' && mediaTimingReviewRuntime.deferPlaybackResume()) { - return; - } - if (pauseValue === 'yes') { - mediaTimingReviewRuntime.cancelPlaybackResume(); - } - const command = - resolveSanitizedSubtitleSeekCommand( - rawCommand, - appState.activeParsedSubtitleCues, - appState.mpvClient?.currentTimePos ?? Number.NaN, - appState.mpvClient?.currentSubDelay ?? 0, - ) ?? rawCommand; - if (isExplicitMpvSeekCommand(command)) { - explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS; - } - sendMpvCommandRuntime(appState.mpvClient, command); -} - -function consumeExplicitSeekIntent(): boolean { - const pending = explicitSeekIntentExpiresAtMs >= Date.now(); - explicitSeekIntentExpiresAtMs = 0; - return pending; -} - -const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({ - getCurrentMediaPath: () => appState.currentMediaPath, - getMpvClient: () => appState.mpvClient, - setCurrentSubText: (text) => { - appState.currentSubText = text; - }, - getCurrentSubText: () => appState.currentSubText, - getCurrentSubtitleData: () => appState.currentSubtitleData, - getActiveParsedSubtitleCues: () => appState.activeParsedSubtitleCues, - setActiveParsedSubtitleMediaPath: (mediaPath) => { - appState.activeParsedSubtitleMediaPath = mediaPath; - }, - subtitleProcessingController, - emitSubtitlePayload: (payload, options) => emitSubtitlePayload(payload, options), - getSubtitlePrefetchService: () => subtitlePrefetchService, - getLastObservedTimePos: () => lastObservedTimePos, - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - emitSecondarySubtitle: (text) => { - secondarySubtitleTrackController.handleLiveText(text); - }, - initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) => - subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey), - refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), - logDebug: (message) => { - logger.debug(message); - }, -}); - -function cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(): void { - cancelLinuxMpvFullscreenOverlayRefreshBurst?.(); - cancelLinuxMpvFullscreenOverlayRefreshBurst = null; -} - -const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ - getCurrentService: () => subtitlePrefetchService, - setCurrentService: (service) => { - subtitlePrefetchService = service; - }, - loadSubtitleSourceText, - parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename), - createSubtitlePrefetchService: (deps) => createSubtitlePrefetchService(deps), - tokenizeSubtitle: async (text) => - tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, - preCacheTokenization: (text, data) => { - subtitleProcessingController.preCacheTokenization(text, data); - }, - hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text), - getCacheGeneration: () => subtitleProcessingController.getCacheGeneration(), - logInfo: (message) => logger.info(message), - logWarn: (message) => logger.warn(message), - onParsedSubtitleCuesChanged: (cues, sourceKey) => { - appState.activeParsedSubtitleCues = cues ?? []; - appState.activeParsedSubtitleSource = sourceKey; - if (!cues?.length) { - appState.activeParsedSubtitleMediaPath = null; - } - const mediaPath = autoplaySubtitlePrimingRuntime.getCurrentAutoplayMediaPath(); - if (mediaPath && cues?.length) { - void autoplaySubtitlePrimingRuntime - .primeAutoplaySubtitleFromParsedCues(mediaPath, cues) - .catch((error) => { - logger.debug( - `[autoplay-subtitle-prime] failed to prime from parsed cues: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - }); - } - }, -}); -const cachedInternalSubtitleTrackExtractor = createCachedInternalSubtitleTrackExtractor(); -const detectRemoteMediaPath = createRemoteMediaPathDetector(); -const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({ - getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg', - extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) => - cachedInternalSubtitleTrackExtractor.extract(ffmpegPath, videoPath, track), - logDebug: (message) => logger.debug(message), -}); - -const secondarySubtitleTrackController = createSecondarySubtitleTrackController({ - getMpvClient: () => appState.mpvClient, - getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? lastObservedTimePos, - resolveSubtitleSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input), - loadSubtitleSourceText, - parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename), - setCurrentSecondaryText: (text) => { - if (appState.mpvClient) { - appState.mpvClient.currentSecondarySubText = text; - } - }, - broadcastSecondaryText: (text) => { - overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text); - }, - logDebug: (message) => logger.debug(message), - logWarn: (message, error) => logger.warn(message, error), -}); - -const refreshSubtitlePrefetchFromActiveTrackHandler = - createRefreshSubtitlePrefetchFromActiveTrackHandler({ - getMpvClient: () => appState.mpvClient, - getLastObservedTimePos: () => lastObservedTimePos, - // Remote media has no extractable on-disk track to fall back to, so a transient - // resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would - // otherwise drop a working cue list for the rest of the episode. - shouldKeepExistingCuesOnMissingSource: async (videoPath) => - isYoutubeMediaPath(videoPath) || (await detectRemoteMediaPath(videoPath)), - subtitlePrefetchInitController, - resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input), - logDebug: (message) => logger.debug(message), - logWarn: (message) => logger.warn(message), - }); - -const subtitlePrefetchRuntime = { - cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(), - initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch, - refreshSubtitleSidebarFromSource: (sourcePath: string, mediaPath?: string) => - autoplaySubtitlePrimingRuntime.refreshSubtitleSidebarFromSource(sourcePath, mediaPath), - refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), - scheduleSubtitlePrefetchRefresh: (delayMs?: number) => - autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(delayMs), - clearScheduledSubtitlePrefetchRefresh: () => - autoplaySubtitlePrimingRuntime.clearScheduledSubtitlePrefetchRefresh(), -} as const; const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({ getConfiguredShortcuts: () => getConfiguredShortcuts(), @@ -1800,7 +1345,7 @@ const settings = createSettingsAssembly({ subtitleProcessingController.invalidateTokenizationCache(); }, refreshSubtitlePrefetch: () => { - subtitlePrefetchService?.onSeek(lastObservedTimePos); + getSubtitlePrefetchService()?.onSeek(getLastObservedTimePos()); }, refreshCurrentSubtitle: () => { subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); @@ -1954,70 +1499,11 @@ const characterDictionary = createCharacterDictionaryAssembly({ invalidateTokenizationCache: () => subtitleProcessingController.invalidateTokenizationCache(), refreshCurrentSubtitle: (text) => subtitleProcessingController.refreshCurrentSubtitle(text), refreshSubtitlePrefetch: () => { - subtitlePrefetchService?.onSeek(lastObservedTimePos); + getSubtitlePrefetchService()?.onSeek(getLastObservedTimePos()); }, refreshCurrentSubtitleAnnotations: () => refreshCurrentSubtitleAnnotations(), }); -const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService({ - getMainWindow: () => overlayManager.getMainWindow(), - getModalActive: () => overlayModalInputState.getModalInputExclusive(), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getForceMousePassthrough: () => appState.statsOverlayVisible, - getNonNativeInputRegionActive: () => - process.platform === 'linux' && - visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(), - getSuspendVisibleOverlay: () => appState.statsOverlayVisible, - getOverlayInteractionActive: () => - visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive(), - getWindowTracker: () => appState.windowTracker, - getMacOSForegroundProbeActive: () => - visibleOverlayInteractionRuntime.getMacOSVisibleOverlayForegroundProbeActive(), - getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, - setTrackerNotReadyWarningShown: (shown: boolean) => { - appState.trackerNotReadyWarningShown = shown; - }, - updateVisibleOverlayBounds: (geometry: WindowGeometry) => - overlayGeometryRuntime.updateVisibleOverlayBounds(geometry), - ensureOverlayWindowLevel: (window) => { - overlayGeometryRuntime.ensureOverlayWindowLevel(window); - }, - syncWindowsOverlayToMpvZOrder: (_window) => { - visibleOverlayInteractionRuntime.requestWindowsVisibleOverlayZOrderSync(); - }, - syncPrimaryOverlayWindowLayer: (layer) => { - overlayGeometryRuntime.syncPrimaryOverlayWindowLayer(layer); - }, - enforceOverlayLayerOrder: () => { - overlayGeometryRuntime.enforceOverlayLayerOrder(); - }, - syncOverlayShortcuts: () => { - overlayShortcutsRuntime.syncOverlayShortcuts(); - }, - isMacOSPlatform: () => process.platform === 'darwin', - isWindowsPlatform: () => process.platform === 'win32', - showOverlayLoadingOsd: (_message: string) => { - overlayNotificationsRuntime.showOverlayLoadingStatusNotification(); - }, - dismissOverlayLoadingOsd: () => { - overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); - }, - hideNonNativeOverlayWhenTargetUnfocused: () => - shouldRunLinuxOverlayZOrderKeepAlive() && - linuxOverlayModeRuntime.mode === 'fullscreen-override', - resolveFallbackBounds: () => { - const cursorPoint = screen.getCursorScreenPoint(); - const display = screen.getDisplayNearestPoint(cursorPoint); - const fallbackBounds = display.workArea; - return { - x: fallbackBounds.x, - y: fallbackBounds.y, - width: fallbackBounds.width, - height: fallbackBounds.height, - }; - }, -}); - const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime({ overlayManager: { getMainWindow: () => overlayManager.getMainWindow(), @@ -2089,7 +1575,6 @@ const overlayNotificationsRuntime = createOverlayNotificationsRuntime({ getRuntimeOptionsManager: () => appState.runtimeOptionsManager, }); const { - flushQueuedOverlayNotifications, flushQueuedMpvOsdNotifications, openAnkiCardFromNotification, toggleNotificationHistoryPanel, @@ -2366,165 +1851,6 @@ const jellyfin = createJellyfinAssembly({ ipcMain, }); -const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({ - getSetupWindow: () => appState.firstRunSetupWindow, -}); -const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ - maybeFocusExistingSetupWindow: maybeFocusExistingFirstRunSetupWindow, - createSetupWindow: createCreateFirstRunSetupWindowHandler({ - createBrowserWindow: (options) => new BrowserWindow(options), - }), - getSetupSnapshot: async () => { - const snapshot = await firstRunSetupService.getSetupStatus(); - const mpvExecutablePath = configService.getConfig().mpv.executablePath; - return { - configReady: snapshot.configReady, - dictionaryCount: snapshot.dictionaryCount, - dictionaryBackend: snapshot.dictionaryBackend, - hachidoriHost: snapshot.hachidoriHost, - canFinish: snapshot.canFinish, - externalYomitanConfigured: snapshot.externalYomitanConfigured, - pluginStatus: snapshot.pluginStatus, - pluginInstallPathSummary: snapshot.pluginInstallPathSummary, - legacyMpvPluginPaths: snapshot.legacyMpvPluginPaths, - mpvExecutablePath, - mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath), - windowsMpvShortcuts: snapshot.windowsMpvShortcuts, - commandLineLauncher: snapshot.commandLineLauncher, - message: firstRunSetupMessage, - }; - }, - buildSetupHtml: (model) => buildFirstRunSetupHtml(model), - parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl), - handleAction: async (submission: FirstRunSetupSubmission) => { - if ( - submission.action === 'link-hachidori-host' || - submission.action === 'unlink-hachidori-host' - ) { - try { - if (activeDictionaryBackend !== 'hachidori') - throw new Error('Select Hachidori and restart SubMiner before linking a host.'); - await ensureYomitanExtensionLoaded(); - const status = await requestHachidoriSharing( - submission.action === 'link-hachidori-host' - ? { type: 'hd_sharing_client_link', address: submission.address } - : { type: 'hd_sharing_client_unlink' }, - getYomitanParserRuntimeDeps(), - logger, - ); - firstRunSetupMessage = - status.kind === 'local' - ? 'Using dictionaries installed in SubMiner.' - : status.kind === 'connected' - ? `Linked to ${status.name}. Anki mining stays in SubMiner.` - : status.message; - } catch (error) { - firstRunSetupMessage = - error instanceof Error ? error.message : 'Could not update the dictionary host.'; - } - return; - } - if (submission.action === 'remove-legacy-plugin') { - const snapshot = await firstRunSetupService.removeLegacyMpvPlugin(); - firstRunSetupMessage = snapshot.message; - return; - } - if (submission.action === 'configure-mpv-executable-path') { - const mpvExecutablePath = submission.mpvExecutablePath?.trim() ?? ''; - const pathStatus = getConfiguredWindowsMpvPathStatus(mpvExecutablePath); - configService.patchRawConfig({ - mpv: { - executablePath: mpvExecutablePath, - }, - }); - firstRunSetupMessage = - pathStatus === 'invalid' - ? `Saved mpv executable path, but the file was not found: ${mpvExecutablePath}` - : mpvExecutablePath - ? `Saved mpv executable path: ${mpvExecutablePath}` - : 'Cleared mpv executable path. SubMiner will auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.'; - return; - } - if (submission.action === 'configure-windows-mpv-shortcuts') { - const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({ - startMenuEnabled: submission.startMenuEnabled === true, - desktopEnabled: submission.desktopEnabled === true, - }); - firstRunSetupMessage = snapshot.message; - return; - } - if (submission.action === 'install-bun') { - const snapshot = await firstRunSetupService.installBun(); - firstRunSetupMessage = snapshot.message; - return; - } - if (submission.action === 'install-command-line-launcher') { - const snapshot = await firstRunSetupService.installCommandLineLauncher(); - firstRunSetupMessage = snapshot.message; - return; - } - if (submission.action === 'open-yomitan-settings') { - firstRunSetupMessage = openDictionarySettings() - ? `Opened ${activeDictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings. Install dictionaries, then refresh status.` - : 'Yomitan settings are unavailable while external read-only profile mode is enabled.'; - return; - } - if (submission.action === 'open-config-settings') { - const opened = settings.window.openWindow(); - firstRunSetupMessage = opened - ? 'Opened SubMiner settings.' - : 'SubMiner settings are unavailable.'; - if (opened) { - return { skipRender: true }; - } - return; - } - if (submission.action === 'refresh') { - const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.'); - firstRunSetupMessage = snapshot.message; - return; - } - - const snapshot = await firstRunSetupService.markSetupCompleted(); - if (snapshot.state.status === 'completed') { - firstRunSetupMessage = null; - return { closeWindow: true }; - } - firstRunSetupMessage = - getFirstRunSetupCompletionMessage(snapshot) ?? - 'Finish setup requires the mpv plugin and Yomitan dictionaries.'; - return; - }, - markSetupInProgress: async () => { - firstRunSetupMessage = null; - await firstRunSetupService.markSetupInProgress(); - }, - markSetupCancelled: async () => { - firstRunSetupMessage = null; - await firstRunSetupService.markSetupCancelled(); - }, - isSetupCompleted: () => firstRunSetupService.isSetupCompleted(), - shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode, - shouldQuitWhenClosedCompleted: () => - Boolean(appState.initialArgs && isStandaloneFirstRunSetupCommand(appState.initialArgs)), - quitApp: () => requestAppQuit(), - clearSetupWindow: () => { - appState.firstRunSetupWindow = null; - }, - setSetupWindow: (window) => { - appState.firstRunSetupWindow = window as BrowserWindow; - }, - encodeURIComponent: (value) => encodeURIComponent(value), - logError: (message, error) => logger.error(message, error), -}); - -function openFirstRunSetupWindow(force = false): void { - if (!force && firstRunSetupService.isSetupCompleted()) { - return; - } - openFirstRunSetupWindowHandler(); -} - const anilist = createAnilistAssembly({ appState, configService, @@ -2618,11 +1944,7 @@ const { }, clearWindowsVisibleOverlayForegroundPollLoop: () => visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(), - clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => { - linuxOverlayModeRuntime.cancelPendingTransition(); - cancelLinuxMpvFullscreenOverlayRefreshBurst = null; - clearLinuxMpvFullscreenOverlayRefreshTimeouts(); - }, + clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => clearLinuxFullscreenOverlayRefresh(), getMainOverlayWindow: () => overlayManager.getMainWindow(), clearMainOverlayWindow: () => overlayManager.setMainWindow(null), getModalOverlayWindow: () => overlayManager.getModalWindow(), @@ -2797,18 +2119,6 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => { ensureImmersionTrackerStarted(); appState.immersionTracker?.recordCardsMined(count, noteIds); }; -function refreshCurrentSubtitleAnnotations(): void { - const hasCurrentSubtitle = appState.currentSubText.trim().length > 0; - if (hasCurrentSubtitle) { - subtitlePrefetchService?.pause(); - } - subtitleProcessingController.invalidateTokenizationCache(); - subtitlePrefetchService?.onSeek(lastObservedTimePos); - if (!subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText)) { - // Idle controller: no settle is coming to release the pause above. - subtitlePrefetchService?.resume(); - } -} let hasAttemptedImmersionTrackerStartup = false; const ensureImmersionTrackerStarted = (): void => { if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) { @@ -2970,7 +2280,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle, onOptionsChanged: () => { subtitleProcessingController.invalidateTokenizationCache(); - subtitlePrefetchService?.onSeek(lastObservedTimePos); + getSubtitlePrefetchService()?.onSeek(getLastObservedTimePos()); overlayVisibilityComposer.broadcastRuntimeOptionsChanged(); refreshOverlayShortcuts(); }, @@ -3051,13 +2361,13 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ await ensureYomitanExtensionLoaded(); }, handleFirstRunSetup: async () => { - const snapshot = await firstRunSetupService.ensureSetupStateInitialized(); + 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') { - openFirstRunSetupWindow(force); + firstRunSetup.openWindow(force); } } }, @@ -3136,7 +2446,7 @@ function ensureOverlayStartupPrereqs(): void { getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle, onOptionsChanged: () => { subtitleProcessingController.invalidateTokenizationCache(); - subtitlePrefetchService?.onSeek(lastObservedTimePos); + getSubtitlePrefetchService()?.onSeek(getLastObservedTimePos()); overlayVisibilityComposer.broadcastRuntimeOptionsChanged(); refreshOverlayShortcuts(); }, @@ -3158,444 +2468,66 @@ async function ensureYoutubePlaybackRuntimeReady(): Promise { ensureOverlayWindowsReadyForVisibilityActions(); } -let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined) => void) | null = - null; - const { - createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, - updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler, - tokenizeSubtitle: tokenizeSubtitleRuntime, + subtitleProcessingController, + autoplaySubtitlePrimingRuntime, + subtitlePrefetchRuntime, + cachedInternalSubtitleTrackExtractor, + resolveActiveSubtitleSidebarSourceHandler, + withCurrentSubtitleTiming, + getSubtitleSidebarPlaybackState, + sendRendererMpvCommand, + refreshCurrentSubtitleAnnotations, + createMpvClientRuntimeServiceHandler, createMecabTokenizerAndCheck, prewarmSubtitleDictionaries, startBackgroundWarmups, startTokenizationWarmups, isTokenizationWarmupReady, -} = composeMpvRuntimeHandlers< - MpvIpcClient, - ReturnType, - SubtitleData ->({ - bindMpvMainEventHandlersMainDeps: { - appState, - getQuitOnDisconnectArmed: () => - jellyfin.getQuitOnDisconnectArmed() || youtube.playback.getQuitOnDisconnectArmed(), - scheduleQuitCheck: (callback) => { - setTimeout(callback, 500); - }, - quitApp: () => requestAppQuit(), - reportJellyfinRemoteStopped: () => { - void jellyfin.reportJellyfinRemoteStopped(); - }, - onMpvConnected: () => { - maybeStartOverlayLoadingOsd(); - flushQueuedMpvOsdNotifications(); - secondarySubtitleTrackController.scheduleRefresh(0); - void refreshMpvSessionBindings(); - if (appState.sessionBindingsInitialized) { - sendMpvCommandRuntime(appState.mpvClient, [ - 'script-message', - 'subminer-reload-session-bindings', - ]); - } - if (appState.currentSubText.trim()) { - subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); - } - }, - maybeRunAnilistPostWatchUpdate: (options) => anilist.maybeRunAnilistPostWatchUpdate(options), - recordAnilistMediaDuration: (durationSec) => { - anilist.recordAnilistMediaDuration(durationSec); - }, - logSubtitleTimingError: (message, error) => logger.error(message, error), - broadcastToOverlayWindows: (channel, payload) => { - overlayManager.broadcastToOverlayWindows(channel, payload); - }, - onSecondarySubtitleChange: (text) => { - secondarySubtitleTrackController.handleLiveText(text); - }, - getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text), - emitImmediateSubtitle: (payload) => { - emitSubtitlePayload(payload); - }, - onSubtitleChange: (text) => { - // Pause only; restarting the prefetch run here would discard in-flight - // tokenization work on every line. Real seeks restart via onTimePosUpdate. - subtitlePrefetchService?.pause(); - if (!subtitleProcessingController.onSubtitleChange(text)) { - // Repeat of the current text: the controller is idle, so no settle is - // coming to release the pause. Resume now instead of idling prefetch - // for the rest of the cue. - subtitlePrefetchService?.resume(); - } - }, - refreshDiscordPresence: () => { - discordPresenceRuntime.publishDiscordPresence(); - }, - logSubtitleProcessingDebug: (message: string) => logger.debug(message), - ensureImmersionTrackerInitialized: () => { - ensureImmersionTrackerStarted(); - }, - tokenizeSubtitleForImmersion: async (text): Promise => - tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, - updateCurrentMediaPath: (path) => { - const normalizedPath = path.trim(); - maybeStartOverlayLoadingOsd(normalizedPath); - const previousPath = appState.currentMediaPath?.trim() || null; - const preserveParsedSubtitleCues = isSameYoutubeMediaPath( - normalizedPath, - appState.activeParsedSubtitleMediaPath, - ); - if ((normalizedPath || null) !== previousPath) { - cachedInternalSubtitleTrackExtractor.clear(); - secondarySubtitleTrackController.reset(); - const resetSubtitlePayload = { text: '', tokens: null }; - const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; - const frequencyOptions = { - enabled: frequencyDictionary.enabled, - topX: frequencyDictionary.topX, - mode: frequencyDictionary.mode, - }; - autoplaySubtitlePrimingRuntime.resetAutoplaySubtitlePrime(); - lastObservedTimePos = 0; - appState.currentSubText = ''; - appState.currentSubtitleData = null; - if (!preserveParsedSubtitleCues) { - appState.activeParsedSubtitleCues = []; - appState.activeParsedSubtitleSource = null; - appState.activeParsedSubtitleMediaPath = null; - } - overlayManager.broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); - subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); - annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); - autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(); - } - currentMediaTokenizationGate.updateCurrentMediaPath(path); - managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path); - startupOsdSequencer.reset(); - subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh(); - if (!preserveParsedSubtitleCues) { - subtitlePrefetchRuntime.cancelPendingInit(); - } - youtube.primarySubtitleNotification.handleMediaPathChange(path); - void youtube.mediaCachePlayback.handleMediaPathChange(path); - youtube.browserPlayback.handleMediaPathChange(path); - youtube.flow.handleMediaPathChange(path); - subtitleGenerationRuntime.handleMediaPathChange(path); - if (path) { - ensureImmersionTrackerStarted(); - secondarySubtitleTrackController.scheduleRefresh(); - void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack(); - // Retry after a short delay because MPV can populate track-list after path. - subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500); - } - mediaRuntime.updateCurrentMediaPath(path); - }, - restoreMpvSubVisibility: () => { - restoreOverlayMpvSubtitles(); - }, - resetSubtitleSidebarEmbeddedLayout: () => { - resetSubtitleSidebarEmbeddedLayoutRuntime(); - }, - getCurrentAnilistMediaKey: () => anilist.getCurrentAnilistMediaKey(), - resetAnilistMediaTracking: (mediaKey) => { - anilist.resetAnilistMediaTracking(mediaKey); - }, - maybeProbeAnilistDuration: (mediaKey) => { - void anilist.maybeProbeAnilistDuration(mediaKey); - }, - ensureAnilistMediaGuess: (mediaKey) => { - void anilist.ensureAnilistMediaGuess(mediaKey); - }, - syncImmersionMediaState: () => { - immersionMediaRuntime.syncFromCurrentMediaState(); - }, - signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path), - markJellyfinRemotePlaybackLoaded: (path) => { - markJellyfinRemotePlaybackLoadedState(jellyfin.getActivePlayback(), path); - }, - scheduleCharacterDictionarySync: () => { - if ( - !configService.getConfig().subtitleStyle.nameMatchEnabled || - !yomitanProfilePolicy.isCharacterDictionaryEnabled() || - youtube.isPlaybackActiveNow() - ) { - return; - } - characterDictionary.autoSync.scheduleSync(); - }, - updateCurrentMediaTitle: (title) => { - mediaRuntime.updateCurrentMediaTitle(title); - }, - resetAnilistMediaGuessState: () => { - anilist.resetAnilistMediaGuessState(); - }, - reportJellyfinRemoteProgress: (forceImmediate) => { - void jellyfin.reportJellyfinRemoteProgress(forceImmediate); - }, - consumeExplicitSeek: () => consumeExplicitSeekIntent(), - onTimePosUpdate: (time) => { - const delta = time - lastObservedTimePos; - if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) { - subtitlePrefetchService.onSeek(time); - } - lastObservedTimePos = time; - secondarySubtitleTrackController.handleTimePos(time); - }, - onFullscreenChange: (fullscreen) => { - cancelLinuxMpvFullscreenOverlayRefreshBurst = updateLinuxMpvFullscreenOverlayRefreshBurst( - fullscreen, - { - overlayManager: { - getMainWindow: () => overlayManager.getMainWindow(), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - }, - overlayVisibilityRuntime, - syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) => - linuxOverlayModeRuntime.sync(nextFullscreen), - getOverlayInteractionActive: () => - visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() || - visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(), - ensureOverlayWindowLevel: (window) => - overlayGeometryRuntime.ensureOverlayWindowLevel(window), - }, - cancelLinuxMpvFullscreenOverlayRefreshBurst, - ); - }, - onSubtitleTrackChange: (sid) => { - lastObservedPrimarySubtitleTrackId = sid; - logger.info('[mpv-subtitles] primary subtitle track changed', { sid }); - autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(); - youtube.primarySubtitleNotification.handleSubtitleTrackChange(sid); - }, - onSecondarySubtitleTrackChange: () => { - secondarySubtitleTrackController.handleTrackChange(); - secondarySubtitleTrackController.scheduleRefresh(0); - }, - onSecondarySubtitleDelayChange: (delay) => { - secondarySubtitleTrackController.handleDelayChange(delay); - }, - onSubtitleTrackListChange: (trackList) => { - const diagnostics = buildSubtitleTrackDiagnostics( - lastObservedPrimarySubtitleTrackId, - trackList, - ); - if (!diagnostics.trackListReadable) { - logger.warn('[mpv-subtitles] mpv reported an unreadable subtitle track list', diagnostics); - } else if (diagnostics.subtitleTrackCount === 0) { - logger.warn('[mpv-subtitles] mpv reported no subtitle tracks', diagnostics); - } else { - logger.info('[mpv-subtitles] subtitle track list updated', diagnostics); - } - managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList); - secondarySubtitleTrackController.scheduleRefresh(0); - autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(); - youtube.primarySubtitleNotification.handleSubtitleTrackListChange(trackList); - }, - updateSubtitleRenderMetrics: (patch) => { - updateMpvSubtitleRenderMetricsHandler(patch as Partial); - }, - syncOverlayMpvSubtitleSuppression: () => { - syncOverlayMpvSubtitleSuppression(); - }, - }, - mpvClientRuntimeServiceFactoryMainDeps: { - createClient: MpvIpcClient, - getSocketPath: () => appState.mpvSocketPath, - getResolvedConfig: () => configService.getConfig(), - isAutoStartOverlayEnabled: () => appState.autoStartOverlay, - setOverlayVisible: (visible: boolean) => setOverlayVisible(visible), - isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getReconnectTimer: () => appState.reconnectTimer, - setReconnectTimer: (timer: ReturnType | null) => { - appState.reconnectTimer = timer; - }, - shouldAutoLoadSecondarySubTrack: (path: string) => - shouldAutoLoadSecondarySubTrackForJellyfinPlayback(jellyfin.getActivePlayback(), path), - shouldQuitOnMpvShutdown: () => - shouldQuitOnMpvShutdownForTrayState({ - managedPlayback: appState.initialArgs?.managedPlayback === true, - backgroundMode: appState.backgroundMode, - hasTray: Boolean(appTray), - }), - requestAppQuit: () => requestAppQuit(), - }, - updateMpvSubtitleRenderMetricsMainDeps: { - getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics, - setCurrentMetrics: (metrics) => { - appState.mpvSubtitleRenderMetrics = metrics; - }, - applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch), - broadcastMetrics: () => { - // no renderer consumer for subtitle render metrics updates at present - }, - }, - tokenizer: { - buildTokenizerDepsMainDeps: { - getYomitanExt: () => appState.yomitanExt, - getYomitanSession: () => appState.yomitanSession, - getYomitanParserWindow: () => appState.yomitanParserWindow, - setYomitanParserWindow: (window) => { - appState.yomitanParserWindow = window as BrowserWindow | null; - }, - getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, - setYomitanParserReadyPromise: (promise) => { - appState.yomitanParserReadyPromise = promise; - }, - getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, - setYomitanParserInitPromise: (promise) => { - appState.yomitanParserInitPromise = promise; - }, - isKnownWord: (text, reading, options) => - Boolean(appState.ankiIntegration?.isKnownWord(text, reading, options)), - getKnownWordTier: (text, reading, options) => - appState.ankiIntegration?.getKnownWordTier(text, reading, options) ?? null, - recordLookup: (hit) => { - ensureImmersionTrackerStarted(); - appState.immersionTracker?.recordLookup(hit); - }, - getKnownWordMatchMode: () => - appState.ankiIntegration?.getKnownWordMatchMode() ?? - configService.getConfig().ankiConnect.knownWords.matchMode, - getKnownWordsEnabled: () => - getRuntimeBooleanOption( - 'subtitle.annotation.knownWords.highlightEnabled', - configService.getConfig().ankiConnect.knownWords.highlightEnabled, - ), - getNPlusOneEnabled: () => - getRuntimeBooleanOption( - 'subtitle.annotation.nPlusOne', - configService.getConfig().ankiConnect.nPlusOne.enabled, - ), - getMinSentenceWordsForNPlusOne: () => - configService.getConfig().ankiConnect.nPlusOne.minSentenceWords, - getJlptLevel: (text) => appState.jlptLevelLookup(text), - getJlptEnabled: () => - getRuntimeBooleanOption( - 'subtitle.annotation.jlpt', - configService.getConfig().subtitleStyle.enableJlpt, - ), - getCharacterDictionaryEnabled: () => - configService.getConfig().subtitleStyle.nameMatchEnabled && - yomitanProfilePolicy.isCharacterDictionaryEnabled() && - !youtube.isPlaybackActiveNow(), - getNameMatchEnabled: () => configService.getConfig().subtitleStyle.nameMatchEnabled, - getNameMatchImagesEnabled: () => - configService.getConfig().subtitleStyle.nameMatchImagesEnabled, - getCharacterNameImage: (term) => characterDictionary.imageLookup.get(term), - getCurrentCharacterDictionaryMediaId: () => characterDictionary.autoSync.getCurrentMediaId(), - getCharacterNameCandidates: () => characterDictionary.nameCandidates.get(), - getFrequencyDictionaryEnabled: () => - getRuntimeBooleanOption( - 'subtitle.annotation.frequency', - configService.getConfig().subtitleStyle.frequencyDictionary.enabled, - ), - getFrequencyDictionaryMatchMode: () => - configService.getConfig().subtitleStyle.frequencyDictionary.matchMode, - getFrequencyRank: (text) => appState.frequencyRankLookup(text), - getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, - getMecabTokenizer: () => appState.mecabTokenizer, - onTokenizationReady: () => { - currentMediaTokenizationGate.markReady( - appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, - ); - startupOsdSequencer.markTokenizationReady(); - }, - }, - createTokenizerRuntimeDeps: (deps) => createTokenizerDepsRuntime(deps), - tokenizeSubtitle: (text, deps) => tokenizeSubtitleCore(text, deps), - createMecabTokenizerAndCheckMainDeps: { - getMecabTokenizer: () => appState.mecabTokenizer, - setMecabTokenizer: (tokenizer) => { - appState.mecabTokenizer = tokenizer as MecabTokenizer | null; - }, - createMecabTokenizer: () => new MecabTokenizer(), - checkAvailability: async (tokenizer) => (tokenizer as MecabTokenizer).checkAvailability(), - }, - prewarmSubtitleDictionariesMainDeps: { - ensureJlptDictionaryLookup: () => dictionaries.jlpt.ensureJlptDictionaryLookup(), - ensureFrequencyDictionaryLookup: () => - dictionaries.frequency.ensureFrequencyDictionaryLookup(), - showMpvOsd: (message: string) => - overlayNotificationsRuntime.showConfiguredStatusNotification(message), - showLoadingOsd: (message: string) => startupOsdSequencer.showAnnotationLoading(message), - showLoadedOsd: (message: string) => - startupOsdSequencer.markAnnotationLoadingComplete(message), - shouldShowOsdNotification: () => { - const type = overlayNotificationsRuntime.getConfiguredStatusNotificationType(); - return type === 'osd' || type === 'osd-system'; - }, - }, - }, - warmups: { - launchBackgroundWarmupTaskMainDeps: { - now: () => Date.now(), - logDebug: (message) => logger.debug(message), - logWarn: (message) => logger.warn(message), - }, - startBackgroundWarmupsMainDeps: { - getStarted: () => backgroundWarmupsStarted, - setStarted: (started) => { - backgroundWarmupsStarted = started; - }, - isTexthookerOnlyMode: () => appState.texthookerOnlyMode, - ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}), - shouldWarmupMecab: () => { - const startupWarmups = configService.getConfig().startupWarmups; - if (startupWarmups.lowPowerMode) { - return false; - } - if (!startupWarmups.mecab) { - return false; - } - return shouldInitializeMecabForAnnotations(); - }, - shouldWarmupYomitanExtension: () => configService.getConfig().startupWarmups.yomitanExtension, - shouldWarmupSubtitleDictionaries: () => { - const startupWarmups = configService.getConfig().startupWarmups; - if (startupWarmups.lowPowerMode) { - return false; - } - return startupWarmups.subtitleDictionaries; - }, - shouldWarmupJellyfinRemoteSession: () => { - const startupWarmups = configService.getConfig().startupWarmups; - if (startupWarmups.lowPowerMode) { - return false; - } - return startupWarmups.jellyfinRemoteSession; - }, - shouldAutoConnectJellyfinRemote: () => { - const jellyfin = configService.getConfig().jellyfin; - return ( - jellyfin.enabled && jellyfin.remoteControlEnabled && jellyfin.remoteControlAutoConnect - ); - }, - startJellyfinRemoteSession: () => jellyfin.startJellyfinRemoteSession(), - logDebug: (message) => logger.debug(message), - }, - }, + getSubtitlePrefetchService, + getLastObservedTimePos, + getTokenizeSubtitleDeferred, +} = createPlaybackAssembly({ + appState, + configService, + logger, + overlayManager, + subtitleWsService, + annotationSubtitleWsService, + autoplayReadyGate, + currentMediaTokenizationGate, + startupOsdSequencer, + managedLocalSubtitleSelectionRuntime, + mediaTimingReviewRuntime, + discordPresenceRuntime, + immersionMediaRuntime, + mediaRuntime, + overlayNotificationsRuntime, + youtube, + jellyfin, + anilist, + characterDictionary, + dictionaries, + getSubtitleGenerationRuntime: () => subtitleGenerationRuntime, + isCharacterDictionaryEnabled: () => yomitanProfilePolicy.isCharacterDictionaryEnabled(), + hasTray: () => Boolean(appTray), + loadSubtitleSourceText, + createSubtitlePrefetchService, + requestAppQuit: () => requestAppQuit(), + maybeStartOverlayLoadingOsd: (mediaPath) => maybeStartOverlayLoadingOsd(mediaPath), + flushQueuedMpvOsdNotifications: () => flushQueuedMpvOsdNotifications(), + refreshMpvSessionBindings: () => refreshMpvSessionBindings(), + ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(), + restoreOverlayMpvSubtitles: () => restoreOverlayMpvSubtitles(), + resetSubtitleSidebarEmbeddedLayoutRuntime: () => resetSubtitleSidebarEmbeddedLayoutRuntime(), + syncOverlayMpvSubtitleSuppression: () => syncOverlayMpvSubtitleSuppression(), + setOverlayVisible: (visible) => setOverlayVisible(visible), + ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), + shouldInitializeMecabForAnnotations: () => shouldInitializeMecabForAnnotations(), + getRuntimeBooleanOption: (id, fallback) => getRuntimeBooleanOption(id, fallback), + onFullscreenChange: (fullscreen) => handleMpvFullscreenChange(fullscreen), }); -async function tokenizeSubtitle(text: string): Promise { - if (!isTokenizationWarmupReady()) { - startupOsdSequencer.showTokenizationLoading('Loading subtitle tokenization...'); - } - return await tokenizeSubtitleRuntime(text); -} - -signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease({ - isTokenizationWarmupReady: () => isTokenizationWarmupReady(), - startTokenizationWarmups: async () => { - await startTokenizationWarmups(); - }, - getCurrentMediaPath: () => - appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, - primeCurrentSubtitle: (mediaPath) => - autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForAutoplay(mediaPath), - signalAutoplayReady: () => signalCurrentSubtitleAutoplayReady(), - warn: (message, error) => logger.warn(message, error), -}); -tokenizeSubtitleDeferred = tokenizeSubtitle; - const aniSkipRuntime = createAniSkipRuntime({ getAniSkipConfig: () => ({ aniskipEnabled: configService.getConfig().mpv.aniskipEnabled, @@ -3739,27 +2671,6 @@ function getYomitanParserRuntimeDeps() { }; } -function createModalWindow(): BrowserWindow { - const existingWindow = overlayManager.getModalWindow(); - if (existingWindow && !existingWindow.isDestroyed()) { - return existingWindow; - } - const window = createModalWindowHandler(); - overlayManager.setModalWindowBounds(overlayGeometryRuntime.getCurrentOverlayGeometry()); - return window; -} - -function createMainWindow(): BrowserWindow { - const window = createMainWindowHandler(); - if (process.platform === 'win32') { - const overlayHwnd = visibleOverlayInteractionRuntime.getWindowsNativeWindowHandleNumber(window); - if (!ensureWindowsOverlayTransparency(overlayHwnd)) { - logger.warn('Failed to eagerly extend Windows overlay transparency via koffi'); - } - } - return window; -} - function generateMiningSentenceFurigana( text: string, highlightedText?: string, @@ -4093,21 +3004,6 @@ const handleMineSentenceDigitHandler = createHandleMineSentenceDigitHandler({ }, handleMineSentenceDigitCore, }); -const { - setVisibleOverlayVisible: setVisibleOverlayVisibleHandler, - toggleVisibleOverlay: toggleVisibleOverlayHandler, - setOverlayVisible: setOverlayVisibleHandler, -} = createOverlayVisibilityRuntime({ - setVisibleOverlayVisibleDeps: { - setVisibleOverlayVisibleCore, - setVisibleOverlayVisibleState: (nextVisible) => { - overlayManager.setVisibleOverlayVisible(nextVisible); - }, - updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), - }, - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), -}); - const handleOverlayModalClosedHandler = createHandleOverlayModalClosedHandler({ handleOverlayModalClosedRuntime: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal), }); @@ -4385,7 +3281,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ : captureCurrentPrimarySubtitleMiningContext(), ), tokenizeCurrentSubtitle: async () => { - const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred; + const tokenizeSubtitleForCurrent = getTokenizeSubtitleDeferred(); return resolveCurrentSubtitleForRenderer({ currentSubText: appState.currentSubText, currentSubtitleData: appState.currentSubtitleData, @@ -4656,7 +3552,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ toggleVisibleOverlay: () => toggleVisibleOverlay(), togglePrimarySubtitleBar: () => overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.primarySubtitleBarToggle), - openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force), + openFirstRunSetupWindow: (force?: boolean) => firstRunSetup.openWindow(force), setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), copyCurrentSubtitle: () => copyCurrentSubtitleHandler(), startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), @@ -4887,62 +3783,6 @@ if (!appState.initialArgs || (!shouldUseMinimalStartup && !shouldSkipHeavyStartu logger.error('Failed to initialize Discord presence service during startup', error); }); } -const { createMainWindow: createMainWindowHandler, createModalWindow: createModalWindowHandler } = - createOverlayWindowRuntimeHandlers({ - createOverlayWindowDeps: { - createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), - isDev, - ensureOverlayWindowLevel: (window) => overlayGeometryRuntime.ensureOverlayWindowLevel(window), - onRuntimeOptionsChanged: () => overlayVisibilityComposer.broadcastRuntimeOptionsChanged(), - setOverlayDebugVisualizationEnabled: (enabled) => - overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled), - isOverlayVisible: (windowKind) => - windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false, - getYomitanSession: () => - activeDictionaryBackend === 'hachidori' ? getHachidoriSession() : appState.yomitanSession, - tryHandleOverlayShortcutLocalFallback: (input) => - overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), - forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), - getLinuxX11FullscreenOverlay: () => - shouldRunLinuxOverlayZOrderKeepAlive() && - linuxOverlayModeRuntime.fullscreen && - linuxOverlayModeRuntime.mode === 'fullscreen-override', - onVisibleWindowBlurred: () => - visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(), - onVisibleWindowFocused: () => - visibleOverlayInteractionRuntime.requestLinuxOverlayZOrderFollow(), - onWindowDidFinishLoad: () => { - flushQueuedOverlayNotifications(); - }, - onWindowContentReady: () => { - overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); - flushQueuedOverlayNotifications(); - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - visibleOverlayInteractionRuntime.primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); - autoplayReadyGate.flushPendingAutoplayReadySignal(); - }, - onWindowClosed: (windowKind, window) => { - if (windowKind === 'visible') { - if (overlayManager.getMainWindow() !== window) { - return; - } - cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); - overlayManager.setMainWindow(null); - } else { - if (overlayManager.getModalWindow() !== window) { - return; - } - void mediaTimingReviewRuntime.dispose(); - overlayManager.setModalWindow(null); - } - }, - }, - getMainWindow: () => overlayManager.getMainWindow(), - isWindowDestroyed: (window) => window.isDestroyed(), - setMainWindow: (window) => overlayManager.setMainWindow(window), - setModalWindow: (window) => overlayManager.setModalWindow(window), - }); - function refreshTrayMenuIfPresent(): void { if (appTray) { ensureTrayHandler(); @@ -4970,8 +3810,8 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = openTexthookerInBrowser: () => handleCliCommand(parseArgs(['--texthooker', '--open-browser'])), showTexthookerPage: () => shouldShowTexthookerTrayEntry(configService.getConfig()), - showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(), - openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force), + showFirstRunSetup: () => !firstRunSetup.service.isSetupCompleted(), + openFirstRunSetupWindow: (force?: boolean) => firstRunSetup.openWindow(force), showWindowsMpvLauncherSetup: () => process.platform === 'win32', getDictionaryBackend: () => activeDictionaryBackend, openHachidoriSettings: () => openHachidoriSettings(), @@ -5166,89 +4006,6 @@ const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSetting logError: (message, error) => logger.error(message, error), }); -function ensureOverlayWindowsReadyForVisibilityActions(): void { - if (!appState.overlayRuntimeInitialized) { - initializeOverlayRuntime(); - return; - } - - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) { - createMainWindow(); - } -} - -function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void { - sendMpvCommandRuntime(appState.mpvClient, [ - 'script-message', - visible ? 'subminer-visible-overlay-shown' : 'subminer-visible-overlay-hidden', - ]); -} - -function setVisibleOverlayVisible(visible: boolean): void { - ensureOverlayWindowsReadyForVisibilityActions(); - if (!visible) { - overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); - autoplayReadyGate.markCurrentMediaAutoplayReady(); - autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); - cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); - visibleOverlayInteractionRuntime.resetVisibleOverlayInputState(); - } - if (visible) { - maybeStartOverlayLoadingOsd(); - visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer(); - visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace(); - visibleOverlayInteractionRuntime.restoreVisibleOverlayWindowShapeForShow(); - void ensureOverlayMpvSubtitlesHidden(); - void autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay(); - } - setVisibleOverlayVisibleHandler(visible); - notifyMpvPluginVisibleOverlayVisibility(visible); - syncOverlayMpvSubtitleSuppression(); -} - -function toggleVisibleOverlay(): void { - ensureOverlayWindowsReadyForVisibilityActions(); - const nextVisible = !overlayManager.getVisibleOverlayVisible(); - if (!nextVisible) { - overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); - autoplayReadyGate.markCurrentMediaAutoplayReady(); - autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); - cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); - visibleOverlayInteractionRuntime.resetVisibleOverlayInputState(); - } else { - maybeStartOverlayLoadingOsd(); - visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer(); - visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace(); - visibleOverlayInteractionRuntime.restoreVisibleOverlayWindowShapeForShow(); - void ensureOverlayMpvSubtitlesHidden(); - void autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay(); - } - toggleVisibleOverlayHandler(); - notifyMpvPluginVisibleOverlayVisibility(nextVisible); - syncOverlayMpvSubtitleSuppression(); -} -function setOverlayVisible(visible: boolean): void { - if (!visible) { - overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); - autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); - visibleOverlayInteractionRuntime.resetVisibleOverlayInputState(); - autoplayReadyGate.markCurrentMediaAutoplayReady(); - cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); - } - if (visible) { - maybeStartOverlayLoadingOsd(); - visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer(); - visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace(); - visibleOverlayInteractionRuntime.restoreVisibleOverlayWindowShapeForShow(); - void ensureOverlayMpvSubtitlesHidden(); - void autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay(); - } - setOverlayVisibleHandler(visible); - notifyMpvPluginVisibleOverlayVisibility(visible); - syncOverlayMpvSubtitleSuppression(); -} - registerIpcRuntimeHandlers(); registerSubtitleSelectionIpc({ ipc: ipcMain, diff --git a/src/main/assemblies/first-run-setup.test.ts b/src/main/assemblies/first-run-setup.test.ts new file mode 100644 index 00000000..e2f325f0 --- /dev/null +++ b/src/main/assemblies/first-run-setup.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + createDefaultSetupState, + getSetupStatePath, + writeSetupState, +} from '../../shared/setup-state'; +import { createFirstRunSetupAssembly, type FirstRunSetupAssemblyDeps } from './first-run-setup'; + +// Every callback throws, so a test fails if the assembly calls a dep it should only call later. +function makeDeps(configDir: string): FirstRunSetupAssemblyDeps { + const lazy = (name: string) => (): never => { + throw new Error(`${name} called unexpectedly`); + }; + return { + appState: { + firstRunSetupCompleted: false, + firstRunSetupWindow: null, + backgroundMode: false, + initialArgs: null, + }, + configService: { getConfig: lazy('getConfig'), patchRawConfig: lazy('patchRawConfig') }, + logger: { error: lazy('error'), info: lazy('info') }, + configDir, + userDataPath: configDir, + dictionaryBackend: 'yomitan', + ensureYomitanExtensionLoaded: lazy('ensureYomitanExtensionLoaded'), + getYomitanParserRuntimeDeps: lazy('getYomitanParserRuntimeDeps'), + createCommandLineLauncherRuntimeOptions: lazy('createCommandLineLauncherRuntimeOptions'), + getAppPath: lazy('getAppPath'), + trashItem: lazy('trashItem'), + writeShortcutLink: lazy('writeShortcutLink'), + createBrowserWindow: lazy('createBrowserWindow'), + openDictionarySettings: lazy('openDictionarySettings'), + openConfigSettingsWindow: lazy('openConfigSettingsWindow'), + onSetupStateChanged: lazy('onSetupStateChanged'), + requestAppQuit: lazy('requestAppQuit'), + }; +} + +function makeConfigDir(t: { after: (fn: () => void) => void }): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-assembly-test-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +// main.ts builds the assembly before most siblings exist, so construction must stay lazy. +test('createFirstRunSetupAssembly does not call its deps while constructing', (t) => { + const firstRunSetup = createFirstRunSetupAssembly(makeDeps(makeConfigDir(t))); + assert.equal(typeof firstRunSetup.service.isSetupCompleted, 'function'); + assert.equal(typeof firstRunSetup.openWindow, 'function'); +}); + +test('openWindow skips a completed setup unless forced', (t) => { + const configDir = makeConfigDir(t); + writeSetupState(getSetupStatePath(configDir), { + ...createDefaultSetupState(), + status: 'completed', + }); + const firstRunSetup = createFirstRunSetupAssembly(makeDeps(configDir)); + + firstRunSetup.openWindow(); + assert.throws(() => firstRunSetup.openWindow(true), /createBrowserWindow called unexpectedly/); +}); diff --git a/src/main/assemblies/first-run-setup.ts b/src/main/assemblies/first-run-setup.ts new file mode 100644 index 00000000..05cc6c72 --- /dev/null +++ b/src/main/assemblies/first-run-setup.ts @@ -0,0 +1,371 @@ +import os from 'node:os'; +import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; +import type { ConfigService } from '../../config/service'; +import { getYomitanDictionaryInfo } from '../../core/services'; +import { requestHachidoriSharing } from '../../core/services/tokenizer/yomitan-parser-runtime'; +import type { Logger } from '../../logger'; +import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state'; +import type { DictionaryBackend } from '../../types/config'; +import { + detectCommandLineLauncher, + installBun as installCommandLineBun, + installLauncher as installCommandLineLauncher, +} from '../runtime/command-line-launcher'; +import { + detectInstalledFirstRunPlugin, + detectInstalledFirstRunPluginCandidates, + removeLegacyMpvPluginCandidates, +} from '../runtime/first-run-setup-plugin'; +import { + createFirstRunSetupService, + getFirstRunSetupCompletionMessage, + isStandaloneFirstRunSetupCommand, +} from '../runtime/first-run-setup-service'; +import { + buildFirstRunSetupHtml, + createMaybeFocusExistingFirstRunSetupWindowHandler, + createOpenFirstRunSetupWindowHandler, + parseFirstRunSetupSubmissionUrl, + type FirstRunSetupSubmission, +} from '../runtime/first-run-setup-window'; +import { createCreateFirstRunSetupWindowHandler } from '../runtime/setup-window-factory'; +import { getConfiguredWindowsMpvPathStatus } from '../runtime/windows-mpv-launch'; +import { + applyWindowsMpvShortcuts, + detectWindowsMpvShortcuts, + resolveWindowsMpvShortcutPaths, +} from '../runtime/windows-mpv-shortcuts'; +import { observeYomitanDictionaryCount } from '../runtime/yomitan-dictionary-integrity'; +import type { AppState } from '../state'; + +type YomitanParserRuntimeDeps = Parameters[1]; +type CommandLineLauncherRuntimeOptions = NonNullable< + Parameters[0] +>; + +/** + * What the first-run setup flow needs from the rest of the app. Siblings built after this + * assembly in `main.ts` are reached through callbacks. + */ +export interface FirstRunSetupAssemblyDeps { + appState: Pick< + AppState, + 'firstRunSetupCompleted' | 'firstRunSetupWindow' | 'backgroundMode' | 'initialArgs' + >; + configService: Pick; + logger: Pick; + configDir: string; + userDataPath: string; + /** Fixed for the process lifetime; backend changes apply on restart. */ + dictionaryBackend: DictionaryBackend; + ensureYomitanExtensionLoaded: () => Promise; + getYomitanParserRuntimeDeps: () => YomitanParserRuntimeDeps; + /** Resolves the launcher options after the login-shell PATH is applied. */ + createCommandLineLauncherRuntimeOptions: () => Promise; + getAppPath: (name: 'appData' | 'desktop') => string; + trashItem: (targetPath: string) => Promise; + writeShortcutLink: Parameters[0]['writeShortcutLink']; + createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow; + openDictionarySettings: () => boolean; + openConfigSettingsWindow: () => boolean; + /** Called when setup state changes, so the tray menu can refresh its setup entry. */ + onSetupStateChanged: () => void; + requestAppQuit: () => void; +} + +export type FirstRunSetupAssembly = ReturnType; + +/** + * Owns the first-run setup service and its window, plus the status message shown in that + * window. `main.ts` initializes setup state during app-ready and opens the window from there, + * the tray and the CLI. + */ +export function createFirstRunSetupAssembly(deps: FirstRunSetupAssemblyDeps) { + const { appState, configService, logger } = deps; + let firstRunSetupMessage: string | null = null; + const resolveWindowsMpvShortcutRuntimePaths = () => + resolveWindowsMpvShortcutPaths({ + appDataDir: deps.getAppPath('appData'), + desktopDir: deps.getAppPath('desktop'), + }); + + const firstRunSetupService = createFirstRunSetupService({ + getDictionaryBackend: () => deps.dictionaryBackend, + getHachidoriHostStatus: async () => { + await deps.ensureYomitanExtensionLoaded(); + return requestHachidoriSharing( + { type: 'hd_sharing_status' }, + deps.getYomitanParserRuntimeDeps(), + logger, + ); + }, + platform: process.platform, + configDir: deps.configDir, + getYomitanDictionaryCount: async () => { + await deps.ensureYomitanExtensionLoaded(); + const dictionaries = await getYomitanDictionaryInfo(deps.getYomitanParserRuntimeDeps(), { + error: (message, ...args) => logger.error(message, ...args), + info: (message, ...args) => logger.info(message, ...args), + }); + const integrity = observeYomitanDictionaryCount(deps.userDataPath, dictionaries.length); + if (!integrity.safe) { + logger.error(`[dictionary:integrity] ${integrity.message}`); + } + return dictionaries.length; + }, + isExternalYomitanConfigured: () => + configService.getConfig().yomitan.externalProfilePath.trim().length > 0, + detectPluginInstalled: () => { + const candidates = detectInstalledFirstRunPluginCandidates({ + platform: process.platform, + homeDir: os.homedir(), + xdgConfigHome: process.env.XDG_CONFIG_HOME, + appDataDir: deps.getAppPath('appData'), + mpvExecutablePath: configService.getConfig().mpv.executablePath, + }); + if (candidates.length > 0) { + return true; + } + const installPaths = resolveDefaultMpvInstallPaths( + process.platform, + os.homedir(), + process.env.XDG_CONFIG_HOME, + ); + return detectInstalledFirstRunPlugin(installPaths); + }, + detectLegacyMpvPluginCandidates: () => + detectInstalledFirstRunPluginCandidates({ + platform: process.platform, + homeDir: os.homedir(), + xdgConfigHome: process.env.XDG_CONFIG_HOME, + appDataDir: deps.getAppPath('appData'), + mpvExecutablePath: configService.getConfig().mpv.executablePath, + }), + removeLegacyMpvPlugins: (candidates) => + removeLegacyMpvPluginCandidates({ + candidates, + trashItem: (candidatePath) => deps.trashItem(candidatePath), + }), + detectWindowsMpvShortcuts: () => { + if (process.platform !== 'win32') { + return { + startMenuInstalled: false, + desktopInstalled: false, + }; + } + return detectWindowsMpvShortcuts(resolveWindowsMpvShortcutRuntimePaths()); + }, + applyWindowsMpvShortcuts: async (preferences) => { + if (process.platform !== 'win32') { + return { + ok: true, + status: 'unknown' as const, + message: '', + }; + } + return applyWindowsMpvShortcuts({ + preferences, + paths: resolveWindowsMpvShortcutRuntimePaths(), + exePath: process.execPath, + writeShortcutLink: (shortcutPath, operation, details) => + deps.writeShortcutLink(shortcutPath, operation, details), + }); + }, + detectCommandLineLauncher: async () => + detectCommandLineLauncher(await deps.createCommandLineLauncherRuntimeOptions()), + installBun: async () => { + const snapshot = await installCommandLineBun( + await deps.createCommandLineLauncherRuntimeOptions(), + ); + return { + ok: snapshot.status === 'ready', + message: + snapshot.message ?? + (snapshot.status === 'ready' + ? 'Bun is ready. Open a new terminal.' + : 'Bun installation failed.'), + }; + }, + installCommandLineLauncher: async () => { + const snapshot = await installCommandLineLauncher( + await deps.createCommandLineLauncherRuntimeOptions(), + ); + const ok = snapshot.status === 'ready' || snapshot.status === 'not_on_path'; + return { + ok, + installPath: snapshot.installPath, + message: + snapshot.message ?? + (ok + ? 'Command-line launcher installed. Open a new terminal.' + : 'Command-line launcher installation failed.'), + }; + }, + onStateChanged: (state) => { + appState.firstRunSetupCompleted = state.status === 'completed'; + deps.onSetupStateChanged(); + }, + }); + + const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({ + getSetupWindow: () => appState.firstRunSetupWindow, + }); + const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ + maybeFocusExistingSetupWindow: maybeFocusExistingFirstRunSetupWindow, + createSetupWindow: createCreateFirstRunSetupWindowHandler({ + createBrowserWindow: (options) => deps.createBrowserWindow(options), + }), + getSetupSnapshot: async () => { + const snapshot = await firstRunSetupService.getSetupStatus(); + const mpvExecutablePath = configService.getConfig().mpv.executablePath; + return { + configReady: snapshot.configReady, + dictionaryCount: snapshot.dictionaryCount, + dictionaryBackend: snapshot.dictionaryBackend, + hachidoriHost: snapshot.hachidoriHost, + canFinish: snapshot.canFinish, + externalYomitanConfigured: snapshot.externalYomitanConfigured, + pluginStatus: snapshot.pluginStatus, + pluginInstallPathSummary: snapshot.pluginInstallPathSummary, + legacyMpvPluginPaths: snapshot.legacyMpvPluginPaths, + mpvExecutablePath, + mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath), + windowsMpvShortcuts: snapshot.windowsMpvShortcuts, + commandLineLauncher: snapshot.commandLineLauncher, + message: firstRunSetupMessage, + }; + }, + buildSetupHtml: (model) => buildFirstRunSetupHtml(model), + parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl), + handleAction: async (submission: FirstRunSetupSubmission) => { + if ( + submission.action === 'link-hachidori-host' || + submission.action === 'unlink-hachidori-host' + ) { + try { + if (deps.dictionaryBackend !== 'hachidori') + throw new Error('Select Hachidori and restart SubMiner before linking a host.'); + await deps.ensureYomitanExtensionLoaded(); + const status = await requestHachidoriSharing( + submission.action === 'link-hachidori-host' + ? { type: 'hd_sharing_client_link', address: submission.address } + : { type: 'hd_sharing_client_unlink' }, + deps.getYomitanParserRuntimeDeps(), + logger, + ); + firstRunSetupMessage = + status.kind === 'local' + ? 'Using dictionaries installed in SubMiner.' + : status.kind === 'connected' + ? `Linked to ${status.name}. Anki mining stays in SubMiner.` + : status.message; + } catch (error) { + firstRunSetupMessage = + error instanceof Error ? error.message : 'Could not update the dictionary host.'; + } + return; + } + if (submission.action === 'remove-legacy-plugin') { + const snapshot = await firstRunSetupService.removeLegacyMpvPlugin(); + firstRunSetupMessage = snapshot.message; + return; + } + if (submission.action === 'configure-mpv-executable-path') { + const mpvExecutablePath = submission.mpvExecutablePath?.trim() ?? ''; + const pathStatus = getConfiguredWindowsMpvPathStatus(mpvExecutablePath); + configService.patchRawConfig({ + mpv: { + executablePath: mpvExecutablePath, + }, + }); + firstRunSetupMessage = + pathStatus === 'invalid' + ? `Saved mpv executable path, but the file was not found: ${mpvExecutablePath}` + : mpvExecutablePath + ? `Saved mpv executable path: ${mpvExecutablePath}` + : 'Cleared mpv executable path. SubMiner will auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.'; + return; + } + if (submission.action === 'configure-windows-mpv-shortcuts') { + const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({ + startMenuEnabled: submission.startMenuEnabled === true, + desktopEnabled: submission.desktopEnabled === true, + }); + firstRunSetupMessage = snapshot.message; + return; + } + if (submission.action === 'install-bun') { + const snapshot = await firstRunSetupService.installBun(); + firstRunSetupMessage = snapshot.message; + return; + } + if (submission.action === 'install-command-line-launcher') { + const snapshot = await firstRunSetupService.installCommandLineLauncher(); + firstRunSetupMessage = snapshot.message; + return; + } + if (submission.action === 'open-yomitan-settings') { + firstRunSetupMessage = deps.openDictionarySettings() + ? `Opened ${deps.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings. Install dictionaries, then refresh status.` + : 'Yomitan settings are unavailable while external read-only profile mode is enabled.'; + return; + } + if (submission.action === 'open-config-settings') { + const opened = deps.openConfigSettingsWindow(); + firstRunSetupMessage = opened + ? 'Opened SubMiner settings.' + : 'SubMiner settings are unavailable.'; + if (opened) { + return { skipRender: true }; + } + return; + } + if (submission.action === 'refresh') { + const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.'); + firstRunSetupMessage = snapshot.message; + return; + } + + const snapshot = await firstRunSetupService.markSetupCompleted(); + if (snapshot.state.status === 'completed') { + firstRunSetupMessage = null; + return { closeWindow: true }; + } + firstRunSetupMessage = + getFirstRunSetupCompletionMessage(snapshot) ?? + 'Finish setup requires the mpv plugin and Yomitan dictionaries.'; + return; + }, + markSetupInProgress: async () => { + firstRunSetupMessage = null; + await firstRunSetupService.markSetupInProgress(); + }, + markSetupCancelled: async () => { + firstRunSetupMessage = null; + await firstRunSetupService.markSetupCancelled(); + }, + isSetupCompleted: () => firstRunSetupService.isSetupCompleted(), + shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode, + shouldQuitWhenClosedCompleted: () => + Boolean(appState.initialArgs && isStandaloneFirstRunSetupCommand(appState.initialArgs)), + quitApp: () => deps.requestAppQuit(), + clearSetupWindow: () => { + appState.firstRunSetupWindow = null; + }, + setSetupWindow: (window) => { + appState.firstRunSetupWindow = window as BrowserWindow; + }, + encodeURIComponent: (value) => encodeURIComponent(value), + logError: (message, error) => logger.error(message, error), + }); + + /** Opens the setup window; without `force` it is a no-op once setup has completed. */ + function openWindow(force = false): void { + if (!force && firstRunSetupService.isSetupCompleted()) { + return; + } + openFirstRunSetupWindowHandler(); + } + + return { service: firstRunSetupService, openWindow }; +} diff --git a/src/main/assemblies/overlay.test.ts b/src/main/assemblies/overlay.test.ts new file mode 100644 index 00000000..911f1fc0 --- /dev/null +++ b/src/main/assemblies/overlay.test.ts @@ -0,0 +1,331 @@ +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 { createAppState } from '../state'; +import { createOverlayAssembly, type OverlayAssemblyDeps } from './overlay'; + +type Call = { name: string; args: unknown[] }; +type OverlayWindowOptions = Parameters[1]; +type Overlay = ReturnType; + +/** Records every dep call by name, in order; each dep still returns an inert value. */ +function createRecorder() { + const calls: Call[] = []; + const spy = + (name: string, impl: (...args: A) => R) => + (...args: A): R => { + calls.push({ name, args }); + return impl(...args); + }; + const names = () => calls.map((call) => call.name); + return { calls, spy, names }; +} + +type Recorder = ReturnType; + +/** Test-controlled state the fakes read from. */ +type FixtureState = { + visible: boolean; + trackedGeometry: WindowGeometry | null; + /** Window returned by window creation; null makes creation throw after recording options. */ + window: BrowserWindow | null; +}; + +const WINDOW_CREATION_STOPPED = 'window creation stopped by test'; +const NO_PLACEMENT: HyprlandPlacementStatus = { + applicable: false, + clientFound: false, + dispatched: false, +}; + +function makeDeps(recorder: Recorder, state: FixtureState) { + const { spy } = recorder; + const noop = () => {}; + const resolved = async () => {}; + const windowOptions: OverlayWindowOptions[] = []; + const deps: OverlayAssemblyDeps = { + appState: createAppState({ + mpvSocketPath: '/tmp/subminer-overlay-test.sock', + texthookerPort: 0, + }), + logger: { + debug: spy('logger.debug', noop), + info: spy('logger.info', noop), + warn: spy('logger.warn', noop), + }, + overlayManager: { + getMainWindow: spy('overlay.getMainWindow', () => null), + setMainWindow: spy('overlay.setMainWindow', noop), + getModalWindow: spy('overlay.getModalWindow', () => null), + setModalWindow: spy('overlay.setModalWindow', noop), + getVisibleOverlayVisible: spy('overlay.getVisibleOverlayVisible', () => state.visible), + setVisibleOverlayVisible: spy('overlay.setVisibleOverlayVisible', (visible: boolean) => { + state.visible = visible; + }), + setOverlayWindowBounds: spy('overlay.setOverlayWindowBounds', () => NO_PLACEMENT), + setModalWindowBounds: spy('overlay.setModalWindowBounds', () => NO_PLACEMENT), + }, + overlayModalInputState: { getModalInputExclusive: spy('modal.getInputExclusive', () => false) }, + autoplayReadyGate: { + markCurrentMediaAutoplayReady: spy('autoplay.markCurrentMediaReady', noop), + flushPendingAutoplayReadySignal: spy('autoplay.flushPending', noop), + }, + screen: { + getCursorScreenPoint: spy('screen.getCursorScreenPoint', () => ({ x: 0, y: 0 })), + getDisplayNearestPoint: spy('screen.getDisplayNearestPoint', (): never => { + throw new Error('display fallback used'); + }), + }, + isDev: false, + createOverlayWindowCore: spy('createOverlayWindowCore', (_kind, options): BrowserWindow => { + windowOptions.push(options); + if (!state.window) throw new Error(WINDOW_CREATION_STOPPED); + return state.window; + }), + getYomitanSession: spy('getYomitanSession', () => null), + getVisibleOverlayInteraction: spy('getVisibleOverlayInteraction', () => ({ + getLinuxOverlayInputShapeActive: spy('interaction.getLinuxInputShapeActive', () => false), + getMacOSVisibleOverlayForegroundProbeActive: spy('interaction.getMacProbe', () => false), + getVisibleOverlayInteractionActive: spy('interaction.getActive', () => false), + getWindowsNativeWindowHandleNumber: spy('interaction.getWindowsHandle', () => 0), + primeLinuxOverlayPointerInteractionAfterFirstMeasurement: spy( + 'interaction.primePointerAfterFirstMeasurement', + noop, + ), + requestLinuxOverlayZOrderFollow: spy('interaction.requestLinuxZOrderFollow', noop), + requestWindowsVisibleOverlayZOrderSync: spy('interaction.requestWindowsZOrderSync', noop), + resetLinuxVisibleOverlayStartupInputPrimer: spy('interaction.resetStartupPrimer', noop), + resetVisibleOverlayInputState: spy('interaction.resetInputState', noop), + restoreVisibleOverlayWindowShapeForShow: spy('interaction.restoreShapeForShow', noop), + scheduleVisibleOverlayBlurRefresh: spy('interaction.scheduleBlurRefresh', noop), + startLinuxVisibleOverlayStartupInputGrace: spy('interaction.startStartupGrace', noop), + })), + getOverlayGeometry: spy('getOverlayGeometry', () => ({ + enforceOverlayLayerOrder: spy('geometry.enforceLayerOrder', noop), + ensureOverlayWindowLevel: spy('geometry.ensureWindowLevel', noop), + getCurrentOverlayGeometry: spy('geometry.getCurrentOverlayGeometry', () => ({ + x: 0, + y: 0, + width: 1920, + height: 1080, + })), + getCurrentTrackedOverlayGeometry: spy( + 'geometry.getCurrentTrackedOverlayGeometry', + () => state.trackedGeometry, + ), + syncPrimaryOverlayWindowLayer: spy('geometry.syncPrimaryLayer', noop), + updateVisibleOverlayBounds: spy('geometry.updateVisibleBounds', () => 'not-applicable'), + })), + getOverlayShortcuts: spy('getOverlayShortcuts', () => ({ + syncOverlayShortcuts: spy('shortcuts.sync', noop), + tryHandleOverlayShortcutLocalFallback: spy('shortcuts.localFallback', () => false), + })), + getOverlayNotifications: spy('getOverlayNotifications', () => ({ + showOverlayLoadingStatusNotification: spy('notifications.showLoading', noop), + dismissOverlayLoadingStatusNotification: spy('notifications.dismissLoading', noop), + flushQueuedOverlayNotifications: spy('notifications.flushQueued', noop), + maybeStartOverlayLoadingOsd: spy('notifications.maybeStartLoadingOsd', noop), + })), + getOverlayVisibilityComposer: spy('getOverlayVisibilityComposer', () => ({ + broadcastRuntimeOptionsChanged: spy('composer.broadcastRuntimeOptions', noop), + setOverlayDebugVisualizationEnabled: spy('composer.setDebugVisualization', noop), + })), + getMediaTimingReviewRuntime: spy('getMediaTimingReviewRuntime', () => ({ + dispose: spy('mediaTimingReview.dispose', resolved), + })), + getSubtitleProcessingController: spy('getSubtitleProcessingController', () => ({ + refreshCurrentSubtitle: spy('subtitle.refreshCurrent', () => true), + })), + getAutoplaySubtitlePriming: spy('getAutoplaySubtitlePriming', () => ({ + cancelVisibleOverlaySubtitleRefreshAfterFirstPaint: spy('priming.cancelFirstPaint', noop), + primeCurrentSubtitleForVisibleOverlay: spy('priming.prime', resolved), + })), + ensureOverlayMpvSubtitlesHidden: spy('ensureOverlayMpvSubtitlesHidden', resolved), + syncOverlayMpvSubtitleSuppression: spy('syncOverlayMpvSubtitleSuppression', noop), + initializeOverlayRuntime: spy('initializeOverlayRuntime', noop), + }; + return { deps, windowOptions }; +} + +/** Builds the assembly, optionally with `process.platform` overridden for the test. */ +function setup(t: TestContext, options: { platform?: NodeJS.Platform; visible?: boolean } = {}) { + if (options.platform) { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: options.platform, configurable: true }); + t.after(() => Object.defineProperty(process, 'platform', { value: originalPlatform })); + } + const recorder = createRecorder(); + const state: FixtureState = { + visible: options.visible ?? false, + trackedGeometry: null, + window: null, + }; + const { deps, windowOptions } = makeDeps(recorder, state); + const overlay = createOverlayAssembly(deps); + return { recorder, state, overlay, windowOptions }; +} + +/** Asserts each name was called, in this relative order. */ +function assertCalledInOrder(names: string[], expected: string[]): void { + let previous = -1; + for (const name of expected) { + const index = names.indexOf(name, previous + 1); + assert.ok(index > previous, `${name} was not called after the previous step`); + previous = index; + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// main.ts builds the assembly before most siblings exist, so construction must stay lazy. +test('createOverlayAssembly does not call its deps while constructing', (t) => { + const { recorder, overlay } = setup(t); + assert.deepEqual(recorder.names(), []); + assert.equal(typeof overlay.setVisibleOverlayVisible, 'function'); +}); + +const hidePaths: Array<{ label: string; hide: (overlay: Overlay) => void; order: string[] }> = [ + { + label: 'setVisibleOverlayVisible(false)', + hide: (overlay) => overlay.setVisibleOverlayVisible(false), + order: [ + 'autoplay.markCurrentMediaReady', + 'priming.cancelFirstPaint', + 'interaction.resetInputState', + ], + }, + { + label: 'toggleVisibleOverlay() while shown', + hide: (overlay) => overlay.toggleVisibleOverlay(), + order: [ + 'autoplay.markCurrentMediaReady', + 'priming.cancelFirstPaint', + 'interaction.resetInputState', + ], + }, + { + label: 'setOverlayVisible(false)', + hide: (overlay) => overlay.setOverlayVisible(false), + order: [ + 'priming.cancelFirstPaint', + 'interaction.resetInputState', + 'autoplay.markCurrentMediaReady', + ], + }, +]; + +for (const { label, hide, order } of hidePaths) { + test(`${label} dismisses the loading OSD, releases autoplay and clears input state`, (t) => { + const { recorder, overlay } = setup(t, { visible: true }); + + hide(overlay); + + const names = recorder.names(); + assertCalledInOrder(names, ['notifications.dismissLoading', ...order]); + assert.ok(!names.includes('priming.prime')); + }); + + test(`${label} cancels a pending Linux fullscreen refresh burst`, async (t) => { + const { recorder, overlay } = setup(t, { platform: 'linux', visible: true }); + overlay.handleMpvFullscreenChange(true); + + hide(overlay); + const afterHide = recorder.calls.length; + await sleep(20); + + // A burst that survived the hide would re-sync the overlay on its 0ms tick. + assert.equal(recorder.calls.length, afterHide); + }); +} + +// Control for the cancellation tests above: the same tick does run when nothing cancels it. +test('an uncancelled Linux fullscreen refresh burst runs on its first tick', async (t) => { + const { recorder, overlay } = setup(t, { platform: 'linux' }); + t.after(() => overlay.clearLinuxFullscreenOverlayRefresh()); + overlay.handleMpvFullscreenChange(true); + const afterSchedule = recorder.calls.length; + + await sleep(20); + + assert.ok(recorder.calls.length > afterSchedule); +}); + +const showPaths: Array<{ label: string; show: (overlay: Overlay) => void }> = [ + { + label: 'setVisibleOverlayVisible(true)', + show: (overlay) => overlay.setVisibleOverlayVisible(true), + }, + { + label: 'toggleVisibleOverlay() while hidden', + show: (overlay) => overlay.toggleVisibleOverlay(), + }, + { label: 'setOverlayVisible(true)', show: (overlay) => overlay.setOverlayVisible(true) }, +]; + +for (const { label, show } of showPaths) { + test(`${label} starts the loading OSD and input grace before priming, keeping autoplay held`, (t) => { + const { recorder, overlay } = setup(t); + + show(overlay); + + const names = recorder.names(); + assertCalledInOrder(names, [ + 'notifications.maybeStartLoadingOsd', + 'interaction.resetStartupPrimer', + 'interaction.startStartupGrace', + 'priming.prime', + ]); + assert.ok(!names.includes('autoplay.markCurrentMediaReady')); + assert.ok(!names.includes('interaction.resetInputState')); + }); +} + +test('visible overlay content-ready flushes notifications and autoplay without tokenizing', (t) => { + const { recorder, overlay, windowOptions } = setup(t, { visible: true }); + assert.throws(() => overlay.createMainWindow(), new RegExp(WINDOW_CREATION_STOPPED)); + const onContentReady = windowOptions[0]?.onWindowContentReady; + assert.ok(onContentReady); + const before = recorder.calls.length; + + onContentReady(); + + const names = recorder.names().slice(before); + assertCalledInOrder(names, [ + 'notifications.dismissLoading', + 'notifications.flushQueued', + 'interaction.primePointerAfterFirstMeasurement', + 'autoplay.flushPending', + ]); + // The first subtitle waits for the first content measurement, not for content-ready. + assert.ok(!names.includes('subtitle.refreshCurrent')); +}); + +for (const trackedGeometry of [null, { x: 10, y: 20, width: 1280, height: 720 }]) { + test(`Linux fullscreen recreation resets input first and ${trackedGeometry ? 'applies tracked mpv bounds' : 'keeps bounds without tracked geometry'}`, (t) => { + const { recorder, state, overlay } = setup(t, { platform: 'linux', visible: true }); + state.trackedGeometry = trackedGeometry; + // An opaque token: the assembly only hands the created window to the overlay manager. + state.window = {} as BrowserWindow; + + overlay.linuxOverlayModeRuntime.sync(true); + + const names = recorder.names(); + assertCalledInOrder(names, [ + 'interaction.resetInputState', + 'createOverlayWindowCore', + 'geometry.getCurrentTrackedOverlayGeometry', + 'ensureOverlayMpvSubtitlesHidden', + ]); + const boundsCalls = recorder.calls.filter( + (call) => call.name === 'overlay.setOverlayWindowBounds', + ); + assert.deepEqual( + boundsCalls.map((call) => call.args[0]), + trackedGeometry ? [trackedGeometry] : [], + ); + // Recreation must never fall back to display bounds before mpv geometry is known. + assert.ok(!names.includes('geometry.getCurrentOverlayGeometry')); + }); +} diff --git a/src/main/assemblies/overlay.ts b/src/main/assemblies/overlay.ts new file mode 100644 index 00000000..2b5e2b8d --- /dev/null +++ b/src/main/assemblies/overlay.ts @@ -0,0 +1,441 @@ +import type { BrowserWindow, Screen, Session } from 'electron'; +import { + type createOverlayWindow as createOverlayWindowCoreFn, + sendMpvCommandRuntime, + setVisibleOverlayVisible as setVisibleOverlayVisibleCore, + type createOverlayManager, +} from '../../core/services'; +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 { createOverlayVisibilityRuntimeService } from '../overlay-visibility-runtime'; +import type { createAutoplayReadyGate } from '../runtime/autoplay-ready-gate'; +import type { composeOverlayVisibilityRuntime } from '../runtime/composers'; +import { createOverlayVisibilityRuntime } from '../runtime/domains/overlay'; +import { + type CancelLinuxMpvFullscreenOverlayRefreshBurst, + clearLinuxMpvFullscreenOverlayRefreshTimeouts, + updateLinuxMpvFullscreenOverlayRefreshBurst, +} from '../runtime/linux-mpv-fullscreen-overlay-refresh'; +import { createLinuxOverlayModeRuntime } from '../runtime/linux-overlay-mode-runtime'; +import { shouldRunLinuxOverlayZOrderKeepAlive } from '../runtime/linux-overlay-zorder-keepalive'; +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'; +import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime'; +import { createOverlayWindowRuntimeHandlers } from '../runtime/overlay-window-runtime-handlers'; +import type { VisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime'; +import type { AppState } from '../state'; +import type { PlaybackAssembly } from './playback'; + +type OverlayManager = ReturnType; + +/** + * What overlay core needs from the rest of `main.ts`. Everything here except the getters and + * callbacks is built before the assembly; siblings built later are reached through getters, and + * construction calls none of them. + */ +export interface OverlayAssemblyDeps { + appState: AppState; + logger: Pick; + overlayManager: Pick< + OverlayManager, + | 'getMainWindow' + | 'setMainWindow' + | 'getModalWindow' + | 'setModalWindow' + | 'getVisibleOverlayVisible' + | 'setVisibleOverlayVisible' + | 'setOverlayWindowBounds' + | 'setModalWindowBounds' + >; + overlayModalInputState: Pick< + ReturnType, + 'getModalInputExclusive' + >; + autoplayReadyGate: Pick< + ReturnType, + 'markCurrentMediaAutoplayReady' | 'flushPendingAutoplayReadySignal' + >; + screen: Pick; + isDev: boolean; + /** Builds an Electron overlay window; injected so the assembly stays free of Electron calls. */ + createOverlayWindowCore: typeof createOverlayWindowCoreFn; + getYomitanSession: () => Session | null; + /** Pointer interaction and geometry stay in `main.ts`: both import Electron at module load. */ + getVisibleOverlayInteraction: () => Pick< + VisibleOverlayInteractionRuntime, + | 'getLinuxOverlayInputShapeActive' + | 'getMacOSVisibleOverlayForegroundProbeActive' + | 'getVisibleOverlayInteractionActive' + | 'getWindowsNativeWindowHandleNumber' + | 'primeLinuxOverlayPointerInteractionAfterFirstMeasurement' + | 'requestLinuxOverlayZOrderFollow' + | 'requestWindowsVisibleOverlayZOrderSync' + | 'resetLinuxVisibleOverlayStartupInputPrimer' + | 'resetVisibleOverlayInputState' + | 'restoreVisibleOverlayWindowShapeForShow' + | 'scheduleVisibleOverlayBlurRefresh' + | 'startLinuxVisibleOverlayStartupInputGrace' + >; + getOverlayGeometry: () => Pick< + ReturnType, + | 'enforceOverlayLayerOrder' + | 'ensureOverlayWindowLevel' + | 'getCurrentOverlayGeometry' + | 'getCurrentTrackedOverlayGeometry' + | 'syncPrimaryOverlayWindowLayer' + | 'updateVisibleOverlayBounds' + >; + getOverlayShortcuts: () => Pick< + ReturnType, + 'syncOverlayShortcuts' | 'tryHandleOverlayShortcutLocalFallback' + >; + getOverlayNotifications: () => Pick< + ReturnType, + | 'showOverlayLoadingStatusNotification' + | 'dismissOverlayLoadingStatusNotification' + | 'flushQueuedOverlayNotifications' + | 'maybeStartOverlayLoadingOsd' + >; + getOverlayVisibilityComposer: () => Pick< + ReturnType, + 'broadcastRuntimeOptionsChanged' | 'setOverlayDebugVisualizationEnabled' + >; + getMediaTimingReviewRuntime: () => Pick< + ReturnType, + 'dispose' + >; + getSubtitleProcessingController: () => Pick< + PlaybackAssembly['subtitleProcessingController'], + 'refreshCurrentSubtitle' + >; + getAutoplaySubtitlePriming: () => Pick< + PlaybackAssembly['autoplaySubtitlePrimingRuntime'], + 'cancelVisibleOverlaySubtitleRefreshAfterFirstPaint' | 'primeCurrentSubtitleForVisibleOverlay' + >; + ensureOverlayMpvSubtitlesHidden: () => Promise; + syncOverlayMpvSubtitleSuppression: () => void; + initializeOverlayRuntime: () => void; +} + +export type OverlayAssembly = ReturnType; + +/** + * Owns overlay core: the visible/modal windows, their visibility, the Linux fullscreen window + * mode and refresh burst, and the show/hide paths. `main.ts` builds it once and reaches the + * runtimes through the result; overlay bootstrap, pointer interaction and geometry stay there. + */ +export function createOverlayAssembly(deps: OverlayAssemblyDeps) { + const { appState, logger, overlayManager, overlayModalInputState, autoplayReadyGate } = deps; + + let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null = + null; + const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({ + isEnabled: shouldRunLinuxOverlayZOrderKeepAlive, + isVisible: () => overlayManager.getVisibleOverlayVisible(), + getWindow: () => overlayManager.getMainWindow(), + clearWindow: () => overlayManager.setMainWindow(null), + createWindow: () => { + deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState(); + createMainWindow(); + }, + refreshWindow: () => { + const trackedGeometry = deps.getOverlayGeometry().getCurrentTrackedOverlayGeometry(); + if (trackedGeometry) overlayManager.setOverlayWindowBounds(trackedGeometry); + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + void deps.ensureOverlayMpvSubtitlesHidden(); + if (appState.currentSubText.trim()) { + deps.getSubtitleProcessingController().refreshCurrentSubtitle(appState.currentSubText); + } + }, + now: Date.now, + logDebug: (message) => logger.debug(message), + }); + function cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(): void { + cancelLinuxMpvFullscreenOverlayRefreshBurst?.(); + cancelLinuxMpvFullscreenOverlayRefreshBurst = null; + } + + const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService({ + getMainWindow: () => overlayManager.getMainWindow(), + getModalActive: () => overlayModalInputState.getModalInputExclusive(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getForceMousePassthrough: () => appState.statsOverlayVisible, + getNonNativeInputRegionActive: () => + process.platform === 'linux' && + deps.getVisibleOverlayInteraction().getLinuxOverlayInputShapeActive(), + getSuspendVisibleOverlay: () => appState.statsOverlayVisible, + getOverlayInteractionActive: () => + deps.getVisibleOverlayInteraction().getVisibleOverlayInteractionActive(), + getWindowTracker: () => appState.windowTracker, + getMacOSForegroundProbeActive: () => + deps.getVisibleOverlayInteraction().getMacOSVisibleOverlayForegroundProbeActive(), + getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, + setTrackerNotReadyWarningShown: (shown: boolean) => { + appState.trackerNotReadyWarningShown = shown; + }, + updateVisibleOverlayBounds: (geometry: WindowGeometry) => + deps.getOverlayGeometry().updateVisibleOverlayBounds(geometry), + ensureOverlayWindowLevel: (window) => { + deps.getOverlayGeometry().ensureOverlayWindowLevel(window); + }, + syncWindowsOverlayToMpvZOrder: (_window) => { + deps.getVisibleOverlayInteraction().requestWindowsVisibleOverlayZOrderSync(); + }, + syncPrimaryOverlayWindowLayer: (layer) => { + deps.getOverlayGeometry().syncPrimaryOverlayWindowLayer(layer); + }, + enforceOverlayLayerOrder: () => { + deps.getOverlayGeometry().enforceOverlayLayerOrder(); + }, + syncOverlayShortcuts: () => { + deps.getOverlayShortcuts().syncOverlayShortcuts(); + }, + isMacOSPlatform: () => process.platform === 'darwin', + isWindowsPlatform: () => process.platform === 'win32', + showOverlayLoadingOsd: (_message: string) => { + deps.getOverlayNotifications().showOverlayLoadingStatusNotification(); + }, + dismissOverlayLoadingOsd: () => { + deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification(); + }, + hideNonNativeOverlayWhenTargetUnfocused: () => + shouldRunLinuxOverlayZOrderKeepAlive() && + linuxOverlayModeRuntime.mode === 'fullscreen-override', + resolveFallbackBounds: () => { + const cursorPoint = deps.screen.getCursorScreenPoint(); + const display = deps.screen.getDisplayNearestPoint(cursorPoint); + const fallbackBounds = display.workArea; + return { + x: fallbackBounds.x, + y: fallbackBounds.y, + width: fallbackBounds.width, + height: fallbackBounds.height, + }; + }, + }); + + function createModalWindow(): BrowserWindow { + const existingWindow = overlayManager.getModalWindow(); + if (existingWindow && !existingWindow.isDestroyed()) { + return existingWindow; + } + const window = createModalWindowHandler(); + overlayManager.setModalWindowBounds(deps.getOverlayGeometry().getCurrentOverlayGeometry()); + return window; + } + + function createMainWindow(): BrowserWindow { + const window = createMainWindowHandler(); + if (process.platform === 'win32') { + const overlayHwnd = deps + .getVisibleOverlayInteraction() + .getWindowsNativeWindowHandleNumber(window); + if (!ensureWindowsOverlayTransparency(overlayHwnd)) { + logger.warn('Failed to eagerly extend Windows overlay transparency via koffi'); + } + } + return window; + } + + const { + setVisibleOverlayVisible: setVisibleOverlayVisibleHandler, + toggleVisibleOverlay: toggleVisibleOverlayHandler, + setOverlayVisible: setOverlayVisibleHandler, + } = createOverlayVisibilityRuntime({ + setVisibleOverlayVisibleDeps: { + setVisibleOverlayVisibleCore, + setVisibleOverlayVisibleState: (nextVisible) => { + overlayManager.setVisibleOverlayVisible(nextVisible); + }, + updateVisibleOverlayVisibility: () => + overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + }, + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + }); + + const { createMainWindow: createMainWindowHandler, createModalWindow: createModalWindowHandler } = + createOverlayWindowRuntimeHandlers({ + createOverlayWindowDeps: { + createOverlayWindowCore: (kind, options) => deps.createOverlayWindowCore(kind, options), + isDev: deps.isDev, + ensureOverlayWindowLevel: (window) => + deps.getOverlayGeometry().ensureOverlayWindowLevel(window), + onRuntimeOptionsChanged: () => + deps.getOverlayVisibilityComposer().broadcastRuntimeOptionsChanged(), + setOverlayDebugVisualizationEnabled: (enabled) => + deps.getOverlayVisibilityComposer().setOverlayDebugVisualizationEnabled(enabled), + isOverlayVisible: (windowKind) => + windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false, + getYomitanSession: () => deps.getYomitanSession(), + tryHandleOverlayShortcutLocalFallback: (input) => + deps.getOverlayShortcuts().tryHandleOverlayShortcutLocalFallback(input), + forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), + getLinuxX11FullscreenOverlay: () => + shouldRunLinuxOverlayZOrderKeepAlive() && + linuxOverlayModeRuntime.fullscreen && + linuxOverlayModeRuntime.mode === 'fullscreen-override', + onVisibleWindowBlurred: () => + deps.getVisibleOverlayInteraction().scheduleVisibleOverlayBlurRefresh(), + onVisibleWindowFocused: () => + deps.getVisibleOverlayInteraction().requestLinuxOverlayZOrderFollow(), + onWindowDidFinishLoad: () => { + deps.getOverlayNotifications().flushQueuedOverlayNotifications(); + }, + onWindowContentReady: () => { + deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification(); + deps.getOverlayNotifications().flushQueuedOverlayNotifications(); + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + deps + .getVisibleOverlayInteraction() + .primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); + autoplayReadyGate.flushPendingAutoplayReadySignal(); + }, + onWindowClosed: (windowKind, window) => { + if (windowKind === 'visible') { + if (overlayManager.getMainWindow() !== window) { + return; + } + cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); + overlayManager.setMainWindow(null); + } else { + if (overlayManager.getModalWindow() !== window) { + return; + } + void deps.getMediaTimingReviewRuntime().dispose(); + overlayManager.setModalWindow(null); + } + }, + }, + getMainWindow: () => overlayManager.getMainWindow(), + isWindowDestroyed: (window) => window.isDestroyed(), + setMainWindow: (window) => overlayManager.setMainWindow(window), + setModalWindow: (window) => overlayManager.setModalWindow(window), + }); + + function ensureOverlayWindowsReadyForVisibilityActions(): void { + if (!appState.overlayRuntimeInitialized) { + deps.initializeOverlayRuntime(); + return; + } + + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + createMainWindow(); + } + } + + function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void { + sendMpvCommandRuntime(appState.mpvClient, [ + 'script-message', + visible ? 'subminer-visible-overlay-shown' : 'subminer-visible-overlay-hidden', + ]); + } + + function setVisibleOverlayVisible(visible: boolean): void { + ensureOverlayWindowsReadyForVisibilityActions(); + if (!visible) { + deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification(); + autoplayReadyGate.markCurrentMediaAutoplayReady(); + deps.getAutoplaySubtitlePriming().cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); + cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); + deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState(); + } + if (visible) { + deps.getOverlayNotifications().maybeStartOverlayLoadingOsd(); + deps.getVisibleOverlayInteraction().resetLinuxVisibleOverlayStartupInputPrimer(); + deps.getVisibleOverlayInteraction().startLinuxVisibleOverlayStartupInputGrace(); + deps.getVisibleOverlayInteraction().restoreVisibleOverlayWindowShapeForShow(); + void deps.ensureOverlayMpvSubtitlesHidden(); + void deps.getAutoplaySubtitlePriming().primeCurrentSubtitleForVisibleOverlay(); + } + setVisibleOverlayVisibleHandler(visible); + notifyMpvPluginVisibleOverlayVisibility(visible); + deps.syncOverlayMpvSubtitleSuppression(); + } + + function toggleVisibleOverlay(): void { + ensureOverlayWindowsReadyForVisibilityActions(); + const nextVisible = !overlayManager.getVisibleOverlayVisible(); + if (!nextVisible) { + deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification(); + autoplayReadyGate.markCurrentMediaAutoplayReady(); + deps.getAutoplaySubtitlePriming().cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); + cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); + deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState(); + } else { + deps.getOverlayNotifications().maybeStartOverlayLoadingOsd(); + deps.getVisibleOverlayInteraction().resetLinuxVisibleOverlayStartupInputPrimer(); + deps.getVisibleOverlayInteraction().startLinuxVisibleOverlayStartupInputGrace(); + deps.getVisibleOverlayInteraction().restoreVisibleOverlayWindowShapeForShow(); + void deps.ensureOverlayMpvSubtitlesHidden(); + void deps.getAutoplaySubtitlePriming().primeCurrentSubtitleForVisibleOverlay(); + } + toggleVisibleOverlayHandler(); + notifyMpvPluginVisibleOverlayVisibility(nextVisible); + deps.syncOverlayMpvSubtitleSuppression(); + } + function setOverlayVisible(visible: boolean): void { + if (!visible) { + deps.getOverlayNotifications().dismissOverlayLoadingStatusNotification(); + deps.getAutoplaySubtitlePriming().cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); + deps.getVisibleOverlayInteraction().resetVisibleOverlayInputState(); + autoplayReadyGate.markCurrentMediaAutoplayReady(); + cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); + } + if (visible) { + deps.getOverlayNotifications().maybeStartOverlayLoadingOsd(); + deps.getVisibleOverlayInteraction().resetLinuxVisibleOverlayStartupInputPrimer(); + deps.getVisibleOverlayInteraction().startLinuxVisibleOverlayStartupInputGrace(); + deps.getVisibleOverlayInteraction().restoreVisibleOverlayWindowShapeForShow(); + void deps.ensureOverlayMpvSubtitlesHidden(); + void deps.getAutoplaySubtitlePriming().primeCurrentSubtitleForVisibleOverlay(); + } + setOverlayVisibleHandler(visible); + notifyMpvPluginVisibleOverlayVisibility(visible); + deps.syncOverlayMpvSubtitleSuppression(); + } + + function handleMpvFullscreenChange(fullscreen: boolean): void { + cancelLinuxMpvFullscreenOverlayRefreshBurst = updateLinuxMpvFullscreenOverlayRefreshBurst( + fullscreen, + { + overlayManager: { + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + }, + overlayVisibilityRuntime, + syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) => + linuxOverlayModeRuntime.sync(nextFullscreen), + getOverlayInteractionActive: () => + deps.getVisibleOverlayInteraction().getVisibleOverlayInteractionActive() || + deps.getVisibleOverlayInteraction().getLinuxOverlayInputShapeActive(), + ensureOverlayWindowLevel: (window) => + deps.getOverlayGeometry().ensureOverlayWindowLevel(window), + }, + cancelLinuxMpvFullscreenOverlayRefreshBurst, + ); + } + + /** Quit cleanup: drops any pending Linux fullscreen transition and refresh burst. */ + function clearLinuxFullscreenOverlayRefresh(): void { + linuxOverlayModeRuntime.cancelPendingTransition(); + cancelLinuxMpvFullscreenOverlayRefreshBurst = null; + clearLinuxMpvFullscreenOverlayRefreshTimeouts(); + } + + return { + linuxOverlayModeRuntime, + overlayVisibilityRuntime, + createMainWindow, + createModalWindow, + ensureOverlayWindowsReadyForVisibilityActions, + setVisibleOverlayVisible, + toggleVisibleOverlay, + setOverlayVisible, + handleMpvFullscreenChange, + clearLinuxFullscreenOverlayRefresh, + }; +} diff --git a/src/main/assemblies/playback.test.ts b/src/main/assemblies/playback.test.ts new file mode 100644 index 00000000..62bae1c5 --- /dev/null +++ b/src/main/assemblies/playback.test.ts @@ -0,0 +1,395 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DEFAULT_CONFIG } from '../../config/definitions'; +import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch'; +import type { SubtitleData } from '../../types'; +import { createAppState } from '../state'; +import { createPlaybackAssembly, type PlaybackAssemblyDeps } from './playback'; + +type Call = { name: string; args: unknown[] }; + +/** Records every dep call by name, in order; each dep still returns an inert value. */ +function createRecorder() { + const calls: Call[] = []; + const spy = + (name: string, impl: (...args: A) => R) => + (...args: A): R => { + calls.push({ name, args }); + return impl(...args); + }; + const names = () => calls.map((call) => call.name); + return { calls, spy, names }; +} + +type Recorder = ReturnType; + +function createFakePrefetchService(recorder: Recorder): SubtitlePrefetchService { + const { spy } = recorder; + return { + start: spy('prefetch.start', () => {}), + stop: spy('prefetch.stop', () => {}), + onSeek: spy('prefetch.onSeek', () => {}), + pause: spy('prefetch.pause', () => {}), + resume: spy('prefetch.resume', () => {}), + }; +} + +function makeDeps(recorder: Recorder): PlaybackAssemblyDeps { + const { spy } = recorder; + const noop = () => {}; + const resolved = async () => {}; + return { + appState: createAppState({ + mpvSocketPath: '/tmp/subminer-playback-test.sock', + texthookerPort: 0, + }), + configService: { getConfig: spy('getConfig', () => DEFAULT_CONFIG) }, + logger: { + debug: spy('logger.debug', noop), + info: spy('logger.info', noop), + warn: spy('logger.warn', noop), + error: spy('logger.error', noop), + }, + overlayManager: { + broadcastToOverlayWindows: spy('overlay.broadcast', noop), + getVisibleOverlayVisible: spy('overlay.getVisibleOverlayVisible', () => false), + }, + subtitleWsService: { broadcast: spy('subtitleWs.broadcast', noop) }, + annotationSubtitleWsService: { broadcast: spy('annotationWs.broadcast', noop) }, + autoplayReadyGate: { + maybeSignalPluginAutoplayReady: spy('autoplay.maybeSignal', noop), + flushPendingAutoplayReadySignal: spy('autoplay.flushPending', noop), + invalidatePendingAutoplayReadyFallbacks: spy('autoplay.invalidateFallbacks', noop), + }, + currentMediaTokenizationGate: { + updateCurrentMediaPath: spy('tokenizationGate.updateCurrentMediaPath', noop), + markReady: spy('tokenizationGate.markReady', noop), + }, + startupOsdSequencer: { + reset: spy('startupOsd.reset', noop), + markTokenizationReady: spy('startupOsd.markTokenizationReady', noop), + showTokenizationLoading: spy('startupOsd.showTokenizationLoading', noop), + showAnnotationLoading: spy('startupOsd.showAnnotationLoading', noop), + markAnnotationLoadingComplete: spy('startupOsd.markAnnotationLoadingComplete', noop), + }, + managedLocalSubtitleSelectionRuntime: { + handleMediaPathChange: spy('localSubtitles.handleMediaPathChange', noop), + handleSubtitleTrackListChange: spy('localSubtitles.handleSubtitleTrackListChange', noop), + }, + mediaTimingReviewRuntime: { + deferPlaybackResume: spy('timingReview.deferPlaybackResume', () => false), + cancelPlaybackResume: spy('timingReview.cancelPlaybackResume', noop), + }, + discordPresenceRuntime: { publishDiscordPresence: spy('discord.publish', noop) }, + immersionMediaRuntime: { syncFromCurrentMediaState: spy('immersion.sync', noop) }, + mediaRuntime: { + updateCurrentMediaPath: spy('media.updateCurrentMediaPath', noop), + updateCurrentMediaTitle: spy('media.updateCurrentMediaTitle', noop), + }, + overlayNotificationsRuntime: { + showConfiguredStatusNotification: spy('notifications.showStatus', noop), + getConfiguredStatusNotificationType: spy('notifications.getType', () => 'osd' as const), + }, + youtube: { + isPlaybackActiveNow: spy('youtube.isPlaybackActiveNow', () => false), + playback: { getQuitOnDisconnectArmed: spy('youtube.getQuitOnDisconnectArmed', () => false) }, + primarySubtitleNotification: { + handleMediaPathChange: spy('youtube.notification.handleMediaPathChange', noop), + handleSubtitleTrackChange: spy('youtube.notification.handleSubtitleTrackChange', noop), + handleSubtitleTrackListChange: spy('youtube.notification.handleTrackListChange', noop), + }, + mediaCachePlayback: { handleMediaPathChange: spy('youtube.mediaCache', resolved) }, + browserPlayback: { handleMediaPathChange: spy('youtube.browser', noop) }, + flow: { handleMediaPathChange: spy('youtube.flow', noop) }, + }, + jellyfin: { + getQuitOnDisconnectArmed: spy('jellyfin.getQuitOnDisconnectArmed', () => false), + reportJellyfinRemoteStopped: spy('jellyfin.reportStopped', resolved), + reportJellyfinRemoteProgress: spy('jellyfin.reportProgress', resolved), + getActivePlayback: spy('jellyfin.getActivePlayback', () => null), + startJellyfinRemoteSession: spy('jellyfin.startRemoteSession', resolved), + }, + anilist: { + maybeRunAnilistPostWatchUpdate: spy('anilist.postWatch', resolved), + recordAnilistMediaDuration: spy('anilist.recordDuration', noop), + getCurrentAnilistMediaKey: spy('anilist.getMediaKey', () => null), + resetAnilistMediaTracking: spy('anilist.resetTracking', noop), + maybeProbeAnilistDuration: spy('anilist.probeDuration', async () => null), + ensureAnilistMediaGuess: spy('anilist.ensureGuess', async () => null), + resetAnilistMediaGuessState: spy('anilist.resetGuess', noop), + }, + characterDictionary: { + autoSync: { + scheduleSync: spy('characterDictionary.scheduleSync', noop), + getCurrentMediaId: spy('characterDictionary.getCurrentMediaId', () => null), + }, + imageLookup: { get: spy('characterDictionary.imageLookup', () => null) }, + nameCandidates: { get: spy('characterDictionary.nameCandidates', () => null) }, + }, + dictionaries: { + jlpt: { ensureJlptDictionaryLookup: spy('dictionaries.jlpt', resolved) }, + frequency: { ensureFrequencyDictionaryLookup: spy('dictionaries.frequency', resolved) }, + }, + getSubtitleGenerationRuntime: spy('getSubtitleGenerationRuntime', () => ({ + handleMediaPathChange: noop, + })), + isCharacterDictionaryEnabled: spy('isCharacterDictionaryEnabled', () => false), + hasTray: spy('hasTray', () => false), + loadSubtitleSourceText: spy('loadSubtitleSourceText', async () => ''), + createSubtitlePrefetchService: spy('createSubtitlePrefetchService', () => + createFakePrefetchService(recorder), + ), + requestAppQuit: spy('requestAppQuit', noop), + maybeStartOverlayLoadingOsd: spy('maybeStartOverlayLoadingOsd', noop), + flushQueuedMpvOsdNotifications: spy('flushQueuedMpvOsdNotifications', noop), + 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), + shouldInitializeMecabForAnnotations: spy('shouldInitializeMecabForAnnotations', () => false), + // Every annotation off, so tokenization warmups need no MeCab or dictionaries. + getRuntimeBooleanOption: spy('getRuntimeBooleanOption', () => false), + onFullscreenChange: spy('onFullscreenChange', noop), + }; +} + +/** Binds the assembly's mpv event handlers to a fake client; returns an `emit` driver. */ +function bindMpvEvents(playback: ReturnType) { + const handlers = new Map void>(); + playback.bindMpvClientEventHandlers({ + on: (event, handler) => { + handlers.set(event, handler as (payload: unknown) => void); + }, + }); + return (event: string, payload: unknown): void => { + const handler = handlers.get(event); + assert.ok(handler, `no handler bound for ${event}`); + handler(payload); + }; +} + +function setup(overrides: Partial = {}) { + const recorder = createRecorder(); + const deps = { ...makeDeps(recorder), ...overrides }; + const playback = createPlaybackAssembly(deps); + const emit = bindMpvEvents(playback); + return { recorder, deps, appState: deps.appState, playback, emit }; +} + +/** Sets up with a fake prefetch service, installed by a real prefetch init over one SRT cue. */ +async function setupWithFakePrefetch() { + const ctx = setup({ + loadSubtitleSourceText: async () => '1\n00:00:01,000 --> 00:00:02,000\n一行目\n', + }); + await ctx.playback.subtitlePrefetchRuntime.initSubtitlePrefetch('/subs/episode.srt', 0); + assert.ok(ctx.playback.getSubtitlePrefetchService()); + ctx.recorder.calls.length = 0; + return ctx; +} + +const flushAsync = async (): Promise => { + for (let i = 0; i < 5; i += 1) await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +// main.ts builds the assembly before some siblings exist, so construction must stay lazy. +test('createPlaybackAssembly does not call its deps while constructing', () => { + const recorder = createRecorder(); + createPlaybackAssembly(makeDeps(recorder)); + assert.deepEqual(recorder.names(), []); +}); + +test('mpv connection starts the loading OSD and flushes queued OSD notifications before session work', () => { + const { recorder, emit } = setup(); + + emit('connection-change', { connected: true }); + + const names = recorder.names(); + const osdIndex = names.indexOf('maybeStartOverlayLoadingOsd'); + assert.ok(osdIndex >= 0); + assert.deepEqual(recorder.calls[osdIndex]?.args, []); + assert.ok(osdIndex < names.indexOf('flushQueuedMpvOsdNotifications')); + assert.ok( + names.indexOf('flushQueuedMpvOsdNotifications') < names.indexOf('refreshMpvSessionBindings'), + ); +}); + +test('media path change starts the loading OSD, then clears rendered subtitle state before broadcasting', (t) => { + const { recorder, deps, appState, playback, emit } = setup(); + t.after(() => playback.subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh()); + appState.currentMediaPath = '/videos/a.mkv'; + appState.currentSubText = 'old line'; + appState.currentSubtitleData = { text: 'old line', tokens: null }; + appState.activeParsedSubtitleCues = [{ startTime: 1, endTime: 2, text: 'old line' }]; + appState.activeParsedSubtitleSource = '/subs/a.srt'; + appState.activeParsedSubtitleMediaPath = '/videos/a.mkv'; + emit('time-pos-change', { time: 42 }); + assert.equal(playback.getLastObservedTimePos(), 42); + let subtitleDataAtBroadcast: SubtitleData | null | undefined; + deps.overlayManager.broadcastToOverlayWindows = (channel) => { + if (channel === 'subtitle:set') subtitleDataAtBroadcast = appState.currentSubtitleData; + }; + recorder.calls.length = 0; + + emit('media-path-change', { path: '/videos/b.mkv' }); + + assert.deepEqual(recorder.calls[0], { + name: 'maybeStartOverlayLoadingOsd', + args: ['/videos/b.mkv'], + }); + assert.equal(subtitleDataAtBroadcast, null); + assert.equal(appState.currentSubText, ''); + assert.equal(appState.currentSubtitleData, null); + assert.deepEqual(appState.activeParsedSubtitleCues, []); + assert.equal(appState.activeParsedSubtitleSource, null); + assert.equal(appState.activeParsedSubtitleMediaPath, null); + assert.equal(playback.getLastObservedTimePos(), 0); + const resetPayload = { text: '', tokens: null }; + const wsCalls = recorder.calls.filter((call) => call.name.endsWith('Ws.broadcast')); + assert.deepEqual( + wsCalls.map((call) => [call.name, call.args[0]]), + [ + ['subtitleWs.broadcast', resetPayload], + ['annotationWs.broadcast', resetPayload], + ], + ); +}); + +test('media path change to the same YouTube video keeps its parsed cues', (t) => { + const { appState, playback, emit } = setup(); + t.after(() => playback.subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh()); + const cues = [{ startTime: 1, endTime: 2, text: '字幕' }]; + appState.currentMediaPath = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; + appState.currentSubText = '字幕'; + appState.activeParsedSubtitleCues = cues; + appState.activeParsedSubtitleSource = 'youtube:ja'; + appState.activeParsedSubtitleMediaPath = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; + + emit('media-path-change', { path: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30' }); + + assert.equal(appState.currentSubText, ''); + assert.equal(appState.activeParsedSubtitleCues, cues); + assert.equal(appState.activeParsedSubtitleSource, 'youtube:ja'); + assert.equal( + appState.activeParsedSubtitleMediaPath, + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ); +}); + +test('same media path update keeps autoplay fallbacks; a new path invalidates them first', (t) => { + const { recorder, appState, playback, emit } = setup(); + t.after(() => playback.subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh()); + appState.currentMediaPath = '/videos/a.mkv'; + + emit('media-path-change', { path: '/videos/a.mkv' }); + assert.ok(!recorder.names().includes('autoplay.invalidateFallbacks')); + assert.ok(recorder.names().includes('tokenizationGate.updateCurrentMediaPath')); + + recorder.calls.length = 0; + emit('media-path-change', { path: '/videos/b.mkv' }); + const names = recorder.names(); + assert.ok(names.includes('autoplay.invalidateFallbacks')); + assert.ok( + names.indexOf('autoplay.invalidateFallbacks') < + names.indexOf('tokenizationGate.updateCurrentMediaPath'), + ); +}); + +test('subtitle change pauses prefetch without reseeking it, and resumes on a repeated line', async () => { + const ctx = await setupWithFakePrefetch(); + const prefetchCalls = () => ctx.recorder.names().filter((name) => name.startsWith('prefetch.')); + + // The controller already holds the empty line, so nothing will settle to resume prefetch. + ctx.emit('subtitle-change', { text: '' }); + assert.deepEqual(prefetchCalls(), ['prefetch.pause', 'prefetch.resume']); + + ctx.recorder.calls.length = 0; + ctx.emit('subtitle-change', { text: '新しい行' }); + // A new line is still tokenizing, so its settle (not this handler) resumes prefetch. + assert.deepEqual(prefetchCalls(), ['prefetch.pause']); + await flushAsync(); +}); + +test('annotation refresh invalidates cached tokenizations and reseeks prefetch', async () => { + const ctx = await setupWithFakePrefetch(); + const generation = ctx.playback.subtitleProcessingController.getCacheGeneration(); + + ctx.playback.refreshCurrentSubtitleAnnotations(); + + assert.equal(ctx.playback.subtitleProcessingController.getCacheGeneration(), generation + 1); + // No current line: nothing will settle, so the refresh resumes prefetch itself. + assert.deepEqual( + ctx.recorder.calls.filter((call) => call.name.startsWith('prefetch.')), + [ + { name: 'prefetch.onSeek', args: [0] }, + { name: 'prefetch.resume', args: [] }, + ], + ); + + ctx.recorder.calls.length = 0; + ctx.appState.currentSubText = '現在の行'; + ctx.playback.refreshCurrentSubtitleAnnotations(); + assert.deepEqual( + ctx.recorder.names().filter((name) => name.startsWith('prefetch.')), + ['prefetch.pause', 'prefetch.onSeek'], + ); + await flushAsync(); +}); + +test('annotation upgrade of the shown line skips the duplicate plain websocket event', () => { + const { recorder, appState, playback, emit } = setup(); + // Emitted payloads always carry their timing, and an upgrade must match it. + appState.currentSubtitleData = { text: '同じ行', tokens: null, startTime: null, endTime: null }; + playback.subtitleProcessingController.preCacheTokenization('同じ行', { + text: '同じ行', + tokens: [], + }); + + emit('subtitle-change', { text: '同じ行' }); + + const names = recorder.names(); + assert.ok(!names.includes('subtitleWs.broadcast')); + assert.equal(names.filter((name) => name === 'annotationWs.broadcast').length, 1); + assert.equal( + recorder.calls.filter( + (call) => call.name === 'overlay.broadcast' && call.args[0] === 'subtitle:set', + ).length, + 1, + ); + assert.deepEqual(appState.currentSubtitleData?.tokens, []); +}); + +test('warm tokenization releases autoplay with a warm signal before the first subtitle', async (t) => { + const { recorder, appState, playback, emit } = setup(); + t.after(() => playback.subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh()); + appState.currentMediaPath = '/videos/a.mkv'; + appState.currentSubText = ''; + + emit('media-path-change', { path: '/videos/a.mkv' }); + await flushAsync(); + + const signals = recorder.calls.filter((call) => call.name === 'autoplay.maybeSignal'); + assert.deepEqual(signals.at(-1)?.args, [ + { text: '__warm__', tokens: null }, + { forceWhilePaused: true }, + ]); + const names = recorder.names(); + assert.ok(names.lastIndexOf('autoplay.flushPending') < names.lastIndexOf('autoplay.maybeSignal')); +}); + +test('warm tokenization releases autoplay with the current subtitle when it is shown', async (t) => { + const { recorder, appState, playback, emit } = setup(); + t.after(() => playback.subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh()); + const current: SubtitleData = { text: '最初の行', tokens: null }; + appState.currentMediaPath = '/videos/a.mkv'; + appState.currentSubText = '最初の行'; + appState.currentSubtitleData = current; + + emit('media-path-change', { path: '/videos/a.mkv' }); + await flushAsync(); + + const signals = recorder.calls.filter((call) => call.name === 'autoplay.maybeSignal'); + assert.deepEqual(signals.at(-1)?.args, [current, { forceWhilePaused: true }]); +}); diff --git a/src/main/assemblies/playback.ts b/src/main/assemblies/playback.ts new file mode 100644 index 00000000..77380471 --- /dev/null +++ b/src/main/assemblies/playback.ts @@ -0,0 +1,981 @@ +import { + MpvIpcClient, + applyMpvSubtitleRenderMetricsPatch, + createTokenizerDepsRuntime, + isSubtitleAnnotationUpgrade, + resolveSanitizedSubtitleSeekCommand, + sendMpvCommandRuntime, + toSubtitleClock, + tokenizeSubtitle as tokenizeSubtitleCore, +} from '../../core/services'; +import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser'; +import { createAutoplaySubtitlePrimingRuntime } from '../runtime/autoplay-subtitle-priming-runtime'; +import { createAutoplayTokenizationWarmRelease } from '../runtime/autoplay-tokenization-warm-release'; +import { composeMpvRuntimeHandlers } from '../runtime/composers'; +import { createCachedInternalSubtitleTrackExtractor } from '../runtime/internal-subtitle-extraction'; +import { + markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState, + shouldAutoLoadSecondarySubTrackForJellyfinPlayback, +} from '../runtime/jellyfin-remote-playback'; +import { buildSubtitleTrackDiagnostics } from '../runtime/mpv-track-diagnostics'; +import { createRemoteMediaPathDetector } from '../runtime/network-media-path'; +import { resolveCanonicalPrimarySubtitle } from '../runtime/primary-subtitle-text'; +import { createSecondarySubtitleTrackController } from '../runtime/secondary-subtitle-track'; +import { shouldQuitOnMpvShutdownForTrayState } from '../runtime/startup-tray-policy'; +import { createSubtitlePrefetchInitController } from '../runtime/subtitle-prefetch-init'; +import { + createRefreshSubtitlePrefetchFromActiveTrackHandler, + createResolveActiveSubtitleSidebarSourceHandler, +} from '../runtime/subtitle-prefetch-runtime'; +import { createPrefetchAwareSubtitleProcessingController } from '../runtime/subtitle-processing-runtime'; +import { isSameYoutubeMediaPath, isYoutubeMediaPath } from '../runtime/youtube-playback'; +import { MecabTokenizer } from '../../mecab-tokenizer'; +import type { + MpvSubtitleRenderMetrics, + SubtitleCue, + SubtitleData, + SubtitleSidebarPlaybackState, +} from '../../types'; +import type { BrowserWindow } from 'electron'; +import type { ConfigService } from '../../config/service'; +import type { createOverlayManager, SubtitleWebSocket } from '../../core/services'; +import type { + SubtitlePrefetchService, + SubtitlePrefetchServiceDeps, +} from '../../core/services/subtitle-prefetch'; +import type { Logger } from '../../logger'; +import type { createMediaRuntimeService } from '../media-runtime'; +import type { createAutoplayReadyGate } from '../runtime/autoplay-ready-gate'; +import type { createCurrentMediaTokenizationGate } from '../runtime/current-media-tokenization-gate'; +import type { createDiscordPresenceRuntime } from '../runtime/discord-presence-runtime'; +import type { createImmersionMediaRuntime } from '../runtime/domains/startup'; +import type { loadSubtitleSourceText } from '../runtime/internal-subtitle-extraction'; +import type { createManagedLocalSubtitleSelectionRuntime } from '../runtime/local-subtitle-selection'; +import type { createMediaTimingReviewRuntime } from '../runtime/media-timing-review'; +import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime'; +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 { 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 { YoutubeAssembly } from './youtube'; + +type SubtitleWsBroadcaster = Pick; +type OverlayNotificationsRuntime = ReturnType; +type AnnotationRuntimeOptionId = Extract< + RuntimeOptionId, + | 'subtitle.annotation.knownWords.highlightEnabled' + | 'subtitle.annotation.nPlusOne' + | 'subtitle.annotation.jlpt' + | 'subtitle.annotation.frequency' +>; + +/** + * What the playback assembly needs from the rest of `main.ts`. Everything here except the + * getters is built before the assembly; siblings built later are reached through getters, and + * construction calls none of them. + */ +export interface PlaybackAssemblyDeps { + appState: AppState; + configService: Pick; + logger: Pick; + overlayManager: Pick< + ReturnType, + 'broadcastToOverlayWindows' | 'getVisibleOverlayVisible' + >; + subtitleWsService: SubtitleWsBroadcaster; + annotationSubtitleWsService: SubtitleWsBroadcaster; + autoplayReadyGate: Pick< + ReturnType, + | 'maybeSignalPluginAutoplayReady' + | 'flushPendingAutoplayReadySignal' + | 'invalidatePendingAutoplayReadyFallbacks' + >; + currentMediaTokenizationGate: Pick< + ReturnType, + 'updateCurrentMediaPath' | 'markReady' + >; + startupOsdSequencer: Pick< + ReturnType, + | 'reset' + | 'markTokenizationReady' + | 'showTokenizationLoading' + | 'showAnnotationLoading' + | 'markAnnotationLoadingComplete' + >; + managedLocalSubtitleSelectionRuntime: Pick< + ReturnType, + 'handleMediaPathChange' | 'handleSubtitleTrackListChange' + >; + mediaTimingReviewRuntime: Pick< + ReturnType, + 'deferPlaybackResume' | 'cancelPlaybackResume' + >; + discordPresenceRuntime: Pick< + ReturnType, + 'publishDiscordPresence' + >; + immersionMediaRuntime: Pick< + ReturnType, + 'syncFromCurrentMediaState' + >; + mediaRuntime: Pick< + ReturnType, + 'updateCurrentMediaPath' | 'updateCurrentMediaTitle' + >; + overlayNotificationsRuntime: Pick< + OverlayNotificationsRuntime, + 'showConfiguredStatusNotification' | 'getConfiguredStatusNotificationType' + >; + youtube: Pick & { + playback: Pick; + primarySubtitleNotification: Pick< + YoutubeAssembly['primarySubtitleNotification'], + 'handleMediaPathChange' | 'handleSubtitleTrackChange' | 'handleSubtitleTrackListChange' + >; + mediaCachePlayback: Pick; + browserPlayback: Pick; + flow: Pick; + }; + jellyfin: Pick< + JellyfinAssembly, + | 'getQuitOnDisconnectArmed' + | 'reportJellyfinRemoteStopped' + | 'reportJellyfinRemoteProgress' + | 'getActivePlayback' + | 'startJellyfinRemoteSession' + >; + anilist: Pick< + AnilistAssembly, + | 'maybeRunAnilistPostWatchUpdate' + | 'recordAnilistMediaDuration' + | 'getCurrentAnilistMediaKey' + | 'resetAnilistMediaTracking' + | 'maybeProbeAnilistDuration' + | 'ensureAnilistMediaGuess' + | 'resetAnilistMediaGuessState' + >; + characterDictionary: { + autoSync: Pick; + imageLookup: Pick; + nameCandidates: Pick; + }; + dictionaries: { + jlpt: Pick; + frequency: Pick; + }; + getSubtitleGenerationRuntime: () => Pick< + ReturnType, + 'handleMediaPathChange' + >; + isCharacterDictionaryEnabled: () => boolean; + /** Whether the tray icon exists; mpv shutdown keeps the app alive when it does. */ + hasTray: () => boolean; + loadSubtitleSourceText: typeof loadSubtitleSourceText; + createSubtitlePrefetchService: ( + serviceDeps: SubtitlePrefetchServiceDeps, + ) => SubtitlePrefetchService; + requestAppQuit: () => void; + maybeStartOverlayLoadingOsd: OverlayNotificationsRuntime['maybeStartOverlayLoadingOsd']; + flushQueuedMpvOsdNotifications: OverlayNotificationsRuntime['flushQueuedMpvOsdNotifications']; + refreshMpvSessionBindings: () => Promise; + ensureImmersionTrackerStarted: () => void; + restoreOverlayMpvSubtitles: () => void; + resetSubtitleSidebarEmbeddedLayoutRuntime: () => void; + syncOverlayMpvSubtitleSuppression: () => void; + setOverlayVisible: (visible: boolean) => void; + ensureYomitanExtensionLoaded: () => Promise; + shouldInitializeMecabForAnnotations: () => boolean; + getRuntimeBooleanOption: (id: AnnotationRuntimeOptionId, fallback: boolean) => boolean; + /** Linux fullscreen overlay refresh lives with the overlay window code in `main.ts`. */ + onFullscreenChange: (fullscreen: boolean) => void; +} + +export type PlaybackAssembly = ReturnType; + +/** + * Owns the subtitle pipeline around mpv playback: the mpv client runtime and its event + * handlers, subtitle tokenization and warmups, subtitle prefetch, the subtitle sidebar's cached + * playback state, secondary subtitles and autoplay subtitle priming. `main.ts` builds it once and + * starts warmups and the mpv client from its startup code. + */ +export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) { + const { + appState, + configService, + logger, + overlayManager, + subtitleWsService, + annotationSubtitleWsService, + autoplayReadyGate, + currentMediaTokenizationGate, + startupOsdSequencer, + managedLocalSubtitleSelectionRuntime, + mediaTimingReviewRuntime, + discordPresenceRuntime, + immersionMediaRuntime, + mediaRuntime, + overlayNotificationsRuntime, + youtube, + jellyfin, + anilist, + characterDictionary, + dictionaries, + loadSubtitleSourceText, + createSubtitlePrefetchService, + } = deps; + let backgroundWarmupsStarted = false; + let tokenizeSubtitleDeferred: ((text: string) => Promise) | null = null; + + function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData { + const canonical = resolveCanonicalPrimarySubtitle({ + liveText: payload.text, + currentTimeSec: toSubtitleClock( + Number(appState.mpvClient?.currentTimePos), + appState.mpvClient?.currentSubDelay ?? 0, + ), + cues: appState.activeParsedSubtitleCues, + }); + return { + ...payload, + startTime: canonical?.startTime ?? appState.mpvClient?.currentSubStart ?? null, + endTime: canonical?.endTime ?? appState.mpvClient?.currentSubEnd ?? null, + }; + } + + function emitSubtitlePayload( + payload: SubtitleData, + options?: { resumePrefetch?: boolean }, + ): void { + const timedPayload = withCurrentSubtitleTiming(payload); + const currentSubtitleData = appState.currentSubtitleData; + const isAnnotationUpgrade = isSubtitleAnnotationUpgrade(currentSubtitleData, timedPayload); + const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; + const frequencyOptions = { + enabled: frequencyDictionary.enabled, + topX: frequencyDictionary.topX, + mode: frequencyDictionary.mode, + }; + appState.currentSubtitleData = timedPayload; + overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload); + if (!isAnnotationUpgrade) { + subtitleWsService.broadcast(timedPayload, frequencyOptions); + } + annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions); + autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true }); + // resumePrefetch: false marks an emit that is not the end of the work for + // this line; prefetch stays paused until the subtitle processing controller + // settles so it does not compete with the on-screen line for the single + // Yomitan parser window. + if (options?.resumePrefetch !== false) { + subtitlePrefetchService?.resume(); + } + } + function getCurrentAutoplaySubtitlePayload(): SubtitleData | null { + const payload = appState.currentSubtitleData; + if (!payload?.text.trim()) { + return null; + } + if (payload.text !== appState.currentSubText) { + return null; + } + return payload; + } + + function signalCurrentSubtitleAutoplayReady(): void { + autoplayReadyGate.flushPendingAutoplayReadySignal(); + const payload = getCurrentAutoplaySubtitlePayload(); + if (payload) { + autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true }); + return; + } + if (!appState.currentSubText.trim()) { + autoplayReadyGate.maybeSignalPluginAutoplayReady( + { text: '__warm__', tokens: null }, + { forceWhilePaused: true }, + ); + } + } + const subtitleProcessingController = createPrefetchAwareSubtitleProcessingController({ + tokenizeSubtitle: async (text: string) => + tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null }, + emitSubtitlePayload: (payload, options) => emitSubtitlePayload(payload, options), + resumePrefetch: () => { + subtitlePrefetchService?.resume(); + }, + logDebug: (message) => { + logger.debug(`[subtitle-processing] ${message}`); + }, + now: () => Date.now(), + }); + + let subtitlePrefetchService: SubtitlePrefetchService | null = null; + let lastObservedTimePos = 0; + let lastObservedPrimarySubtitleTrackId: number | null = null; + let subtitleSidebarCuesRef: SubtitleCue[] | null = null; + let subtitleSidebarCuesRevision = 0; + + /** + * Cheap sidebar state polled ~12x/s while the sidebar is open. Reads only cached state, no + * mpv round-trips. `cuesRevision` bumps whenever a new parsed cue array is swapped in, so a + * re-parse of the same source still tells the renderer to fetch the full snapshot. + */ + function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState { + if (appState.activeParsedSubtitleCues !== subtitleSidebarCuesRef) { + subtitleSidebarCuesRef = appState.activeParsedSubtitleCues; + subtitleSidebarCuesRevision += 1; + } + const subDelay = appState.mpvClient?.currentSubDelay ?? 0; + return { + sourceKey: JSON.stringify([ + appState.activeParsedSubtitleMediaPath, + appState.activeParsedSubtitleSource, + ]), + cuesRevision: subtitleSidebarCuesRevision, + currentTimeSec: + appState.mpvClient?.currentTimePos === undefined + ? null + : toSubtitleClock(appState.mpvClient.currentTimePos, subDelay), + subDelay, + currentSubtitle: { + text: appState.currentSubText, + startTime: appState.mpvClient?.currentSubStart ?? null, + endTime: appState.mpvClient?.currentSubEnd ?? null, + }, + }; + } + const SEEK_THRESHOLD_SECONDS = 3; + const EXPLICIT_SEEK_INTENT_TTL_MS = 2000; + let explicitSeekIntentExpiresAtMs = 0; + + function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolean { + return command[0] === 'seek' || command[0] === 'sub-seek'; + } + + function getMpvPauseCommandValue(command: readonly (string | number)[]): 'yes' | 'no' | null { + if ((command[0] !== 'set_property' && command[0] !== 'set') || command[1] !== 'pause') { + return null; + } + return command[2] === 'yes' || command[2] === 'no' ? command[2] : null; + } + + function sendRendererMpvCommand(rawCommand: (string | number)[]): void { + // Overlay auto-pause releases (popup closed, hover left) must not resume playback + // behind an open timing review; the review applies them when it closes. A pause + // request during the review keeps playback paused after it closes. + const pauseValue = getMpvPauseCommandValue(rawCommand); + if (pauseValue === 'no' && mediaTimingReviewRuntime.deferPlaybackResume()) { + return; + } + if (pauseValue === 'yes') { + mediaTimingReviewRuntime.cancelPlaybackResume(); + } + const command = + resolveSanitizedSubtitleSeekCommand( + rawCommand, + appState.activeParsedSubtitleCues, + appState.mpvClient?.currentTimePos ?? Number.NaN, + appState.mpvClient?.currentSubDelay ?? 0, + ) ?? rawCommand; + if (isExplicitMpvSeekCommand(command)) { + explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS; + } + sendMpvCommandRuntime(appState.mpvClient, command); + } + + function consumeExplicitSeekIntent(): boolean { + const pending = explicitSeekIntentExpiresAtMs >= Date.now(); + explicitSeekIntentExpiresAtMs = 0; + return pending; + } + + const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({ + getCurrentMediaPath: () => appState.currentMediaPath, + getMpvClient: () => appState.mpvClient, + setCurrentSubText: (text) => { + appState.currentSubText = text; + }, + getCurrentSubText: () => appState.currentSubText, + getCurrentSubtitleData: () => appState.currentSubtitleData, + getActiveParsedSubtitleCues: () => appState.activeParsedSubtitleCues, + setActiveParsedSubtitleMediaPath: (mediaPath) => { + appState.activeParsedSubtitleMediaPath = mediaPath; + }, + subtitleProcessingController, + emitSubtitlePayload: (payload, options) => emitSubtitlePayload(payload, options), + getSubtitlePrefetchService: () => subtitlePrefetchService, + getLastObservedTimePos: () => lastObservedTimePos, + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + emitSecondarySubtitle: (text) => { + secondarySubtitleTrackController.handleLiveText(text); + }, + initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) => + subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey), + refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), + logDebug: (message) => { + logger.debug(message); + }, + }); + + const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ + getCurrentService: () => subtitlePrefetchService, + setCurrentService: (service) => { + subtitlePrefetchService = service; + }, + loadSubtitleSourceText, + parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename), + createSubtitlePrefetchService: (serviceDeps) => createSubtitlePrefetchService(serviceDeps), + tokenizeSubtitle: async (text) => + tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, + preCacheTokenization: (text, data) => { + subtitleProcessingController.preCacheTokenization(text, data); + }, + hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text), + getCacheGeneration: () => subtitleProcessingController.getCacheGeneration(), + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), + onParsedSubtitleCuesChanged: (cues, sourceKey) => { + appState.activeParsedSubtitleCues = cues ?? []; + appState.activeParsedSubtitleSource = sourceKey; + if (!cues?.length) { + appState.activeParsedSubtitleMediaPath = null; + } + const mediaPath = autoplaySubtitlePrimingRuntime.getCurrentAutoplayMediaPath(); + if (mediaPath && cues?.length) { + void autoplaySubtitlePrimingRuntime + .primeAutoplaySubtitleFromParsedCues(mediaPath, cues) + .catch((error) => { + logger.debug( + `[autoplay-subtitle-prime] failed to prime from parsed cues: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + } + }, + }); + const cachedInternalSubtitleTrackExtractor = createCachedInternalSubtitleTrackExtractor(); + const detectRemoteMediaPath = createRemoteMediaPathDetector(); + const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler( + { + getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg', + extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) => + cachedInternalSubtitleTrackExtractor.extract(ffmpegPath, videoPath, track), + logDebug: (message) => logger.debug(message), + }, + ); + + const secondarySubtitleTrackController = createSecondarySubtitleTrackController({ + getMpvClient: () => appState.mpvClient, + getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? lastObservedTimePos, + resolveSubtitleSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input), + loadSubtitleSourceText, + parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename), + setCurrentSecondaryText: (text) => { + if (appState.mpvClient) { + appState.mpvClient.currentSecondarySubText = text; + } + }, + broadcastSecondaryText: (text) => { + overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text); + }, + logDebug: (message) => logger.debug(message), + logWarn: (message, error) => logger.warn(message, error), + }); + + const refreshSubtitlePrefetchFromActiveTrackHandler = + createRefreshSubtitlePrefetchFromActiveTrackHandler({ + getMpvClient: () => appState.mpvClient, + getLastObservedTimePos: () => lastObservedTimePos, + // Remote media has no extractable on-disk track to fall back to, so a transient + // resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would + // otherwise drop a working cue list for the rest of the episode. + shouldKeepExistingCuesOnMissingSource: async (videoPath) => + isYoutubeMediaPath(videoPath) || (await detectRemoteMediaPath(videoPath)), + subtitlePrefetchInitController, + resolveActiveSubtitleSidebarSource: (input) => + resolveActiveSubtitleSidebarSourceHandler(input), + logDebug: (message) => logger.debug(message), + logWarn: (message) => logger.warn(message), + }); + + const subtitlePrefetchRuntime = { + cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(), + initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch, + refreshSubtitleSidebarFromSource: (sourcePath: string, mediaPath?: string) => + autoplaySubtitlePrimingRuntime.refreshSubtitleSidebarFromSource(sourcePath, mediaPath), + refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), + scheduleSubtitlePrefetchRefresh: (delayMs?: number) => + autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(delayMs), + clearScheduledSubtitlePrefetchRefresh: () => + autoplaySubtitlePrimingRuntime.clearScheduledSubtitlePrefetchRefresh(), + } as const; + + function refreshCurrentSubtitleAnnotations(): void { + const hasCurrentSubtitle = appState.currentSubText.trim().length > 0; + if (hasCurrentSubtitle) { + subtitlePrefetchService?.pause(); + } + subtitleProcessingController.invalidateTokenizationCache(); + subtitlePrefetchService?.onSeek(lastObservedTimePos); + if (!subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText)) { + // Idle controller: no settle is coming to release the pause above. + subtitlePrefetchService?.resume(); + } + } + + let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined) => void) | null = + null; + + const { + bindMpvClientEventHandlers, + createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, + updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler, + tokenizeSubtitle: tokenizeSubtitleRuntime, + createMecabTokenizerAndCheck, + prewarmSubtitleDictionaries, + startBackgroundWarmups, + startTokenizationWarmups, + isTokenizationWarmupReady, + } = composeMpvRuntimeHandlers< + MpvIpcClient, + ReturnType, + SubtitleData + >({ + bindMpvMainEventHandlersMainDeps: { + appState, + getQuitOnDisconnectArmed: () => + jellyfin.getQuitOnDisconnectArmed() || youtube.playback.getQuitOnDisconnectArmed(), + scheduleQuitCheck: (callback) => { + setTimeout(callback, 500); + }, + quitApp: () => deps.requestAppQuit(), + reportJellyfinRemoteStopped: () => { + void jellyfin.reportJellyfinRemoteStopped(); + }, + onMpvConnected: () => { + deps.maybeStartOverlayLoadingOsd(); + deps.flushQueuedMpvOsdNotifications(); + secondarySubtitleTrackController.scheduleRefresh(0); + void deps.refreshMpvSessionBindings(); + if (appState.sessionBindingsInitialized) { + sendMpvCommandRuntime(appState.mpvClient, [ + 'script-message', + 'subminer-reload-session-bindings', + ]); + } + if (appState.currentSubText.trim()) { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + } + }, + maybeRunAnilistPostWatchUpdate: (options) => anilist.maybeRunAnilistPostWatchUpdate(options), + recordAnilistMediaDuration: (durationSec) => { + anilist.recordAnilistMediaDuration(durationSec); + }, + logSubtitleTimingError: (message, error) => logger.error(message, error), + broadcastToOverlayWindows: (channel, payload) => { + overlayManager.broadcastToOverlayWindows(channel, payload); + }, + onSecondarySubtitleChange: (text) => { + secondarySubtitleTrackController.handleLiveText(text); + }, + getImmediateSubtitlePayload: (text) => + subtitleProcessingController.consumeCachedSubtitle(text), + emitImmediateSubtitle: (payload) => { + emitSubtitlePayload(payload); + }, + onSubtitleChange: (text) => { + // Pause only; restarting the prefetch run here would discard in-flight + // tokenization work on every line. Real seeks restart via onTimePosUpdate. + subtitlePrefetchService?.pause(); + if (!subtitleProcessingController.onSubtitleChange(text)) { + // Repeat of the current text: the controller is idle, so no settle is + // coming to release the pause. Resume now instead of idling prefetch + // for the rest of the cue. + subtitlePrefetchService?.resume(); + } + }, + refreshDiscordPresence: () => { + discordPresenceRuntime.publishDiscordPresence(); + }, + logSubtitleProcessingDebug: (message: string) => logger.debug(message), + ensureImmersionTrackerInitialized: () => { + deps.ensureImmersionTrackerStarted(); + }, + tokenizeSubtitleForImmersion: async (text): Promise => + tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, + updateCurrentMediaPath: (path) => { + const normalizedPath = path.trim(); + deps.maybeStartOverlayLoadingOsd(normalizedPath); + const previousPath = appState.currentMediaPath?.trim() || null; + const preserveParsedSubtitleCues = isSameYoutubeMediaPath( + normalizedPath, + appState.activeParsedSubtitleMediaPath, + ); + if ((normalizedPath || null) !== previousPath) { + cachedInternalSubtitleTrackExtractor.clear(); + secondarySubtitleTrackController.reset(); + const resetSubtitlePayload = { text: '', tokens: null }; + const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; + const frequencyOptions = { + enabled: frequencyDictionary.enabled, + topX: frequencyDictionary.topX, + mode: frequencyDictionary.mode, + }; + autoplaySubtitlePrimingRuntime.resetAutoplaySubtitlePrime(); + lastObservedTimePos = 0; + appState.currentSubText = ''; + appState.currentSubtitleData = null; + if (!preserveParsedSubtitleCues) { + appState.activeParsedSubtitleCues = []; + appState.activeParsedSubtitleSource = null; + appState.activeParsedSubtitleMediaPath = null; + } + overlayManager.broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); + subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); + annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); + autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(); + } + currentMediaTokenizationGate.updateCurrentMediaPath(path); + managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path); + startupOsdSequencer.reset(); + subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh(); + if (!preserveParsedSubtitleCues) { + subtitlePrefetchRuntime.cancelPendingInit(); + } + youtube.primarySubtitleNotification.handleMediaPathChange(path); + void youtube.mediaCachePlayback.handleMediaPathChange(path); + youtube.browserPlayback.handleMediaPathChange(path); + youtube.flow.handleMediaPathChange(path); + deps.getSubtitleGenerationRuntime().handleMediaPathChange(path); + if (path) { + deps.ensureImmersionTrackerStarted(); + secondarySubtitleTrackController.scheduleRefresh(); + void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack(); + // Retry after a short delay because MPV can populate track-list after path. + subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500); + } + mediaRuntime.updateCurrentMediaPath(path); + }, + restoreMpvSubVisibility: () => { + deps.restoreOverlayMpvSubtitles(); + }, + resetSubtitleSidebarEmbeddedLayout: () => { + deps.resetSubtitleSidebarEmbeddedLayoutRuntime(); + }, + getCurrentAnilistMediaKey: () => anilist.getCurrentAnilistMediaKey(), + resetAnilistMediaTracking: (mediaKey) => { + anilist.resetAnilistMediaTracking(mediaKey); + }, + maybeProbeAnilistDuration: (mediaKey) => { + void anilist.maybeProbeAnilistDuration(mediaKey); + }, + ensureAnilistMediaGuess: (mediaKey) => { + void anilist.ensureAnilistMediaGuess(mediaKey); + }, + syncImmersionMediaState: () => { + immersionMediaRuntime.syncFromCurrentMediaState(); + }, + signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path), + markJellyfinRemotePlaybackLoaded: (path) => { + markJellyfinRemotePlaybackLoadedState(jellyfin.getActivePlayback(), path); + }, + scheduleCharacterDictionarySync: () => { + if ( + !configService.getConfig().subtitleStyle.nameMatchEnabled || + !deps.isCharacterDictionaryEnabled() || + youtube.isPlaybackActiveNow() + ) { + return; + } + characterDictionary.autoSync.scheduleSync(); + }, + updateCurrentMediaTitle: (title) => { + mediaRuntime.updateCurrentMediaTitle(title); + }, + resetAnilistMediaGuessState: () => { + anilist.resetAnilistMediaGuessState(); + }, + reportJellyfinRemoteProgress: (forceImmediate) => { + void jellyfin.reportJellyfinRemoteProgress(forceImmediate); + }, + consumeExplicitSeek: () => consumeExplicitSeekIntent(), + onTimePosUpdate: (time) => { + const delta = time - lastObservedTimePos; + if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) { + subtitlePrefetchService.onSeek(time); + } + lastObservedTimePos = time; + secondarySubtitleTrackController.handleTimePos(time); + }, + onFullscreenChange: (fullscreen) => deps.onFullscreenChange(fullscreen), + onSubtitleTrackChange: (sid) => { + lastObservedPrimarySubtitleTrackId = sid; + logger.info('[mpv-subtitles] primary subtitle track changed', { sid }); + autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(); + youtube.primarySubtitleNotification.handleSubtitleTrackChange(sid); + }, + onSecondarySubtitleTrackChange: () => { + secondarySubtitleTrackController.handleTrackChange(); + secondarySubtitleTrackController.scheduleRefresh(0); + }, + onSecondarySubtitleDelayChange: (delay) => { + secondarySubtitleTrackController.handleDelayChange(delay); + }, + onSubtitleTrackListChange: (trackList) => { + const diagnostics = buildSubtitleTrackDiagnostics( + lastObservedPrimarySubtitleTrackId, + trackList, + ); + if (!diagnostics.trackListReadable) { + logger.warn( + '[mpv-subtitles] mpv reported an unreadable subtitle track list', + diagnostics, + ); + } else if (diagnostics.subtitleTrackCount === 0) { + logger.warn('[mpv-subtitles] mpv reported no subtitle tracks', diagnostics); + } else { + logger.info('[mpv-subtitles] subtitle track list updated', diagnostics); + } + managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList); + secondarySubtitleTrackController.scheduleRefresh(0); + autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(); + youtube.primarySubtitleNotification.handleSubtitleTrackListChange(trackList); + }, + updateSubtitleRenderMetrics: (patch) => { + updateMpvSubtitleRenderMetricsHandler(patch as Partial); + }, + syncOverlayMpvSubtitleSuppression: () => { + deps.syncOverlayMpvSubtitleSuppression(); + }, + }, + mpvClientRuntimeServiceFactoryMainDeps: { + createClient: MpvIpcClient, + getSocketPath: () => appState.mpvSocketPath, + getResolvedConfig: () => configService.getConfig(), + isAutoStartOverlayEnabled: () => appState.autoStartOverlay, + setOverlayVisible: (visible: boolean) => deps.setOverlayVisible(visible), + isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getReconnectTimer: () => appState.reconnectTimer, + setReconnectTimer: (timer: ReturnType | null) => { + appState.reconnectTimer = timer; + }, + shouldAutoLoadSecondarySubTrack: (path: string) => + shouldAutoLoadSecondarySubTrackForJellyfinPlayback(jellyfin.getActivePlayback(), path), + shouldQuitOnMpvShutdown: () => + shouldQuitOnMpvShutdownForTrayState({ + managedPlayback: appState.initialArgs?.managedPlayback === true, + backgroundMode: appState.backgroundMode, + hasTray: deps.hasTray(), + }), + requestAppQuit: () => deps.requestAppQuit(), + }, + updateMpvSubtitleRenderMetricsMainDeps: { + getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics, + setCurrentMetrics: (metrics) => { + appState.mpvSubtitleRenderMetrics = metrics; + }, + applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch), + broadcastMetrics: () => { + // no renderer consumer for subtitle render metrics updates at present + }, + }, + tokenizer: { + buildTokenizerDepsMainDeps: { + getYomitanExt: () => appState.yomitanExt, + getYomitanSession: () => appState.yomitanSession, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (window) => { + appState.yomitanParserWindow = window as BrowserWindow | null; + }, + getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise) => { + appState.yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, + setYomitanParserInitPromise: (promise) => { + appState.yomitanParserInitPromise = promise; + }, + isKnownWord: (text, reading, options) => + Boolean(appState.ankiIntegration?.isKnownWord(text, reading, options)), + getKnownWordTier: (text, reading, options) => + appState.ankiIntegration?.getKnownWordTier(text, reading, options) ?? null, + recordLookup: (hit) => { + deps.ensureImmersionTrackerStarted(); + appState.immersionTracker?.recordLookup(hit); + }, + getKnownWordMatchMode: () => + appState.ankiIntegration?.getKnownWordMatchMode() ?? + configService.getConfig().ankiConnect.knownWords.matchMode, + getKnownWordsEnabled: () => + deps.getRuntimeBooleanOption( + 'subtitle.annotation.knownWords.highlightEnabled', + configService.getConfig().ankiConnect.knownWords.highlightEnabled, + ), + getNPlusOneEnabled: () => + deps.getRuntimeBooleanOption( + 'subtitle.annotation.nPlusOne', + configService.getConfig().ankiConnect.nPlusOne.enabled, + ), + getMinSentenceWordsForNPlusOne: () => + configService.getConfig().ankiConnect.nPlusOne.minSentenceWords, + getJlptLevel: (text) => appState.jlptLevelLookup(text), + getJlptEnabled: () => + deps.getRuntimeBooleanOption( + 'subtitle.annotation.jlpt', + configService.getConfig().subtitleStyle.enableJlpt, + ), + getCharacterDictionaryEnabled: () => + configService.getConfig().subtitleStyle.nameMatchEnabled && + deps.isCharacterDictionaryEnabled() && + !youtube.isPlaybackActiveNow(), + getNameMatchEnabled: () => configService.getConfig().subtitleStyle.nameMatchEnabled, + getNameMatchImagesEnabled: () => + configService.getConfig().subtitleStyle.nameMatchImagesEnabled, + getCharacterNameImage: (term) => characterDictionary.imageLookup.get(term), + getCurrentCharacterDictionaryMediaId: () => + characterDictionary.autoSync.getCurrentMediaId(), + getCharacterNameCandidates: () => characterDictionary.nameCandidates.get(), + getFrequencyDictionaryEnabled: () => + deps.getRuntimeBooleanOption( + 'subtitle.annotation.frequency', + configService.getConfig().subtitleStyle.frequencyDictionary.enabled, + ), + getFrequencyDictionaryMatchMode: () => + configService.getConfig().subtitleStyle.frequencyDictionary.matchMode, + getFrequencyRank: (text) => appState.frequencyRankLookup(text), + getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, + getMecabTokenizer: () => appState.mecabTokenizer, + onTokenizationReady: () => { + currentMediaTokenizationGate.markReady( + appState.currentMediaPath?.trim() || + appState.mpvClient?.currentVideoPath?.trim() || + null, + ); + startupOsdSequencer.markTokenizationReady(); + }, + }, + createTokenizerRuntimeDeps: (tokenizerDeps) => createTokenizerDepsRuntime(tokenizerDeps), + tokenizeSubtitle: (text, tokenizerDeps) => tokenizeSubtitleCore(text, tokenizerDeps), + createMecabTokenizerAndCheckMainDeps: { + getMecabTokenizer: () => appState.mecabTokenizer, + setMecabTokenizer: (tokenizer) => { + appState.mecabTokenizer = tokenizer as MecabTokenizer | null; + }, + createMecabTokenizer: () => new MecabTokenizer(), + checkAvailability: async (tokenizer) => (tokenizer as MecabTokenizer).checkAvailability(), + }, + prewarmSubtitleDictionariesMainDeps: { + ensureJlptDictionaryLookup: () => dictionaries.jlpt.ensureJlptDictionaryLookup(), + ensureFrequencyDictionaryLookup: () => + dictionaries.frequency.ensureFrequencyDictionaryLookup(), + showMpvOsd: (message: string) => + overlayNotificationsRuntime.showConfiguredStatusNotification(message), + showLoadingOsd: (message: string) => startupOsdSequencer.showAnnotationLoading(message), + showLoadedOsd: (message: string) => + startupOsdSequencer.markAnnotationLoadingComplete(message), + shouldShowOsdNotification: () => { + const type = overlayNotificationsRuntime.getConfiguredStatusNotificationType(); + return type === 'osd' || type === 'osd-system'; + }, + }, + }, + warmups: { + launchBackgroundWarmupTaskMainDeps: { + now: () => Date.now(), + logDebug: (message) => logger.debug(message), + logWarn: (message) => logger.warn(message), + }, + startBackgroundWarmupsMainDeps: { + getStarted: () => backgroundWarmupsStarted, + setStarted: (started) => { + backgroundWarmupsStarted = started; + }, + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded().then(() => {}), + shouldWarmupMecab: () => { + const startupWarmups = configService.getConfig().startupWarmups; + if (startupWarmups.lowPowerMode) { + return false; + } + if (!startupWarmups.mecab) { + return false; + } + return deps.shouldInitializeMecabForAnnotations(); + }, + shouldWarmupYomitanExtension: () => + configService.getConfig().startupWarmups.yomitanExtension, + shouldWarmupSubtitleDictionaries: () => { + const startupWarmups = configService.getConfig().startupWarmups; + if (startupWarmups.lowPowerMode) { + return false; + } + return startupWarmups.subtitleDictionaries; + }, + shouldWarmupJellyfinRemoteSession: () => { + const startupWarmups = configService.getConfig().startupWarmups; + if (startupWarmups.lowPowerMode) { + return false; + } + return startupWarmups.jellyfinRemoteSession; + }, + shouldAutoConnectJellyfinRemote: () => { + const jellyfin = configService.getConfig().jellyfin; + return ( + jellyfin.enabled && jellyfin.remoteControlEnabled && jellyfin.remoteControlAutoConnect + ); + }, + startJellyfinRemoteSession: () => jellyfin.startJellyfinRemoteSession(), + logDebug: (message) => logger.debug(message), + }, + }, + }); + + async function tokenizeSubtitle(text: string): Promise { + if (!isTokenizationWarmupReady()) { + startupOsdSequencer.showTokenizationLoading('Loading subtitle tokenization...'); + } + return await tokenizeSubtitleRuntime(text); + } + + signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease({ + isTokenizationWarmupReady: () => isTokenizationWarmupReady(), + startTokenizationWarmups: async () => { + await startTokenizationWarmups(); + }, + getCurrentMediaPath: () => + appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, + primeCurrentSubtitle: (mediaPath) => + autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForAutoplay(mediaPath), + signalAutoplayReady: () => signalCurrentSubtitleAutoplayReady(), + warn: (message, error) => logger.warn(message, error), + }); + tokenizeSubtitleDeferred = tokenizeSubtitle; + + return { + subtitleProcessingController, + autoplaySubtitlePrimingRuntime, + secondarySubtitleTrackController, + subtitlePrefetchRuntime, + cachedInternalSubtitleTrackExtractor, + resolveActiveSubtitleSidebarSourceHandler, + withCurrentSubtitleTiming, + getSubtitleSidebarPlaybackState, + sendRendererMpvCommand, + refreshCurrentSubtitleAnnotations, + tokenizeSubtitle, + bindMpvClientEventHandlers, + createMpvClientRuntimeServiceHandler, + createMecabTokenizerAndCheck, + prewarmSubtitleDictionaries, + startBackgroundWarmups, + startTokenizationWarmups, + isTokenizationWarmupReady, + getSubtitlePrefetchService: () => subtitlePrefetchService, + getLastObservedTimePos: () => lastObservedTimePos, + getTokenizeSubtitleDeferred: () => tokenizeSubtitleDeferred, + }; +} diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 231886ef..70f914c1 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -29,129 +29,6 @@ test('manual watched session action starts immersion tracker before marking watc ); }); -test('media path changes clear rendered subtitle state without clearing same-youtube parsed cues', () => { - const source = readMainSource(); - const actionBlock = source.match( - /updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?[\s\S]*?)autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);/, - )?.groups?.body; - - assert.ok(actionBlock); - assert.match(actionBlock, /appState\.currentSubText = '';/); - assert.match(actionBlock, /appState\.currentSubtitleData = null;/); - assert.match(actionBlock, /isSameYoutubeMediaPath\(/); - assert.match(actionBlock, /if \(!preserveParsedSubtitleCues\)/); - assert.match(actionBlock, /appState\.activeParsedSubtitleCues = \[\];/); - assert.match(actionBlock, /appState\.activeParsedSubtitleSource = null;/); - assert.match(actionBlock, /appState\.activeParsedSubtitleMediaPath = null;/); - assert.match(actionBlock, /lastObservedTimePos = 0;/); - assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/); - assert.match(actionBlock, /subtitleWsService\.broadcast\(/); - assert.match(actionBlock, /annotationSubtitleWsService\.broadcast\(/); - assert.ok( - actionBlock.indexOf('appState.currentSubtitleData = null;') < - actionBlock.indexOf("broadcastToOverlayWindows('subtitle:set'"), - ); -}); - -test('same media path updates do not reset autoplay ready fallback state', () => { - const source = readMainSource(); - const actionBlock = source.match( - /updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?[\s\S]*?)\n restoreMpvSubVisibility:/, - )?.groups?.body; - - assert.ok(actionBlock); - assert.match( - actionBlock, - /annotationSubtitleWsService\.broadcast\(resetSubtitlePayload, frequencyOptions\);\s+autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);\s+\}\s+currentMediaTokenizationGate\.updateCurrentMediaPath\(path\);/, - ); -}); - -test('mpv startup signals start overlay loading OSD before readiness work', () => { - const source = readMainSource(); - const connectedBlock = source.match( - /onMpvConnected:\s*\(\)\s*=>\s*\{(?[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/, - )?.groups?.body; - const mediaPathBlock = source.match( - /updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?[\s\S]*?)\n restoreMpvSubVisibility:/, - )?.groups?.body; - const setVisibleBlock = source.match( - /function setVisibleOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - - assert.ok(connectedBlock); - assert.ok(mediaPathBlock); - assert.ok(setVisibleBlock); - assert.match(connectedBlock, /maybeStartOverlayLoadingOsd\(\);/); - assert.match( - mediaPathBlock, - /const normalizedPath = path\.trim\(\);\s+maybeStartOverlayLoadingOsd\(normalizedPath\);/, - ); - assert.match(setVisibleBlock, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/); - assert.match( - source, - /function toggleVisibleOverlay\(\): void \{[\s\S]*?else \{\s+maybeStartOverlayLoadingOsd\(\);/, - ); - assert.match( - source, - /function setOverlayVisible\(visible: boolean\): void \{[\s\S]*?if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/, - ); -}); - -test('manual visible overlay toggles only release current-media autoplay when hiding', () => { - const source = readMainSource(); - const actionBlock = source.match( - /function toggleVisibleOverlay\(\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - - assert.ok(actionBlock); - assert.match( - actionBlock, - /if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, - ); -}); - -test('all visible overlay hide paths clear stale overlay input state', () => { - const source = readMainSource(); - const setVisibleBlock = source.match( - /function setVisibleOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - const toggleBlock = source.match( - /function toggleVisibleOverlay\(\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - const setOverlayBlock = source.match( - /function setOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - - assert.ok(setVisibleBlock); - assert.ok(toggleBlock); - assert.ok(setOverlayBlock); - assert.match( - setVisibleBlock, - /if \(!visible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/, - ); - assert.match( - toggleBlock, - /if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/, - ); - assert.match( - setOverlayBlock, - /if \(!visible\) \{[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?resetVisibleOverlayInputState\(\);[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, - ); - // Hiding must also dismiss the loading OSD, or it lingers over mpv. - assert.match( - setVisibleBlock, - /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/, - ); - assert.match( - toggleBlock, - /if \(!nextVisible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/, - ); - assert.match( - setOverlayBlock, - /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/, - ); -}); - 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( @@ -169,30 +46,6 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () = ); }); -test('subtitle change pauses prefetch without restarting its run before tokenizing current line', () => { - const source = readMainSource(); - const actionBlock = source.match( - /onSubtitleChange:\s*\(text\)\s*=>\s*\{(?[\s\S]*?)\n \},\n refreshDiscordPresence:/, - )?.groups?.body; - - assert.ok(actionBlock); - assert.match(actionBlock, /subtitlePrefetchService\?\.pause\(\);/); - // Restarting the run per line (onSeek) discards in-flight prefetch work; - // only real seeks restart via onTimePosUpdate. - assert.doesNotMatch(actionBlock, /subtitlePrefetchService\?\.onSeek\(/); - assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\)/); - assert.ok( - actionBlock.indexOf('subtitlePrefetchService?.pause();') < - actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text)'), - ); - // A repeated subtitle emits nothing, so the pause has to be released here or - // prefetching idles until the next distinct line. - assert.match( - actionBlock, - /if \(!subtitleProcessingController\.onSubtitleChange\(text\)\) \{[\s\S]*?subtitlePrefetchService\?\.resume\(\);/, - ); -}); - test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => { const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts'); const actionBlock = source.match( @@ -241,28 +94,12 @@ test('startup autoplay release is tied to visible overlay measurement readiness' assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/); }); -test('visible overlay content-ready does not tokenize before first measurement', () => { +test('visible overlay first measurement flushes autoplay before scheduling the first-paint refresh', () => { const source = readMainSource(); - const contentReadyBlock = source.match( - /onWindowContentReady:\s*\(\)\s*=>\s*\{(?[\s\S]*?)\n \},/, - )?.groups?.body; const measurementBlock = source.match( /reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?[\s\S]*?)\n \},/, )?.groups?.body; - assert.ok(contentReadyBlock); - assert.doesNotMatch(contentReadyBlock, /subtitleProcessingController\.refreshCurrentSubtitle/); - assert.match(contentReadyBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/); - assert.match(contentReadyBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/); - assert.ok( - contentReadyBlock.indexOf('overlayVisibilityRuntime.updateVisibleOverlayVisibility();') < - contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'), - ); - assert.ok( - contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();') < - contentReadyBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();'), - ); - assert.ok(measurementBlock); assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/); assert.match(measurementBlock, /scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint\(\)/); @@ -318,35 +155,6 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi assert.match(depsBlock, /subtitleSidebarRequestedOpen/); }); -test('warm tokenization release can signal readiness before the first subtitle appears', () => { - const source = readMainSource(); - const warmReleaseBlock = source.match( - /signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease\(\{(?[\s\S]*?)\n\}\);/, - )?.groups?.body; - const signalBlock = source.match( - /function signalCurrentSubtitleAutoplayReady\(\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - const currentPayloadBlock = source.match( - /function getCurrentAutoplaySubtitlePayload\(\): SubtitleData \| null \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - - assert.ok(warmReleaseBlock); - assert.match( - warmReleaseBlock, - /signalAutoplayReady: \(\) => signalCurrentSubtitleAutoplayReady\(\)/, - ); - - assert.ok(signalBlock); - assert.match(signalBlock, /const payload = getCurrentAutoplaySubtitlePayload\(\);/); - assert.match(signalBlock, /if \(payload\) \{/); - assert.match(signalBlock, /if \(!appState\.currentSubText\.trim\(\)\) \{/); - assert.match(signalBlock, /text: '__warm__'/); - - assert.ok(currentPayloadBlock); - assert.match(currentPayloadBlock, /appState\.currentSubtitleData/); - assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/); -}); - test('stats server Yomitan note creation honors configured Anki server override policy', () => { const source = readSource('src/main/runtime/stats-server-runtime.ts'); const statsServerConfigBlock = source.match( @@ -365,104 +173,14 @@ test('stats server Yomitan note creation honors configured Anki server override assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/); }); -test('Linux visible overlay recreation clears stale input state before creating replacement window', () => { - const source = readMainSource(); +test('visible overlay input reset clears the visible content measurement', () => { const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts'); - const actionBlock = source.match( - /const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?createWindow: \(\) => \{(?[\s\S]*?)\n \},/, - )?.groups?.body; const resetBlock = runtimeSource.match( /function resetVisibleOverlayInputState\(\): void \{(?[\s\S]*?)\n \}/, )?.groups?.body; - assert.ok(actionBlock); assert.ok(resetBlock); - assert.match(actionBlock, /resetVisibleOverlayInputState\(\);/); assert.match(resetBlock, /overlayContentMeasurementStore\.clear\('visible'\);/); - assert.ok( - actionBlock.indexOf('resetVisibleOverlayInputState();') < - actionBlock.indexOf('createMainWindow();'), - ); -}); - -test('Linux visible overlay recreation avoids display fallback before tracked geometry exists', () => { - const source = readMainSource(); - const actionBlock = source.match( - /const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?refreshWindow: \(\) => \{(?[\s\S]*?)\n \},/, - )?.groups?.body; - - assert.ok(actionBlock); - assert.match( - actionBlock, - /const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/, - ); - assert.match( - actionBlock, - /if \(trackedGeometry\) overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/, - ); - assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/); - assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/); -}); - -test('subtitle annotation updates invalidate prefetched tokenizations before refreshing current subtitle', () => { - const source = readMainSource(); - const actionBlock = source.match( - /function refreshCurrentSubtitleAnnotations\(\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - - assert.ok(actionBlock); - assert.match(actionBlock, /subtitleProcessingController\.invalidateTokenizationCache\(\);/); - assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/); - assert.match( - actionBlock, - /if \(!subtitleProcessingController\.refreshCurrentSubtitle\(appState\.currentSubText\)\) \{[\s\S]*?subtitlePrefetchService\?\.resume\(\);/, - ); - assert.ok( - actionBlock.indexOf('subtitleProcessingController.invalidateTokenizationCache();') < - actionBlock.indexOf( - 'subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText)', - ), - ); -}); - -test('annotation upgrades skip the duplicate basic websocket event', () => { - const source = readMainSource(); - const emitBlock = source.match( - /function emitSubtitlePayload\([\s\S]*?\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - - assert.ok(emitBlock); - assert.match( - emitBlock, - /const isAnnotationUpgrade = isSubtitleAnnotationUpgrade\(currentSubtitleData, timedPayload\);/, - ); - assert.match( - emitBlock, - /if \(!isAnnotationUpgrade\) \{\s+subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);\s+\}/, - ); - assert.equal( - (emitBlock.match(/overlayManager\.broadcastToOverlayWindows\('subtitle:set'/g) ?? []).length, - 1, - ); - assert.equal( - ( - emitBlock.match( - /annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\)/g, - ) ?? [] - ).length, - 1, - ); -}); - -test('mpv connection flushes queued configured OSD notifications', () => { - const source = readMainSource(); - const connectedBlock = source.match( - /onMpvConnected:\s*\(\)\s*=>\s*\{(?[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/, - )?.groups?.body; - - assert.ok(connectedBlock); - assert.match(source, /flushQueuedMpvOsdNotifications/); - assert.match(connectedBlock, /flushQueuedMpvOsdNotifications\(\);/); }); test('Linux visible overlay startup reapplies passive passthrough after input reset', () => { @@ -498,37 +216,6 @@ test('Linux visible overlay startup reapplies passive passthrough after input re ); }); -test('Linux visible overlay show starts input grace before first measurement', () => { - const source = readMainSource(); - const setVisibleBlock = source.match( - /function setVisibleOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - const toggleBlock = source.match( - /function toggleVisibleOverlay\(\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - const setOverlayBlock = source.match( - /function setOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - - for (const block of [setVisibleBlock, toggleBlock, setOverlayBlock]) { - assert.ok(block); - const resetIndex = block.indexOf( - 'visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer();', - ); - const graceIndex = block.indexOf( - 'visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace();', - ); - const primeIndex = block.indexOf( - 'void autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay();', - ); - assert.ok(resetIndex >= 0); - assert.ok(graceIndex >= 0); - assert.ok(primeIndex >= 0); - assert.ok(resetIndex < graceIndex); - assert.ok(graceIndex < primeIndex); - } -}); - test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => { const source = readSource('src/main/runtime/overlay-geometry-runtime.ts'); const afterBoundsBlock = source.match(