diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index c7ab649..cf4a05a 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -6,7 +6,7 @@ Read first. Keep concise. | ------------ | -------------- | ---------------------------------------------------- | --------- | ------------------------------------- | ---------------------- | | `codex-main` | `planner-exec` | `Fix frequency/N+1 regression in plugin --start flow` | `in_progress` | `docs/subagents/agents/codex-main.md` | `2026-02-19T19:36:46Z` | | `codex-config-validation-20260219T172015Z-iiyf` | `codex-config-validation` | `Find root cause of config validation error for ~/.config/SubMiner/config.jsonc` | `completed` | `docs/subagents/agents/codex-config-validation-20260219T172015Z-iiyf.md` | `2026-02-19T17:26:17Z` | -| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T03:27:35Z` | +| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T05:15:40Z` | | `codex-anilist-deeplink-20260219T233926Z` | `anilist-deeplink` | `Fix external subminer:// AniList callback handling from browser` | `done` | `docs/subagents/agents/codex-anilist-deeplink-20260219T233926Z.md` | `2026-02-19T23:59:21Z` | | `codex-texthooker-highlights-20260220T002354Z-927c` | `codex-texthooker-highlights` | `Add optional texthooker highlight toggles for known/n+1/frequency/JLPT` | `completed` | `docs/subagents/agents/codex-texthooker-highlights-20260220T002354Z-927c.md` | `2026-02-20T00:30:49Z` | | `codex-texthooker-ui-playwright-20260220T003827Z-k3p9` | `codex-texthooker-ui-playwright` | `Run Playwright MCP smoke/regression checks for texthooker-ui changes` | `completed` | `docs/subagents/agents/codex-texthooker-ui-playwright-20260220T003827Z-k3p9.md` | `2026-02-20T00:42:09Z` | diff --git a/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md b/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md index 4423254..c0d844a 100644 --- a/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md +++ b/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md @@ -181,6 +181,18 @@ - `src/main/runtime/anilist-setup-window.test.ts` - `src/main/runtime/jellyfin-setup-window.ts` - `src/main/runtime/jellyfin-setup-window.test.ts` +- `src/main/runtime/app-runtime-main-deps.ts` +- `src/main/runtime/app-runtime-main-deps.test.ts` +- `src/main/runtime/global-shortcuts-main-deps.ts` +- `src/main/runtime/global-shortcuts-main-deps.test.ts` +- `src/main/runtime/mpv-osd-log-main-deps.ts` +- `src/main/runtime/mpv-osd-log-main-deps.test.ts` +- `src/main/runtime/app-lifecycle-main-activate.ts` +- `src/main/runtime/app-lifecycle-main-activate.test.ts` +- `src/main/runtime/initial-args-main-deps.ts` +- `src/main/runtime/initial-args-main-deps.test.ts` +- `src/main/runtime/mpv-main-event-main-deps.ts` +- `src/main/runtime/mpv-main-event-main-deps.test.ts` ## Open Questions / Blockers @@ -188,4 +200,47 @@ ## Next Step -- extract next `src/main.ts` app-lifecycle cleanup/restore wrapper cluster in startup lifecycle wiring (`onWillQuitCleanup`, `restoreWindowsOnActivate`) into runtime helper + tests. +- extract next `src/main.ts` runtime-deps assembly cluster (global shortcuts and/or MPV OSD bindings) into focused `*-main-deps` helper(s) with parity tests. +- [2026-02-20T04:04:21Z] progress: extracted MPV main event action callbacks into `src/main/runtime/mpv-main-event-actions.ts` and rewired `bindMpvClientEventHandlers` to delegate through focused handlers. +- [2026-02-20T04:04:21Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js` pass (14/14). +- [2026-02-20T04:06:41Z] progress: extracted Jellyfin setup-window orchestration into `createOpenJellyfinSetupWindowHandler` in `src/main/runtime/jellyfin-setup-window.ts`; rewired `openJellyfinSetupWindow` in `src/main.ts` to thin wrapper. +- [2026-02-20T04:06:41Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/mpv-client-runtime-service.test.js` pass (23/23). +- [2026-02-20T04:09:21Z] progress: extracted AniList setup-window orchestration into `createOpenAnilistSetupWindowHandler` in `src/main/runtime/anilist-setup-window.ts`; rewired `openAnilistSetupWindow` in `src/main.ts` to thin wrapper. +- [2026-02-20T04:09:21Z] progress: `src/main.ts` reduced to 2883 LOC after this slice. +- [2026-02-20T04:09:21Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/mpv-client-runtime-service.test.js` pass (39/39). +- [2026-02-20T04:11:27Z] progress: extracted Jellyfin external subtitle preload/track-picking from `playJellyfinItemInMpv` into new `src/main/runtime/jellyfin-subtitle-preload.ts` (`createPreloadJellyfinExternalSubtitlesHandler`) and rewired `main.ts`. +- [2026-02-20T04:11:27Z] progress: `src/main.ts` reduced to 2785 LOC after subtitle-preload extraction. +- [2026-02-20T04:11:27Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-subtitle-preload.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/mpv-main-event-actions.test.js` pass (41/41). +- [2026-02-20T04:17:41Z] progress: extracted `playJellyfinItemInMpv` orchestration into `src/main/runtime/jellyfin-playback-launch.ts` (`createPlayJellyfinItemInMpvHandler`) + tests; rewired `main.ts` playback handler wiring. +- [2026-02-20T04:17:41Z] progress: extracted `runJellyfinCommand` command routing into `src/main/runtime/jellyfin-command-dispatch.ts` (`createRunJellyfinCommandHandler`) + tests; rewired `main.ts` dispatcher wiring. +- [2026-02-20T04:17:41Z] progress: `src/main.ts` reduced to 2696 LOC after Jellyfin playback/dispatch extraction. +- [2026-02-20T04:17:41Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-command-dispatch.test.js dist/main/runtime/jellyfin-playback-launch.test.js dist/main/runtime/jellyfin-subtitle-preload.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (24/24). +- [2026-02-20T04:32:30Z] progress: extracted AniList media-state helpers to `src/main/runtime/anilist-media-state.ts` (`createGetCurrentAnilistMediaKeyHandler`, `createResetAnilistMediaTrackingHandler`, `createGetAnilistMediaGuessRuntimeStateHandler`, `createSetAnilistMediaGuessRuntimeStateHandler`, `createResetAnilistMediaGuessStateHandler`) + tests; rewired main AniList guess-state callbacks. +- [2026-02-20T04:32:30Z] progress: extracted MPV main event binder orchestration from `bindMpvClientEventHandlers` into `src/main/runtime/mpv-main-event-bindings.ts` (`createBindMpvMainEventHandlersHandler`) + tests; rewired `main.ts` to dependency-only binder setup. +- [2026-02-20T04:32:30Z] progress: `src/main.ts` reduced to 2665 LOC. +- [2026-02-20T04:32:30Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mpv-main-event-bindings.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/anilist-media-state.test.js dist/main/runtime/anilist-media-guess.test.js dist/main/runtime/jellyfin-command-dispatch.test.js dist/main/runtime/jellyfin-playback-launch.test.js` pass (23/23). +- [2026-02-20T04:33:17Z] progress: extracted main MPV event binding orchestration from `src/main.ts` into `src/main/runtime/mpv-main-event-bindings.ts` (`createBindMpvMainEventHandlersHandler`) with unit tests in `src/main/runtime/mpv-main-event-bindings.test.ts`; rewired `main.ts` bind setup to dependency map. +- [2026-02-20T04:33:17Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mpv-main-event-bindings.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/anilist-media-state.test.js dist/main/runtime/anilist-media-guess.test.js dist/main/runtime/jellyfin-command-dispatch.test.js dist/main/runtime/jellyfin-playback-launch.test.js` pass (23/23). +- [2026-02-20T05:00:15Z] progress: extracted on-will-quit cleanup dependency assembly to `src/main/runtime/app-lifecycle-main-cleanup.ts` (`createBuildOnWillQuitCleanupDepsHandler`) + tests; rewired `onWillQuitCleanupHandler` wiring in `src/main.ts`. +- [2026-02-20T05:00:15Z] progress: extracted overlay runtime options dependency assembly to `src/main/runtime/overlay-runtime-options-main-deps.ts` + tests; rewired `buildInitializeOverlayRuntimeOptionsHandler` wiring in `src/main.ts`. +- [2026-02-20T05:00:15Z] progress: extracted CLI command context dependency assembly to `src/main/runtime/cli-command-context-main-deps.ts` + tests; rewired `buildCliCommandContextDepsHandler` wiring in `src/main.ts`. +- [2026-02-20T05:00:15Z] progress: `src/main.ts` reduced to 2617 LOC. +- [2026-02-20T05:00:15Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/cli-command-context-deps.test.js dist/main/runtime/overlay-runtime-options-main-deps.test.js dist/main/runtime/overlay-runtime-options.test.js dist/main/runtime/app-lifecycle-main-cleanup.test.js dist/main/runtime/mpv-main-event-bindings.test.js` pass (6/6). +- [2026-02-20T05:01:42Z] progress: extracted MPV runtime-service dependency assembly into `src/main/runtime/mpv-client-runtime-service-main-deps.ts` + tests; rewired `createMpvClientRuntimeService` in `src/main.ts` to use builder before `createMpvClientRuntimeServiceFactory`. +- [2026-02-20T05:01:42Z] progress: `src/main.ts` currently 2618 LOC. +- [2026-02-20T05:01:42Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mpv-client-runtime-service-main-deps.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-main-event-bindings.test.js dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/overlay-runtime-options-main-deps.test.js dist/main/runtime/app-lifecycle-main-cleanup.test.js` pass (7/7). +- [2026-02-20T05:06:04Z] progress: extracted overlay-window factory dependency assembly into `src/main/runtime/overlay-window-factory-main-deps.ts` + tests; rewired `createOverlayWindowHandler`, `createMainWindowHandler`, and `createInvisibleWindowHandler` wiring in `src/main.ts`. +- [2026-02-20T05:06:04Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/overlay-window-factory-main-deps.test.js dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/mpv-client-runtime-service-main-deps.test.js dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/overlay-runtime-options-main-deps.test.js dist/main/runtime/app-lifecycle-main-cleanup.test.js` pass (8/8). +- [2026-02-20T05:10:14Z] progress: extracted tray/overlay-bootstrap/Yomitan opener dependency assembly into `src/main/runtime/app-runtime-main-deps.ts` (`createBuildEnsureTrayMainDepsHandler`, `createBuildDestroyTrayMainDepsHandler`, `createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler`, `createBuildOpenYomitanSettingsMainDepsHandler`); rewired `ensureTray`, `destroyTray`, `initializeOverlayRuntime`, and `openYomitanSettings` to thin handlers in `src/main.ts`. +- [2026-02-20T05:10:14Z] progress: `src/main.ts` currently 2652 LOC after this slice (line-count bump from import/deps constant growth while inline wrapper bodies were removed). +- [2026-02-20T05:10:14Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/app-runtime-main-deps.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/tray-main-deps.test.js dist/main/runtime/overlay-runtime-options-main-deps.test.js` pass (13/13). +- [2026-02-20T05:12:29Z] progress: extracted global-shortcuts dependency assembly into `src/main/runtime/global-shortcuts-main-deps.ts` (`createBuildGetConfiguredShortcutsMainDepsHandler`, `createBuildRegisterGlobalShortcutsMainDepsHandler`, `createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler`) and rewired corresponding handler setup in `src/main.ts`. +- [2026-02-20T05:12:29Z] progress: extracted MPV OSD/log dependency assembly into `src/main/runtime/mpv-osd-log-main-deps.ts` (`createBuildAppendToMpvLogMainDepsHandler`, `createBuildShowMpvOsdMainDepsHandler`) and rewired `appendToMpvLogHandler` + `showMpvOsdHandler` setup in `src/main.ts`. +- [2026-02-20T05:12:29Z] progress: `src/main.ts` currently 2672 LOC after this slice (net increase from imports and explicit deps builders). +- [2026-02-20T05:12:29Z] test: initial `bun run build` failed with type mismatch in `global-shortcuts-main-deps` (`unknown` options type); fixed by tightening to `RegisterGlobalShortcutsServiceOptions`. +- [2026-02-20T05:12:29Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/global-shortcuts-main-deps.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/mpv-osd-log-main-deps.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/app-runtime-main-deps.test.js` pass (15/15). +- [2026-02-20T05:15:40Z] progress: extracted activate-lifecycle deps assembly into `src/main/runtime/app-lifecycle-main-activate.ts` (`createBuildShouldRestoreWindowsOnActivateMainDepsHandler`, `createBuildRestoreWindowsOnActivateMainDepsHandler`) and rewired `shouldRestoreWindowsOnActivateHandler` + `restoreWindowsOnActivateHandler` setup in `src/main.ts`. +- [2026-02-20T05:15:40Z] progress: extracted initial-args deps assembly into `src/main/runtime/initial-args-main-deps.ts` (`createBuildHandleInitialArgsMainDepsHandler`) and rewired `handleInitialArgs` setup in `src/main.ts`. +- [2026-02-20T05:15:40Z] progress: extracted MPV main-event deps assembly into `src/main/runtime/mpv-main-event-main-deps.ts` (`createBuildBindMpvMainEventHandlersMainDepsHandler`) and rewired `bindMpvClientEventHandlers` setup in `src/main.ts`. +- [2026-02-20T05:15:40Z] progress: `src/main.ts` currently 2653 LOC after this slice. +- [2026-02-20T05:15:40Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/app-lifecycle-main-activate.test.js dist/main/runtime/initial-args-main-deps.test.js dist/main/runtime/app-lifecycle-actions.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/app-runtime-main-deps.test.js dist/main/runtime/mpv-main-event-main-deps.test.js dist/main/runtime/mpv-main-event-bindings.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js` pass (27/27). diff --git a/src/main.ts b/src/main.ts index 99815d8..017cde9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -112,31 +112,26 @@ import { createWaitForMpvConnectedHandler, } from './main/runtime/jellyfin-remote-connection'; import { - createHandleJellyfinSetupWindowClosedHandler, buildJellyfinSetupFormHtml, - createHandleJellyfinSetupNavigationHandler, - createHandleJellyfinSetupWindowOpenedHandler, - createHandleJellyfinSetupSubmissionHandler, + createOpenJellyfinSetupWindowHandler, createMaybeFocusExistingJellyfinSetupWindowHandler, parseJellyfinSetupSubmissionUrl, } from './main/runtime/jellyfin-setup-window'; import { - createHandleAnilistSetupWindowClosedHandler, - createHandleAnilistSetupWindowOpenedHandler, createMaybeFocusExistingAnilistSetupWindowHandler, - createAnilistSetupDidFailLoadHandler, - createAnilistSetupDidFinishLoadHandler, - createAnilistSetupDidNavigateHandler, - createAnilistSetupFallbackHandler, - createAnilistSetupWillNavigateHandler, - createAnilistSetupWillRedirectHandler, - createAnilistSetupWindowOpenHandler, - createHandleManualAnilistSetupSubmissionHandler, + createOpenAnilistSetupWindowHandler, } from './main/runtime/anilist-setup-window'; import { createEnsureAnilistMediaGuessHandler, createMaybeProbeAnilistDurationHandler, } from './main/runtime/anilist-media-guess'; +import { + createGetAnilistMediaGuessRuntimeStateHandler, + createGetCurrentAnilistMediaKeyHandler, + createResetAnilistMediaGuessStateHandler, + createResetAnilistMediaTrackingHandler, + createSetAnilistMediaGuessRuntimeStateHandler, +} from './main/runtime/anilist-media-state'; import { buildAnilistAttemptKey, createMaybeRunAnilistPostWatchUpdateHandler, @@ -149,21 +144,23 @@ import { } from './main/runtime/subtitle-position'; import { registerProtocolUrlHandlers } from './main/runtime/protocol-url-handlers'; import { createHandleJellyfinAuthCommands } from './main/runtime/jellyfin-cli-auth'; +import { createRunJellyfinCommandHandler } from './main/runtime/jellyfin-command-dispatch'; import { createHandleJellyfinListCommands } from './main/runtime/jellyfin-cli-list'; import { createHandleJellyfinPlayCommand } from './main/runtime/jellyfin-cli-play'; import { createHandleJellyfinRemoteAnnounceCommand } from './main/runtime/jellyfin-cli-remote-announce'; +import { createPlayJellyfinItemInMpvHandler } from './main/runtime/jellyfin-playback-launch'; +import { createPreloadJellyfinExternalSubtitlesHandler } from './main/runtime/jellyfin-subtitle-preload'; import { createStartJellyfinRemoteSessionHandler, createStopJellyfinRemoteSessionHandler, } from './main/runtime/jellyfin-remote-session-lifecycle'; import { createHandleInitialArgsHandler } from './main/runtime/initial-args-handler'; +import { createBuildHandleInitialArgsMainDepsHandler } from './main/runtime/initial-args-main-deps'; import { createHandleTexthookerOnlyModeTransitionHandler } from './main/runtime/cli-command-prechecks'; import { createCliCommandContext } from './main/runtime/cli-command-context'; -import { - createBindMpvClientEventHandlers, - createHandleMpvConnectionChangeHandler, - createHandleMpvSubtitleTimingHandler, -} from './main/runtime/mpv-client-event-bindings'; +import { createBindMpvMainEventHandlersHandler } from './main/runtime/mpv-main-event-bindings'; +import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './main/runtime/mpv-main-event-main-deps'; +import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from './main/runtime/mpv-client-runtime-service-main-deps'; import { createMpvClientRuntimeServiceFactory } from './main/runtime/mpv-client-runtime-service'; import { createUpdateMpvSubtitleRenderMetricsHandler } from './main/runtime/mpv-subtitle-render-metrics'; import { @@ -185,7 +182,16 @@ import { createRefreshGlobalAndOverlayShortcutsHandler, createRegisterGlobalShortcutsHandler, } from './main/runtime/global-shortcuts'; +import { + createBuildGetConfiguredShortcutsMainDepsHandler, + createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler, + createBuildRegisterGlobalShortcutsMainDepsHandler, +} from './main/runtime/global-shortcuts-main-deps'; import { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './main/runtime/mpv-osd-log'; +import { + createBuildAppendToMpvLogMainDepsHandler, + createBuildShowMpvOsdMainDepsHandler, +} from './main/runtime/mpv-osd-log-main-deps'; import { createCancelNumericShortcutSessionHandler, createStartNumericShortcutSessionHandler, @@ -229,16 +235,44 @@ import { createCreateMainWindowHandler, createCreateOverlayWindowHandler, } from './main/runtime/overlay-window-factory'; +import { + createBuildCreateInvisibleWindowMainDepsHandler, + createBuildCreateMainWindowMainDepsHandler, + createBuildCreateOverlayWindowMainDepsHandler, +} from './main/runtime/overlay-window-factory-main-deps'; import { createBuildTrayMenuTemplateHandler, createResolveTrayIconPathHandler, } from './main/runtime/tray-main-actions'; +import { + createBuildResolveTrayIconPathMainDepsHandler, + createBuildTrayMenuTemplateMainDepsHandler, +} from './main/runtime/tray-main-deps'; +import { + createBuildDestroyTrayMainDepsHandler, + createBuildEnsureTrayMainDepsHandler, + createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler, + createBuildOpenYomitanSettingsMainDepsHandler, +} from './main/runtime/app-runtime-main-deps'; import { createEnsureYomitanExtensionLoadedHandler, createLoadYomitanExtensionHandler, } from './main/runtime/yomitan-extension-loader'; import { createBuildInitializeOverlayRuntimeOptionsHandler } from './main/runtime/overlay-runtime-options'; +import { createBuildInitializeOverlayRuntimeMainDepsHandler } from './main/runtime/overlay-runtime-options-main-deps'; import { createBuildCliCommandContextDepsHandler } from './main/runtime/cli-command-context-deps'; +import { createBuildCliCommandContextMainDepsHandler } from './main/runtime/cli-command-context-main-deps'; +import { + createOnWillQuitCleanupHandler, + createRestoreWindowsOnActivateHandler, + createShouldRestoreWindowsOnActivateHandler, +} from './main/runtime/app-lifecycle-actions'; +import { createBuildOnWillQuitCleanupDepsHandler } from './main/runtime/app-lifecycle-main-cleanup'; +import { + createBuildRestoreWindowsOnActivateMainDepsHandler, + createBuildShouldRestoreWindowsOnActivateMainDepsHandler, +} from './main/runtime/app-lifecycle-main-activate'; +import { createBuildStartupBootstrapRuntimeFactoryDepsHandler } from './main/runtime/startup-bootstrap-deps-builder'; import { buildRestartRequiredConfigMessage, createConfigHotReloadAppliedHandler, @@ -953,206 +987,62 @@ const ensureMpvConnectedForJellyfinPlayback = createEnsureMpvConnectedForJellyfi autoLaunchTimeoutMs: JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS, }); -async function playJellyfinItemInMpv(params: { - session: { - serverUrl: string; - accessToken: string; - userId: string; - username: string; - }; - clientInfo: ReturnType; - jellyfinConfig: ReturnType; - itemId: string; - audioStreamIndex?: number; - subtitleStreamIndex?: number; - startTimeTicksOverride?: number; - setQuitOnDisconnectArm?: boolean; -}): Promise { - const connected = await ensureMpvConnectedForJellyfinPlayback(); - if (!connected || !appState.mpvClient) { - throw new Error( - 'MPV not connected and auto-launch failed. Ensure mpv is installed and available in PATH.', - ); - } +const preloadJellyfinExternalSubtitles = createPreloadJellyfinExternalSubtitlesHandler({ + listJellyfinSubtitleTracks: (session, clientInfo, itemId) => + listJellyfinSubtitleTracksRuntime(session, clientInfo, itemId), + getMpvClient: () => appState.mpvClient, + sendMpvCommand: (command) => { + sendMpvCommandRuntime(appState.mpvClient, command); + }, + wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + logDebug: (message, error) => { + logger.debug(message, error); + }, +}); - const plan = await resolveJellyfinPlaybackPlanRuntime( - params.session, - params.clientInfo, - params.jellyfinConfig, - { - itemId: params.itemId, - audioStreamIndex: params.audioStreamIndex, - subtitleStreamIndex: params.subtitleStreamIndex, - }, - ); - - applyJellyfinMpvDefaults(appState.mpvClient); - sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'sub-auto', 'no']); - sendMpvCommandRuntime(appState.mpvClient, ['loadfile', plan.url, 'replace']); - if (params.setQuitOnDisconnectArm !== false) { +const playJellyfinItemInMpv = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: () => ensureMpvConnectedForJellyfinPlayback(), + getMpvClient: () => appState.mpvClient, + resolvePlaybackPlan: (params) => + resolveJellyfinPlaybackPlanRuntime( + params.session, + params.clientInfo, + params.jellyfinConfig as ReturnType, + { + itemId: params.itemId, + audioStreamIndex: params.audioStreamIndex ?? undefined, + subtitleStreamIndex: params.subtitleStreamIndex ?? undefined, + }, + ), + applyJellyfinMpvDefaults: (mpvClient) => + applyJellyfinMpvDefaults((mpvClient as unknown) as MpvIpcClient), + sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), + armQuitOnDisconnect: () => { jellyfinPlayQuitOnDisconnectArmed = false; setTimeout(() => { jellyfinPlayQuitOnDisconnectArmed = true; }, 3000); - } - sendMpvCommandRuntime(appState.mpvClient, [ - 'set_property', - 'force-media-title', - `[Jellyfin/${plan.mode}] ${plan.title}`, - ]); - sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'sid', 'no']); - setTimeout(() => { - sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'sid', 'no']); - }, 500); - - const startTimeTicks = - typeof params.startTimeTicksOverride === 'number' - ? Math.max(0, params.startTimeTicksOverride) - : plan.startTimeTicks; - if (startTimeTicks > 0) { - sendMpvCommandRuntime(appState.mpvClient, [ - 'seek', - jellyfinTicksToSecondsRuntime(startTimeTicks), - 'absolute+exact', - ]); - } - - void (async () => { - try { - const normalizeLang = (value: unknown): string => - String(value || '') - .trim() - .toLowerCase() - .replace(/_/g, '-'); - const isJapanese = (value: string): boolean => { - const v = normalizeLang(value); - return ( - v === 'ja' || - v === 'jp' || - v === 'jpn' || - v === 'japanese' || - v.startsWith('ja-') || - v.startsWith('jp-') - ); - }; - const isEnglish = (value: string): boolean => { - const v = normalizeLang(value); - return ( - v === 'en' || - v === 'eng' || - v === 'english' || - v === 'enus' || - v === 'en-us' || - v.startsWith('en-') - ); - }; - const isLikelyHearingImpaired = (title: string): boolean => - /\b(hearing impaired|sdh|closed captions?|cc)\b/i.test(title); - const pickBestTrackId = ( - tracks: Array<{ - id: number; - lang: string; - title: string; - external: boolean; - }>, - languageMatcher: (value: string) => boolean, - excludeId: number | null = null, - ): number | null => { - const ranked = tracks - .filter((track) => languageMatcher(track.lang)) - .filter((track) => track.id !== excludeId) - .map((track) => ({ - track, - score: - (track.external ? 100 : 0) + - (isLikelyHearingImpaired(track.title) ? -10 : 10) + - (/\bdefault\b/i.test(track.title) ? 3 : 0), - })) - .sort((a, b) => b.score - a.score); - return ranked[0]?.track.id ?? null; - }; - - const tracks = await listJellyfinSubtitleTracksRuntime( - params.session, - params.clientInfo, - params.itemId, - ); - const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl)); - - if (externalTracks.length === 0) return; - await new Promise((resolve) => setTimeout(resolve, 300)); - const seenUrls = new Set(); - for (const track of externalTracks) { - if (!track.deliveryUrl) continue; - if (seenUrls.has(track.deliveryUrl)) continue; - seenUrls.add(track.deliveryUrl); - const labelBase = (track.title || track.language || '').trim(); - const label = labelBase || `Jellyfin Subtitle ${track.index}`; - sendMpvCommandRuntime(appState.mpvClient, [ - 'sub-add', - track.deliveryUrl, - 'cached', - label, - track.language || '', - ]); - } - await new Promise((resolve) => setTimeout(resolve, 250)); - const trackListRaw = await appState.mpvClient?.requestProperty('track-list'); - const subtitleTracks = Array.isArray(trackListRaw) - ? trackListRaw - .filter( - (track): track is Record => - Boolean(track) && - typeof track === 'object' && - track.type === 'sub' && - typeof track.id === 'number', - ) - .map((track) => ({ - id: track.id as number, - lang: String(track.lang || ''), - title: String(track.title || ''), - external: track.external === true, - })) - : []; - - const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese); - if (japanesePrimaryId !== null) { - sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'sid', japanesePrimaryId]); - } else { - sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'sid', 'no']); - } - - const englishSecondaryId = pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId); - if (englishSecondaryId !== null) { - sendMpvCommandRuntime(appState.mpvClient, [ - 'set_property', - 'secondary-sid', - englishSecondaryId, - ]); - } - } catch (error) { - logger.debug('Failed to preload Jellyfin external subtitles', error); - } - })(); - - activeJellyfinRemotePlayback = { - itemId: params.itemId, - mediaSourceId: undefined, - audioStreamIndex: plan.audioStreamIndex, - subtitleStreamIndex: plan.subtitleStreamIndex, - playMethod: plan.mode === 'direct' ? 'DirectPlay' : 'Transcode', - }; - jellyfinRemoteLastProgressAtMs = 0; - void appState.jellyfinRemoteSession?.reportPlaying({ - itemId: params.itemId, - mediaSourceId: undefined, - playMethod: activeJellyfinRemotePlayback.playMethod, - audioStreamIndex: plan.audioStreamIndex, - subtitleStreamIndex: plan.subtitleStreamIndex, - eventName: 'start', - }); - showMpvOsd(`Jellyfin ${plan.mode}: ${plan.title}`); -} + }, + schedule: (callback, delayMs) => { + setTimeout(callback, delayMs); + }, + convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks), + preloadExternalSubtitles: (params) => { + void preloadJellyfinExternalSubtitles(params); + }, + setActivePlayback: (state) => { + activeJellyfinRemotePlayback = state as ActiveJellyfinRemotePlaybackState; + }, + setLastProgressAtMs: (value) => { + jellyfinRemoteLastProgressAtMs = value; + }, + reportPlaying: (payload) => { + void appState.jellyfinRemoteSession?.reportPlaying(payload); + }, + showMpvOsd: (text) => { + showMpvOsd(text); + }, +}); const handleJellyfinAuthCommands = createHandleJellyfinAuthCommands({ patchRawConfig: (patch) => { @@ -1212,61 +1102,15 @@ const stopJellyfinRemoteSession = createStopJellyfinRemoteSessionHandler({ }, }); -async function runJellyfinCommand(args: CliArgs): Promise { - const jellyfinConfig = getResolvedJellyfinConfig(); - const serverUrl = - args.jellyfinServer?.trim() || jellyfinConfig.serverUrl || DEFAULT_CONFIG.jellyfin.serverUrl; - const clientInfo = getJellyfinClientInfo(jellyfinConfig); - - if ( - await handleJellyfinAuthCommands({ - args, - jellyfinConfig, - serverUrl, - clientInfo, - }) - ) { - return; - } - - const accessToken = jellyfinConfig.accessToken; - const userId = jellyfinConfig.userId; - if (!serverUrl || !accessToken || !userId) { - throw new Error('Missing Jellyfin session. Run --jellyfin-login first.'); - } - const session = { - serverUrl, - accessToken, - userId, - username: jellyfinConfig.username, - }; - - if (await handleJellyfinRemoteAnnounceCommand(args)) { - return; - } - - if ( - await handleJellyfinListCommands({ - args, - session, - clientInfo, - jellyfinConfig, - }) - ) { - return; - } - - if ( - await handleJellyfinPlayCommand({ - args, - session, - clientInfo, - jellyfinConfig, - }) - ) { - return; - } -} +const runJellyfinCommand = createRunJellyfinCommandHandler({ + getJellyfinConfig: () => getResolvedJellyfinConfig(), + defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl, + getJellyfinClientInfo: (jellyfinConfig) => getJellyfinClientInfo(jellyfinConfig), + handleAuthCommands: (params) => handleJellyfinAuthCommands(params), + handleRemoteAnnounceCommand: (args) => handleJellyfinRemoteAnnounceCommand(args), + handleListCommands: (params) => handleJellyfinListCommands(params), + handlePlayCommand: (params) => handleJellyfinPlayCommand(params), +}); const notifyAnilistSetup = createNotifyAnilistSetupHandler({ hasMpvClient: () => Boolean(appState.mpvClient), @@ -1319,167 +1163,83 @@ const registerSubminerProtocolClient = createRegisterSubminerProtocolClientHandl }); function openAnilistSetupWindow(): void { - const maybeFocusExistingAnilistSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({ - getSetupWindow: () => appState.anilistSetupWindow, - }); - if (maybeFocusExistingAnilistSetupWindow()) { - return; - } - - const setupWindow = new BrowserWindow({ - width: 1000, - height: 760, - title: 'Anilist Setup', - show: true, - autoHideMenuBar: true, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - }, - }); - const authorizeUrl = buildAnilistSetupUrl({ - authorizeUrl: ANILIST_SETUP_CLIENT_ID_URL, - clientId: ANILIST_DEFAULT_CLIENT_ID, - responseType: ANILIST_SETUP_RESPONSE_TYPE, - }); - const consumeCallbackUrl = (rawUrl: string): boolean => consumeAnilistSetupTokenFromUrl(rawUrl); - const openSetupInBrowser = () => - openAnilistSetupInBrowser({ - authorizeUrl, - openExternal: (url) => shell.openExternal(url), - logError: (message, error) => logger.error(message, error), - }); - const loadManualTokenEntry = () => - loadAnilistManualTokenEntry({ - setupWindow, - authorizeUrl, - developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, - logWarn: (message, data) => logger.warn(message, data), - }); - const handleManualAnilistSetupSubmission = createHandleManualAnilistSetupSubmissionHandler({ - consumeCallbackUrl: (rawUrl) => consumeCallbackUrl(rawUrl), + createOpenAnilistSetupWindowHandler({ + maybeFocusExistingSetupWindow: createMaybeFocusExistingAnilistSetupWindowHandler({ + getSetupWindow: () => appState.anilistSetupWindow, + }), + createSetupWindow: () => + new BrowserWindow({ + width: 1000, + height: 760, + title: 'Anilist Setup', + show: true, + autoHideMenuBar: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }), + buildAuthorizeUrl: () => + buildAnilistSetupUrl({ + authorizeUrl: ANILIST_SETUP_CLIENT_ID_URL, + clientId: ANILIST_DEFAULT_CLIENT_ID, + responseType: ANILIST_SETUP_RESPONSE_TYPE, + }), + consumeCallbackUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl), + openSetupInBrowser: (authorizeUrl) => + openAnilistSetupInBrowser({ + authorizeUrl, + openExternal: (url) => shell.openExternal(url), + logError: (message, error) => logger.error(message, error), + }), + loadManualTokenEntry: (setupWindow, authorizeUrl) => + loadAnilistManualTokenEntry({ + setupWindow: setupWindow as BrowserWindow, + authorizeUrl, + developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, + logWarn: (message, data) => logger.warn(message, data), + }), redirectUri: ANILIST_REDIRECT_URI, - logWarn: (message) => logger.warn(message), - }); - const anilistSetupFallback = createAnilistSetupFallbackHandler({ - authorizeUrl, developerSettingsUrl: ANILIST_DEVELOPER_SETTINGS_URL, - setupWindow, - openSetupInBrowser, - loadManualTokenEntry, - logError: (message, details) => logger.error(message, details), - logWarn: (message) => logger.warn(message), - }); - const handleAnilistSetupWindowOpen = createAnilistSetupWindowOpenHandler({ isAllowedExternalUrl: (url) => isAllowedAnilistExternalUrl(url), - openExternal: (url) => { - void shell.openExternal(url); - }, - logWarn: (message, details) => logger.warn(message, details), - }); - const handleAnilistSetupWillNavigate = createAnilistSetupWillNavigateHandler({ - handleManualSubmission: (url) => handleManualAnilistSetupSubmission(url), - consumeCallbackUrl: (url) => consumeCallbackUrl(url), - redirectUri: ANILIST_REDIRECT_URI, isAllowedNavigationUrl: (url) => isAllowedAnilistSetupNavigationUrl(url), logWarn: (message, details) => logger.warn(message, details), - }); - const handleAnilistSetupWillRedirect = createAnilistSetupWillRedirectHandler({ - consumeCallbackUrl: (url) => consumeCallbackUrl(url), - }); - const handleAnilistSetupDidNavigate = createAnilistSetupDidNavigateHandler({ - consumeCallbackUrl: (url) => consumeCallbackUrl(url), - }); - const handleAnilistSetupDidFailLoad = createAnilistSetupDidFailLoadHandler({ - onLoadFailure: (details) => anilistSetupFallback.onLoadFailure(details), - }); - const handleAnilistSetupDidFinishLoad = createAnilistSetupDidFinishLoadHandler({ - getLoadedUrl: () => setupWindow.webContents.getURL(), - onBlankPageLoaded: () => anilistSetupFallback.onBlankPageLoaded(), - }); - const handleAnilistSetupWindowClosed = createHandleAnilistSetupWindowClosedHandler({ + logError: (message, details) => logger.error(message, details), clearSetupWindow: () => { appState.anilistSetupWindow = null; }, setSetupPageOpened: (opened) => { appState.anilistSetupPageOpened = opened; }, - }); - const handleAnilistSetupWindowOpened = createHandleAnilistSetupWindowOpenedHandler({ - setSetupWindow: () => { - appState.anilistSetupWindow = setupWindow; + setSetupWindow: (setupWindow) => { + appState.anilistSetupWindow = setupWindow as BrowserWindow; }, - setSetupPageOpened: (opened) => { - appState.anilistSetupPageOpened = opened; + openExternal: (url) => { + void shell.openExternal(url); }, - }); - - setupWindow.webContents.setWindowOpenHandler(({ url }) => handleAnilistSetupWindowOpen({ url })); - setupWindow.webContents.on('will-navigate', (event, url) => { - handleAnilistSetupWillNavigate({ - url, - preventDefault: () => event.preventDefault(), - }); - }); - setupWindow.webContents.on('will-redirect', (event, url) => { - handleAnilistSetupWillRedirect({ - url, - preventDefault: () => event.preventDefault(), - }); - }); - setupWindow.webContents.on('did-navigate', (_event, url) => { - handleAnilistSetupDidNavigate(url); - }); - - setupWindow.webContents.on( - 'did-fail-load', - (_event, errorCode, errorDescription, validatedURL) => { - handleAnilistSetupDidFailLoad({ - errorCode, - errorDescription, - validatedURL, - }); - }, - ); - - setupWindow.webContents.on('did-finish-load', () => { - handleAnilistSetupDidFinishLoad(); - }); - - loadManualTokenEntry(); - - setupWindow.on('closed', () => { - handleAnilistSetupWindowClosed(); - }); - - handleAnilistSetupWindowOpened(); + })(); } function openJellyfinSetupWindow(): void { - const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler({ - getSetupWindow: () => appState.jellyfinSetupWindow, - }); - if (maybeFocusExistingJellyfinSetupWindow()) { - return; - } - - const setupWindow = new BrowserWindow({ - width: 520, - height: 560, - title: 'Jellyfin Setup', - show: true, - autoHideMenuBar: true, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - }, - }); - - const defaults = getResolvedJellyfinConfig(); - const defaultServer = defaults.serverUrl || 'http://127.0.0.1:8096'; - const defaultUser = defaults.username || ''; - const formHtml = buildJellyfinSetupFormHtml(defaultServer, defaultUser); - const handleJellyfinSetupSubmission = createHandleJellyfinSetupSubmissionHandler({ + createOpenJellyfinSetupWindowHandler({ + maybeFocusExistingSetupWindow: createMaybeFocusExistingJellyfinSetupWindowHandler({ + getSetupWindow: () => appState.jellyfinSetupWindow, + }), + createSetupWindow: () => + new BrowserWindow({ + width: 520, + height: 560, + title: 'Jellyfin Setup', + show: true, + autoHideMenuBar: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }), + getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), + buildSetupFormHtml: (defaultServer, defaultUser) => + buildJellyfinSetupFormHtml(defaultServer, defaultUser), parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), authenticateWithPassword: (server, username, password, clientInfo) => authenticateWithPasswordRuntime(server, username, password, clientInfo), @@ -1498,42 +1258,14 @@ function openJellyfinSetupWindow(): void { logInfo: (message) => logger.info(message), logError: (message, error) => logger.error(message, error), showMpvOsd: (message) => showMpvOsd(message), - closeSetupWindow: () => { - if (!setupWindow.isDestroyed()) { - setupWindow.close(); - } - }, - }); - const handleJellyfinSetupNavigation = createHandleJellyfinSetupNavigationHandler({ - setupSchemePrefix: 'subminer://jellyfin-setup', - handleSubmission: (rawUrl) => handleJellyfinSetupSubmission(rawUrl), - logError: (message, error) => logger.error(message, error), - }); - const handleJellyfinSetupWindowClosed = createHandleJellyfinSetupWindowClosedHandler({ clearSetupWindow: () => { appState.jellyfinSetupWindow = null; }, - }); - const handleJellyfinSetupWindowOpened = createHandleJellyfinSetupWindowOpenedHandler({ - setSetupWindow: () => { - appState.jellyfinSetupWindow = setupWindow; + setSetupWindow: (window) => { + appState.jellyfinSetupWindow = window; }, - }); - - setupWindow.webContents.on('will-navigate', (event, url) => { - handleJellyfinSetupNavigation({ - url, - preventDefault: () => event.preventDefault(), - }); - }); - - void setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(formHtml)}`); - - setupWindow.on('closed', () => { - handleJellyfinSetupWindowClosed(); - }); - - handleJellyfinSetupWindowOpened(); + encodeURIComponent: (value) => encodeURIComponent(value), + })(); } const refreshAnilistClientSecretState = createRefreshAnilistClientSecretStateHandler({ @@ -1560,40 +1292,62 @@ const refreshAnilistClientSecretState = createRefreshAnilistClientSecretStateHan now: () => Date.now(), }); -function getCurrentAnilistMediaKey(): string | null { - const path = appState.currentMediaPath?.trim(); - return path && path.length > 0 ? path : null; -} - -function resetAnilistMediaTracking(mediaKey: string | null): void { - anilistCurrentMediaKey = mediaKey; - anilistCurrentMediaDurationSec = null; - anilistCurrentMediaGuess = null; - anilistCurrentMediaGuessPromise = null; - anilistLastDurationProbeAtMs = 0; -} - -const getAnilistMediaGuessRuntimeState = () => ({ - mediaKey: anilistCurrentMediaKey, - mediaDurationSec: anilistCurrentMediaDurationSec, - mediaGuess: anilistCurrentMediaGuess, - mediaGuessPromise: anilistCurrentMediaGuessPromise, - lastDurationProbeAtMs: anilistLastDurationProbeAtMs, +const getCurrentAnilistMediaKey = createGetCurrentAnilistMediaKeyHandler({ + getCurrentMediaPath: () => appState.currentMediaPath, }); -const setAnilistMediaGuessRuntimeState = (state: { - mediaKey: string | null; - mediaDurationSec: number | null; - mediaGuess: AnilistMediaGuess | null; - mediaGuessPromise: Promise | null; - lastDurationProbeAtMs: number; -}) => { - anilistCurrentMediaKey = state.mediaKey; - anilistCurrentMediaDurationSec = state.mediaDurationSec; - anilistCurrentMediaGuess = state.mediaGuess; - anilistCurrentMediaGuessPromise = state.mediaGuessPromise; - anilistLastDurationProbeAtMs = state.lastDurationProbeAtMs; -}; +const resetAnilistMediaTracking = createResetAnilistMediaTrackingHandler({ + setMediaKey: (value) => { + anilistCurrentMediaKey = value; + }, + setMediaDurationSec: (value) => { + anilistCurrentMediaDurationSec = value; + }, + setMediaGuess: (value) => { + anilistCurrentMediaGuess = value; + }, + setMediaGuessPromise: (value) => { + anilistCurrentMediaGuessPromise = value; + }, + setLastDurationProbeAtMs: (value) => { + anilistLastDurationProbeAtMs = value; + }, +}); + +const getAnilistMediaGuessRuntimeState = createGetAnilistMediaGuessRuntimeStateHandler({ + getMediaKey: () => anilistCurrentMediaKey, + getMediaDurationSec: () => anilistCurrentMediaDurationSec, + getMediaGuess: () => anilistCurrentMediaGuess, + getMediaGuessPromise: () => anilistCurrentMediaGuessPromise, + getLastDurationProbeAtMs: () => anilistLastDurationProbeAtMs, +}); + +const setAnilistMediaGuessRuntimeState = createSetAnilistMediaGuessRuntimeStateHandler({ + setMediaKey: (value) => { + anilistCurrentMediaKey = value; + }, + setMediaDurationSec: (value) => { + anilistCurrentMediaDurationSec = value; + }, + setMediaGuess: (value) => { + anilistCurrentMediaGuess = value; + }, + setMediaGuessPromise: (value) => { + anilistCurrentMediaGuessPromise = value; + }, + setLastDurationProbeAtMs: (value) => { + anilistLastDurationProbeAtMs = value; + }, +}); + +const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler({ + setMediaGuess: (value) => { + anilistCurrentMediaGuess = value; + }, + setMediaGuessPromise: (value) => { + anilistCurrentMediaGuessPromise = value; + }, +}); const maybeProbeAnilistDuration = createMaybeProbeAnilistDurationHandler({ getState: () => getAnilistMediaGuessRuntimeState(), @@ -1737,8 +1491,70 @@ registerProtocolUrlHandlers({ }, }); -const startupState = runStartupBootstrapRuntime( - createStartupBootstrapRuntimeDeps({ +const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler( + createBuildOnWillQuitCleanupDepsHandler({ + destroyTray: () => destroyTray(), + stopConfigHotReload: () => configHotReloadRuntime.stop(), + restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(), + unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), + stopSubtitleWebsocket: () => subtitleWsService.stop(), + stopTexthookerService: () => texthookerService.stop(), + getYomitanParserWindow: () => appState.yomitanParserWindow, + clearYomitanParserState: () => { + appState.yomitanParserWindow = null; + appState.yomitanParserReadyPromise = null; + appState.yomitanParserInitPromise = null; + }, + getWindowTracker: () => appState.windowTracker, + getMpvSocket: () => appState.mpvClient?.socket ?? null, + getReconnectTimer: () => appState.reconnectTimer, + clearReconnectTimerRef: () => { + appState.reconnectTimer = null; + }, + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + getImmersionTracker: () => appState.immersionTracker, + clearImmersionTracker: () => { + appState.immersionTracker = null; + }, + getAnkiIntegration: () => appState.ankiIntegration, + getAnilistSetupWindow: () => appState.anilistSetupWindow, + clearAnilistSetupWindow: () => { + appState.anilistSetupWindow = null; + }, + getJellyfinSetupWindow: () => appState.jellyfinSetupWindow, + clearJellyfinSetupWindow: () => { + appState.jellyfinSetupWindow = null; + }, + stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), + })(), +); + +const shouldRestoreWindowsOnActivateHandler = createShouldRestoreWindowsOnActivateHandler( + createBuildShouldRestoreWindowsOnActivateMainDepsHandler({ + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + getAllWindowCount: () => BrowserWindow.getAllWindows().length, + })(), +); + +const restoreWindowsOnActivateHandler = createRestoreWindowsOnActivateHandler( + createBuildRestoreWindowsOnActivateMainDepsHandler({ + createMainWindow: () => { + createMainWindow(); + }, + createInvisibleWindow: () => { + createInvisibleWindow(); + }, + updateVisibleOverlayVisibility: () => { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, + updateInvisibleOverlayVisibility: () => { + overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); + }, + })(), +); + +const buildStartupBootstrapRuntimeFactoryDepsHandler = + createBuildStartupBootstrapRuntimeFactoryDepsHandler({ argv: process.argv, parseArgs: (argv: string[]) => parseArgs(argv), setLogLevel: (level: string, source: LogLevelSource) => { @@ -1887,59 +1703,15 @@ const startupState = runStartupBootstrapRuntime( }, now: () => Date.now(), }), - onWillQuitCleanup: () => { - destroyTray(); - configHotReloadRuntime.stop(); - restorePreviousSecondarySubVisibility(); - globalShortcut.unregisterAll(); - subtitleWsService.stop(); - texthookerService.stop(); - if (appState.yomitanParserWindow && !appState.yomitanParserWindow.isDestroyed()) { - appState.yomitanParserWindow.destroy(); - } - appState.yomitanParserWindow = null; - appState.yomitanParserReadyPromise = null; - appState.yomitanParserInitPromise = null; - if (appState.windowTracker) { - appState.windowTracker.stop(); - } - if (appState.mpvClient && appState.mpvClient.socket) { - appState.mpvClient.socket.destroy(); - } - if (appState.reconnectTimer) { - clearTimeout(appState.reconnectTimer); - } - if (appState.subtitleTimingTracker) { - appState.subtitleTimingTracker.destroy(); - } - if (appState.immersionTracker) { - appState.immersionTracker.destroy(); - appState.immersionTracker = null; - } - if (appState.ankiIntegration) { - appState.ankiIntegration.destroy(); - } - if (appState.anilistSetupWindow) { - appState.anilistSetupWindow.destroy(); - } - appState.anilistSetupWindow = null; - if (appState.jellyfinSetupWindow) { - appState.jellyfinSetupWindow.destroy(); - } - appState.jellyfinSetupWindow = null; - stopJellyfinRemoteSession(); - }, - shouldRestoreWindowsOnActivate: () => - appState.overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, - restoreWindowsOnActivate: () => { - createMainWindow(); - createInvisibleWindow(); - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); - }, + onWillQuitCleanup: () => onWillQuitCleanupHandler(), + shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(), + restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(), shouldQuitOnWindowAllClosed: () => !appState.backgroundMode, }), - }), + }); + +const startupState = runStartupBootstrapRuntime( + createStartupBootstrapRuntimeDeps(buildStartupBootstrapRuntimeFactoryDepsHandler()), ); applyStartupState(appState, startupState); @@ -1962,106 +1734,77 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): } function handleInitialArgs(): void { - createHandleInitialArgsHandler({ - getInitialArgs: () => appState.initialArgs, - isBackgroundMode: () => appState.backgroundMode, - ensureTray: () => ensureTray(), - isTexthookerOnlyMode: () => appState.texthookerOnlyMode, - hasImmersionTracker: () => Boolean(appState.immersionTracker), - getMpvClient: () => appState.mpvClient, - logInfo: (message) => logger.info(message), - handleCliCommand: (args, source) => handleCliCommand(args, source), - })(); + createHandleInitialArgsHandler( + createBuildHandleInitialArgsMainDepsHandler({ + getInitialArgs: () => appState.initialArgs, + isBackgroundMode: () => appState.backgroundMode, + ensureTray: () => ensureTray(), + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + hasImmersionTracker: () => Boolean(appState.immersionTracker), + getMpvClient: () => appState.mpvClient, + logInfo: (message) => logger.info(message), + handleCliCommand: (args, source) => handleCliCommand(args, source), + })(), + )(); } -function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void { - const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({ - reportJellyfinRemoteStopped: () => { - void reportJellyfinRemoteStopped(); - }, - hasInitialJellyfinPlayArg: () => Boolean(appState.initialArgs?.jellyfinPlay), - isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - isQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed, +const bindMpvClientEventHandlers = createBindMpvMainEventHandlersHandler( + createBuildBindMpvMainEventHandlersMainDepsHandler({ + appState, + getQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed, scheduleQuitCheck: (callback) => { setTimeout(callback, 500); }, - isMpvConnected: () => Boolean(appState.mpvClient?.connected), quitApp: () => app.quit(), - }); - const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({ - recordImmersionSubtitleLine: (text, start, end) => { - appState.immersionTracker?.recordSubtitleLine(text, start, end); - }, - hasSubtitleTimingTracker: () => Boolean(appState.subtitleTimingTracker), - recordSubtitleTiming: (text, start, end) => { - appState.subtitleTimingTracker?.recordSubtitle(text, start, end); + reportJellyfinRemoteStopped: () => { + void reportJellyfinRemoteStopped(); }, maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(), - logError: (message, error) => logger.error(message, error), - }); - createBindMpvClientEventHandlers({ - onConnectionChange: (payload) => { - handleMpvConnectionChange(payload); + logSubtitleTimingError: (message, error) => logger.error(message, error), + broadcastToOverlayWindows: (channel, payload) => { + broadcastToOverlayWindows(channel, payload); }, - onSubtitleChange: ({ text }) => { - appState.currentSubText = text; - broadcastToOverlayWindows('subtitle:set', { text, tokens: null }); + onSubtitleChange: (text) => { subtitleProcessingController.onSubtitleChange(text); }, - onSubtitleAssChange: ({ text }) => { - appState.currentSubAssText = text; - broadcastToOverlayWindows('subtitle-ass:set', text); - }, - onSecondarySubtitleChange: ({ text }) => { - broadcastToOverlayWindows('secondary-subtitle:set', text); - }, - onSubtitleTiming: (payload) => { - handleMpvSubtitleTiming(payload); - }, - onMediaPathChange: ({ path }) => { + updateCurrentMediaPath: (path) => { mediaRuntime.updateCurrentMediaPath(path); - if (!path) { - void reportJellyfinRemoteStopped(); - } - const mediaKey = getCurrentAnilistMediaKey(); + }, + getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(), + resetAnilistMediaTracking: (mediaKey) => { resetAnilistMediaTracking(mediaKey); - if (mediaKey) { - void maybeProbeAnilistDuration(mediaKey); - void ensureAnilistMediaGuess(mediaKey); - } + }, + maybeProbeAnilistDuration: (mediaKey) => { + void maybeProbeAnilistDuration(mediaKey); + }, + ensureAnilistMediaGuess: (mediaKey) => { + void ensureAnilistMediaGuess(mediaKey); + }, + syncImmersionMediaState: () => { immersionMediaRuntime.syncFromCurrentMediaState(); }, - onMediaTitleChange: ({ title }) => { + updateCurrentMediaTitle: (title) => { mediaRuntime.updateCurrentMediaTitle(title); - anilistCurrentMediaGuess = null; - anilistCurrentMediaGuessPromise = null; - appState.immersionTracker?.handleMediaTitleUpdate(title); - immersionMediaRuntime.syncFromCurrentMediaState(); }, - onTimePosChange: ({ time }) => { - appState.immersionTracker?.recordPlaybackPosition(time); - void reportJellyfinRemoteProgress(false); + resetAnilistMediaGuessState: () => { + resetAnilistMediaGuessState(); }, - onPauseChange: ({ paused }) => { - appState.immersionTracker?.recordPauseState(paused); - void reportJellyfinRemoteProgress(true); + reportJellyfinRemoteProgress: (forceImmediate) => { + void reportJellyfinRemoteProgress(forceImmediate); }, - onSubtitleMetricsChange: ({ patch }) => { + updateSubtitleRenderMetrics: (patch) => { updateMpvSubtitleRenderMetrics(patch as Partial); }, - onSecondarySubtitleVisibility: ({ visible }) => { - appState.previousSecondarySubVisibility = visible; - }, - })(mpvClient); -} + })(), +); function createMpvClientRuntimeService(): MpvIpcClient { - return createMpvClientRuntimeServiceFactory({ - createClient: MpvIpcClient, - socketPath: appState.mpvSocketPath, - options: { + return createMpvClientRuntimeServiceFactory( + createBuildMpvClientRuntimeServiceFactoryDepsHandler({ + createClient: MpvIpcClient, + getSocketPath: () => appState.mpvSocketPath, getResolvedConfig: () => getResolvedConfig(), - autoStartOverlay: appState.autoStartOverlay, + isAutoStartOverlayEnabled: () => appState.autoStartOverlay, setOverlayVisible: (visible: boolean) => setOverlayVisible(visible), shouldBindVisibleOverlayToMpvSubVisibility: () => configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(), @@ -2070,9 +1813,9 @@ function createMpvClientRuntimeService(): MpvIpcClient { setReconnectTimer: (timer: ReturnType | null) => { appState.reconnectTimer = timer; }, - }, - bindEventHandlers: (client) => bindMpvClientEventHandlers(client), - })(); + bindEventHandlers: (client) => bindMpvClientEventHandlers(client), + })(), + )(); } const updateMpvSubtitleRenderMetricsRuntime = createUpdateMpvSubtitleRenderMetricsHandler({ @@ -2221,91 +1964,47 @@ function buildTrayMenu(): Menu { } function ensureTray(): void { - createEnsureTrayHandler({ - getTray: () => appTray, - setTray: (tray) => { - appTray = tray as Tray | null; - }, - buildTrayMenu: () => buildTrayMenu(), - resolveTrayIconPath: () => resolveTrayIconPath(), - createImageFromPath: (iconPath) => nativeImage.createFromPath(iconPath), - createEmptyImage: () => nativeImage.createEmpty(), - createTray: (icon) => new Tray(icon as never), - trayTooltip: TRAY_TOOLTIP, - platform: process.platform, - logWarn: (message) => logger.warn(message), - ensureOverlayVisibleFromTrayClick: () => { - if (!appState.overlayRuntimeInitialized) { - initializeOverlayRuntime(); - } - setVisibleOverlayVisible(true); - }, - })(); + ensureTrayHandler(); } function destroyTray(): void { - createDestroyTrayHandler({ - getTray: () => appTray, - setTray: (tray) => { - appTray = tray as Tray | null; - }, - })(); + destroyTrayHandler(); } function initializeOverlayRuntime(): void { - createInitializeOverlayRuntimeHandler({ - isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - initializeOverlayRuntimeCore: (options) => initializeOverlayRuntimeCore(options), - buildOptions: () => buildInitializeOverlayRuntimeOptionsHandler(), - setInvisibleOverlayVisible: (visible) => { - overlayManager.setInvisibleOverlayVisible(visible); - }, - setOverlayRuntimeInitialized: (initialized) => { - appState.overlayRuntimeInitialized = initialized; - }, - startBackgroundWarmups: () => startBackgroundWarmups(), - })(); + initializeOverlayRuntimeHandler(); } function openYomitanSettings(): void { - createOpenYomitanSettingsHandler({ - ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), - openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow }) => { - openYomitanSettingsWindow({ - yomitanExt: yomitanExt as Extension, - getExistingWindow: () => getExistingWindow() as BrowserWindow | null, - setWindow: (window) => setWindow(window as BrowserWindow | null), - }); - }, - getExistingWindow: () => appState.yomitanSettingsWindow, - setWindow: (window) => { - appState.yomitanSettingsWindow = window as BrowserWindow | null; - }, - logWarn: (message) => logger.warn(message), - logError: (message, error) => logger.error(message, error), - })(); + openYomitanSettingsHandler(); } const getConfiguredShortcutsHandler = createGetConfiguredShortcutsHandler({ - getResolvedConfig: () => getResolvedConfig(), - defaultConfig: DEFAULT_CONFIG, - resolveConfiguredShortcuts, + ...createBuildGetConfiguredShortcutsMainDepsHandler({ + getResolvedConfig: () => getResolvedConfig(), + defaultConfig: DEFAULT_CONFIG, + resolveConfiguredShortcuts, + })(), }); const registerGlobalShortcutsHandler = createRegisterGlobalShortcutsHandler({ - getConfiguredShortcuts: () => getConfiguredShortcuts(), - registerGlobalShortcutsCore, - onToggleVisibleOverlay: () => toggleVisibleOverlay(), - onToggleInvisibleOverlay: () => toggleInvisibleOverlay(), - onOpenYomitanSettings: () => openYomitanSettings(), - isDev, - getMainWindow: () => overlayManager.getMainWindow(), + ...createBuildRegisterGlobalShortcutsMainDepsHandler({ + getConfiguredShortcuts: () => getConfiguredShortcuts(), + registerGlobalShortcutsCore, + toggleVisibleOverlay: () => toggleVisibleOverlay(), + toggleInvisibleOverlay: () => toggleInvisibleOverlay(), + openYomitanSettings: () => openYomitanSettings(), + isDev, + getMainWindow: () => overlayManager.getMainWindow(), + })(), }); const refreshGlobalAndOverlayShortcutsHandler = createRefreshGlobalAndOverlayShortcutsHandler({ - unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), - registerGlobalShortcuts: () => registerGlobalShortcuts(), - syncOverlayShortcuts: () => syncOverlayShortcuts(), + ...createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler({ + unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(), + registerGlobalShortcuts: () => registerGlobalShortcuts(), + syncOverlayShortcuts: () => syncOverlayShortcuts(), + })(), }); function registerGlobalShortcuts(): void { @@ -2338,18 +2037,23 @@ function cycleSecondarySubMode(): void { } const appendToMpvLogHandler = createAppendToMpvLogHandler({ - logPath: DEFAULT_MPV_LOG_PATH, - dirname: (targetPath) => path.dirname(targetPath), - mkdirSync: (targetPath, options) => fs.mkdirSync(targetPath, options), - appendFileSync: (targetPath, data, options) => fs.appendFileSync(targetPath, data, options), - now: () => new Date(), + ...createBuildAppendToMpvLogMainDepsHandler({ + logPath: DEFAULT_MPV_LOG_PATH, + dirname: (targetPath) => path.dirname(targetPath), + mkdirSync: (targetPath, options) => fs.mkdirSync(targetPath, options), + appendFileSync: (targetPath, data, options) => fs.appendFileSync(targetPath, data, options), + now: () => new Date(), + })(), }); const showMpvOsdHandler = createShowMpvOsdHandler({ - appendToMpvLog: (message) => appendToMpvLog(message), - showMpvOsdRuntime, - getMpvClient: () => appState.mpvClient, - logInfo: (line) => logger.info(line), + ...createBuildShowMpvOsdMainDepsHandler({ + appendToMpvLog: (message) => appendToMpvLog(message), + showMpvOsdRuntime: (mpvClient, text, fallbackLog) => + showMpvOsdRuntime(mpvClient as never, text, fallbackLog), + getMpvClient: () => appState.mpvClient, + logInfo: (line) => logger.info(line), + })(), }); function showMpvOsd(text: string): void { @@ -2560,106 +2264,136 @@ const handleMpvCommandFromIpcHandler = createHandleMpvCommandFromIpcHandler({ const runSubsyncManualFromIpcHandler = createRunSubsyncManualFromIpcHandler({ runManualFromIpc: (request: SubsyncManualRunRequest) => subsyncRuntime.runManualFromIpc(request), }); -const buildCliCommandContextDepsHandler = createBuildCliCommandContextDepsHandler({ - getSocketPath: () => appState.mpvSocketPath, - setSocketPath: (socketPath: string) => { - appState.mpvSocketPath = socketPath; - }, - getMpvClient: () => appState.mpvClient, - showOsd: (text: string) => showMpvOsd(text), - texthookerService, - getTexthookerPort: () => appState.texthookerPort, - setTexthookerPort: (port: number) => { - appState.texthookerPort = port; - }, - shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, - openExternal: (url: string) => shell.openExternal(url), - logBrowserOpenError: (url: string, error: unknown) => - logger.error(`Failed to open browser for texthooker URL: ${url}`, error), - isOverlayInitialized: () => appState.overlayRuntimeInitialized, - initializeOverlay: () => initializeOverlayRuntime(), - toggleVisibleOverlay: () => toggleVisibleOverlay(), - toggleInvisibleOverlay: () => toggleInvisibleOverlay(), - setVisibleOverlay: (visible: boolean) => setVisibleOverlayVisible(visible), - setInvisibleOverlay: (visible: boolean) => setInvisibleOverlayVisible(visible), - copyCurrentSubtitle: () => copyCurrentSubtitle(), - startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), - mineSentenceCard: () => mineSentenceCard(), - startPendingMineSentenceMultiple: (timeoutMs: number) => - startPendingMineSentenceMultiple(timeoutMs), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - refreshKnownWordCache: () => refreshKnownWordCache(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - markLastCardAsAudioCard: () => markLastCardAsAudioCard(), - getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), - clearAnilistToken: () => anilistStateRuntime.clearTokenState(), - openAnilistSetup: () => openAnilistSetupWindow(), - openJellyfinSetup: () => openJellyfinSetupWindow(), - getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), - retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), - runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), - openYomitanSettings: () => openYomitanSettings(), - cycleSecondarySubMode: () => cycleSecondarySubMode(), - openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), - stopApp: () => app.quit(), - hasMainWindow: () => Boolean(overlayManager.getMainWindow()), - getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, - schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), - logInfo: (message: string) => logger.info(message), - logWarn: (message: string) => logger.warn(message), - logError: (message: string, err: unknown) => logger.error(message, err), -}); -const createOverlayWindowHandler = createCreateOverlayWindowHandler({ - createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), - isDev, - getOverlayDebugVisualizationEnabled: () => appState.overlayDebugVisualizationEnabled, - ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), - onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), - setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), - isOverlayVisible: (windowKind) => - windowKind === 'visible' - ? overlayManager.getVisibleOverlayVisible() - : overlayManager.getInvisibleOverlayVisible(), - tryHandleOverlayShortcutLocalFallback: (input) => - overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), - onWindowClosed: (windowKind) => { - if (windowKind === 'visible') { - overlayManager.setMainWindow(null); - } else { - overlayManager.setInvisibleWindow(null); - } - }, -}); -const createMainWindowHandler = createCreateMainWindowHandler({ - createOverlayWindow: (kind) => createOverlayWindow(kind), - setMainWindow: (window) => overlayManager.setMainWindow(window), -}); -const createInvisibleWindowHandler = createCreateInvisibleWindowHandler({ - createOverlayWindow: (kind) => createOverlayWindow(kind), - setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window), -}); -const resolveTrayIconPathHandler = createResolveTrayIconPathHandler({ - resolveTrayIconPathRuntime, - platform: process.platform, - resourcesPath: process.resourcesPath, - appPath: app.getAppPath(), - dirname: __dirname, - joinPath: (...parts) => path.join(...parts), - fileExists: (candidate) => fs.existsSync(candidate), -}); -const buildTrayMenuTemplateHandler = createBuildTrayMenuTemplateHandler({ - buildTrayMenuTemplateRuntime, - initializeOverlayRuntime: () => initializeOverlayRuntime(), - isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), - openYomitanSettings: () => openYomitanSettings(), - openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - openJellyfinSetupWindow: () => openJellyfinSetupWindow(), - openAnilistSetupWindow: () => openAnilistSetupWindow(), - quitApp: () => app.quit(), -}); +const buildCliCommandContextDepsHandler = createBuildCliCommandContextDepsHandler( + createBuildCliCommandContextMainDepsHandler({ + appState, + texthookerService, + getResolvedConfig: () => getResolvedConfig(), + openExternal: (url: string) => shell.openExternal(url), + logBrowserOpenError: (url: string, error: unknown) => + logger.error(`Failed to open browser for texthooker URL: ${url}`, error), + showMpvOsd: (text: string) => showMpvOsd(text), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + toggleInvisibleOverlay: () => toggleInvisibleOverlay(), + setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), + setInvisibleOverlayVisible: (visible: boolean) => setInvisibleOverlayVisible(visible), + copyCurrentSubtitle: () => copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs: number) => + startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + refreshKnownWordCache: () => refreshKnownWordCache(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), + clearAnilistToken: () => anilistStateRuntime.clearTokenState(), + openAnilistSetupWindow: () => openAnilistSetupWindow(), + openJellyfinSetupWindow: () => openJellyfinSetupWindow(), + getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), + processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), + runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), + openYomitanSettings: () => openYomitanSettings(), + cycleSecondarySubMode: () => cycleSecondarySubMode(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), + stopApp: () => app.quit(), + hasMainWindow: () => Boolean(overlayManager.getMainWindow()), + getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, + schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), + logInfo: (message: string) => logger.info(message), + logWarn: (message: string) => logger.warn(message), + logError: (message: string, err: unknown) => logger.error(message, err), + })(), +); +const createOverlayWindowHandler = createCreateOverlayWindowHandler( + createBuildCreateOverlayWindowMainDepsHandler({ + createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), + isDev, + getOverlayDebugVisualizationEnabled: () => appState.overlayDebugVisualizationEnabled, + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), + onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), + setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), + isOverlayVisible: (windowKind) => + windowKind === 'visible' + ? overlayManager.getVisibleOverlayVisible() + : overlayManager.getInvisibleOverlayVisible(), + tryHandleOverlayShortcutLocalFallback: (input) => + overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), + onWindowClosed: (windowKind) => { + if (windowKind === 'visible') { + overlayManager.setMainWindow(null); + } else { + overlayManager.setInvisibleWindow(null); + } + }, + })(), +); +const createMainWindowHandler = createCreateMainWindowHandler( + createBuildCreateMainWindowMainDepsHandler({ + createOverlayWindow: (kind) => createOverlayWindow(kind), + setMainWindow: (window) => overlayManager.setMainWindow(window), + })(), +); +const createInvisibleWindowHandler = createCreateInvisibleWindowHandler( + createBuildCreateInvisibleWindowMainDepsHandler({ + createOverlayWindow: (kind) => createOverlayWindow(kind), + setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window), + })(), +); +const resolveTrayIconPathHandler = createResolveTrayIconPathHandler( + createBuildResolveTrayIconPathMainDepsHandler({ + resolveTrayIconPathRuntime, + platform: process.platform, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + dirname: __dirname, + joinPath: (...parts) => path.join(...parts), + fileExists: (candidate) => fs.existsSync(candidate), + })(), +); +const buildTrayMenuTemplateHandler = createBuildTrayMenuTemplateHandler( + createBuildTrayMenuTemplateMainDepsHandler({ + buildTrayMenuTemplateRuntime, + initializeOverlayRuntime: () => initializeOverlayRuntime(), + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + openYomitanSettings: () => openYomitanSettings(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + openJellyfinSetupWindow: () => openJellyfinSetupWindow(), + openAnilistSetupWindow: () => openAnilistSetupWindow(), + quitApp: () => app.quit(), + })(), +); +const ensureTrayHandler = createEnsureTrayHandler( + createBuildEnsureTrayMainDepsHandler({ + getTray: () => appTray, + setTray: (tray) => { + appTray = tray as Tray | null; + }, + buildTrayMenu: () => buildTrayMenu(), + resolveTrayIconPath: () => resolveTrayIconPath(), + createImageFromPath: (iconPath) => nativeImage.createFromPath(iconPath), + createEmptyImage: () => nativeImage.createEmpty(), + createTray: (icon) => new Tray(icon as never), + trayTooltip: TRAY_TOOLTIP, + platform: process.platform, + logWarn: (message) => logger.warn(message), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + })(), +); +const destroyTrayHandler = createDestroyTrayHandler( + createBuildDestroyTrayMainDepsHandler({ + getTray: () => appTray, + setTray: (tray) => { + appTray = tray as Tray | null; + }, + })(), +); const loadYomitanExtensionHandler = createLoadYomitanExtensionHandler({ loadYomitanExtensionCore, userDataPath: USER_DATA_PATH, @@ -2685,50 +2419,67 @@ const ensureYomitanExtensionLoadedHandler = createEnsureYomitanExtensionLoadedHa }, loadYomitanExtension: () => loadYomitanExtension(), }); -const buildInitializeOverlayRuntimeOptionsHandler = createBuildInitializeOverlayRuntimeOptionsHandler({ - getBackendOverride: () => appState.backendOverride, - getInitialInvisibleOverlayVisibility: () => - configDerivedRuntime.getInitialInvisibleOverlayVisibility(), - createMainWindow: () => { - createMainWindow(); - }, - createInvisibleWindow: () => { - createInvisibleWindow(); - }, - registerGlobalShortcuts: () => { - registerGlobalShortcuts(); - }, - updateVisibleOverlayBounds: (geometry) => { - updateVisibleOverlayBounds(geometry); - }, - updateInvisibleOverlayBounds: (geometry) => { - updateInvisibleOverlayBounds(geometry); - }, - isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - isInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), - updateVisibleOverlayVisibility: () => { - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - }, - updateInvisibleOverlayVisibility: () => { - overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); - }, - getOverlayWindows: () => getOverlayWindows(), - syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), - setWindowTracker: (tracker) => { - appState.windowTracker = tracker as never; - }, - getResolvedConfig: () => getResolvedConfig(), - getSubtitleTimingTracker: () => appState.subtitleTimingTracker, - getMpvClient: () => appState.mpvClient, - getMpvSocketPath: () => appState.mpvSocketPath, - getRuntimeOptionsManager: () => appState.runtimeOptionsManager, - setAnkiIntegration: (integration) => { - appState.ankiIntegration = integration as AnkiIntegration | null; - }, - showDesktopNotification, - createFieldGroupingCallback: () => createFieldGroupingCallback() as never, - getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), -}); +const buildInitializeOverlayRuntimeOptionsHandler = createBuildInitializeOverlayRuntimeOptionsHandler( + createBuildInitializeOverlayRuntimeMainDepsHandler({ + appState, + overlayManager: { + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => + overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), + }, + getInitialInvisibleOverlayVisibility: () => + configDerivedRuntime.getInitialInvisibleOverlayVisibility(), + createMainWindow: () => createMainWindow(), + createInvisibleWindow: () => createInvisibleWindow(), + registerGlobalShortcuts: () => registerGlobalShortcuts(), + updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry), + updateInvisibleOverlayBounds: (geometry) => updateInvisibleOverlayBounds(geometry), + getOverlayWindows: () => getOverlayWindows(), + getResolvedConfig: () => getResolvedConfig(), + showDesktopNotification, + createFieldGroupingCallback: () => createFieldGroupingCallback() as never, + getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), + })(), +); +const initializeOverlayRuntimeHandler = createInitializeOverlayRuntimeHandler( + createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({ + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + initializeOverlayRuntimeCore: (options) => initializeOverlayRuntimeCore(options as never), + buildOptions: () => buildInitializeOverlayRuntimeOptionsHandler(), + setInvisibleOverlayVisible: (visible) => { + overlayManager.setInvisibleOverlayVisible(visible); + }, + setOverlayRuntimeInitialized: (initialized) => { + appState.overlayRuntimeInitialized = initialized; + }, + startBackgroundWarmups: () => startBackgroundWarmups(), + })(), +); +const openYomitanSettingsHandler = createOpenYomitanSettingsHandler( + createBuildOpenYomitanSettingsMainDepsHandler({ + ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), + openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow }) => { + openYomitanSettingsWindow({ + yomitanExt: yomitanExt as Extension, + getExistingWindow: () => getExistingWindow() as BrowserWindow | null, + setWindow: (window) => setWindow(window as BrowserWindow | null), + }); + }, + getExistingWindow: () => appState.yomitanSettingsWindow, + setWindow: (window) => { + appState.yomitanSettingsWindow = window as BrowserWindow | null; + }, + logWarn: (message) => logger.warn(message), + logError: (message, error) => logger.error(message, error), + })(), +); async function updateLastCardFromClipboard(): Promise { await updateLastCardFromClipboardHandler(); diff --git a/src/main/runtime/anilist-media-state.test.ts b/src/main/runtime/anilist-media-state.test.ts new file mode 100644 index 0000000..c3ecd7b --- /dev/null +++ b/src/main/runtime/anilist-media-state.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createGetAnilistMediaGuessRuntimeStateHandler, + createGetCurrentAnilistMediaKeyHandler, + createResetAnilistMediaGuessStateHandler, + createResetAnilistMediaTrackingHandler, + createSetAnilistMediaGuessRuntimeStateHandler, +} from './anilist-media-state'; + +test('get current anilist media key trims and normalizes empty path', () => { + const getKey = createGetCurrentAnilistMediaKeyHandler({ + getCurrentMediaPath: () => ' /tmp/video.mkv ', + }); + const getEmptyKey = createGetCurrentAnilistMediaKeyHandler({ + getCurrentMediaPath: () => ' ', + }); + + assert.equal(getKey(), '/tmp/video.mkv'); + assert.equal(getEmptyKey(), null); +}); + +test('reset anilist media tracking clears duration/guess/probe state', () => { + let mediaKey: string | null = 'old'; + let mediaDurationSec: number | null = 123; + let mediaGuess: { title: string } | null = { title: 'guess' }; + let mediaGuessPromise: Promise | null = Promise.resolve(null); + let lastDurationProbeAtMs = 999; + + const reset = createResetAnilistMediaTrackingHandler({ + setMediaKey: (value) => { + mediaKey = value; + }, + setMediaDurationSec: (value) => { + mediaDurationSec = value; + }, + setMediaGuess: (value) => { + mediaGuess = value as { title: string } | null; + }, + setMediaGuessPromise: (value) => { + mediaGuessPromise = value; + }, + setLastDurationProbeAtMs: (value) => { + lastDurationProbeAtMs = value; + }, + }); + + reset('/new/media'); + assert.equal(mediaKey, '/new/media'); + assert.equal(mediaDurationSec, null); + assert.equal(mediaGuess, null); + assert.equal(mediaGuessPromise, null); + assert.equal(lastDurationProbeAtMs, 0); +}); + +test('get/set anilist media guess runtime state round-trips fields', () => { + let state = { + mediaKey: null as string | null, + mediaDurationSec: null as number | null, + mediaGuess: null as { title: string } | null, + mediaGuessPromise: null as Promise | null, + lastDurationProbeAtMs: 0, + }; + + const setState = createSetAnilistMediaGuessRuntimeStateHandler({ + setMediaKey: (value) => { + state.mediaKey = value; + }, + setMediaDurationSec: (value) => { + state.mediaDurationSec = value; + }, + setMediaGuess: (value) => { + state.mediaGuess = value as { title: string } | null; + }, + setMediaGuessPromise: (value) => { + state.mediaGuessPromise = value; + }, + setLastDurationProbeAtMs: (value) => { + state.lastDurationProbeAtMs = value; + }, + }); + + const getState = createGetAnilistMediaGuessRuntimeStateHandler({ + getMediaKey: () => state.mediaKey, + getMediaDurationSec: () => state.mediaDurationSec, + getMediaGuess: () => state.mediaGuess as never, + getMediaGuessPromise: () => state.mediaGuessPromise as never, + getLastDurationProbeAtMs: () => state.lastDurationProbeAtMs, + }); + + const nextPromise = Promise.resolve(null); + setState({ + mediaKey: '/tmp/video.mkv', + mediaDurationSec: 24, + mediaGuess: { title: 'Title' } as never, + mediaGuessPromise: nextPromise as never, + lastDurationProbeAtMs: 321, + }); + + const roundTrip = getState(); + assert.equal(roundTrip.mediaKey, '/tmp/video.mkv'); + assert.equal(roundTrip.mediaDurationSec, 24); + assert.deepEqual(roundTrip.mediaGuess, { title: 'Title' }); + assert.equal(roundTrip.mediaGuessPromise, nextPromise); + assert.equal(roundTrip.lastDurationProbeAtMs, 321); +}); + +test('reset anilist media guess state clears guess and in-flight promise', () => { + let mediaGuess: { title: string } | null = { title: 'guess' }; + let mediaGuessPromise: Promise | null = Promise.resolve(null); + + const resetGuessState = createResetAnilistMediaGuessStateHandler({ + setMediaGuess: (value) => { + mediaGuess = value as { title: string } | null; + }, + setMediaGuessPromise: (value) => { + mediaGuessPromise = value; + }, + }); + + resetGuessState(); + assert.equal(mediaGuess, null); + assert.equal(mediaGuessPromise, null); +}); diff --git a/src/main/runtime/anilist-media-state.ts b/src/main/runtime/anilist-media-state.ts new file mode 100644 index 0000000..433967d --- /dev/null +++ b/src/main/runtime/anilist-media-state.ts @@ -0,0 +1,68 @@ +import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess'; + +export function createGetCurrentAnilistMediaKeyHandler(deps: { + getCurrentMediaPath: () => string | null; +}) { + return (): string | null => { + const mediaPath = deps.getCurrentMediaPath()?.trim(); + return mediaPath && mediaPath.length > 0 ? mediaPath : null; + }; +} + +export function createResetAnilistMediaTrackingHandler(deps: { + setMediaKey: (value: string | null) => void; + setMediaDurationSec: (value: number | null) => void; + setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void; + setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void; + setLastDurationProbeAtMs: (value: number) => void; +}) { + return (mediaKey: string | null): void => { + deps.setMediaKey(mediaKey); + deps.setMediaDurationSec(null); + deps.setMediaGuess(null); + deps.setMediaGuessPromise(null); + deps.setLastDurationProbeAtMs(0); + }; +} + +export function createGetAnilistMediaGuessRuntimeStateHandler(deps: { + getMediaKey: () => string | null; + getMediaDurationSec: () => number | null; + getMediaGuess: () => AnilistMediaGuessRuntimeState['mediaGuess']; + getMediaGuessPromise: () => AnilistMediaGuessRuntimeState['mediaGuessPromise']; + getLastDurationProbeAtMs: () => number; +}) { + return (): AnilistMediaGuessRuntimeState => ({ + mediaKey: deps.getMediaKey(), + mediaDurationSec: deps.getMediaDurationSec(), + mediaGuess: deps.getMediaGuess(), + mediaGuessPromise: deps.getMediaGuessPromise(), + lastDurationProbeAtMs: deps.getLastDurationProbeAtMs(), + }); +} + +export function createSetAnilistMediaGuessRuntimeStateHandler(deps: { + setMediaKey: (value: string | null) => void; + setMediaDurationSec: (value: number | null) => void; + setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void; + setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void; + setLastDurationProbeAtMs: (value: number) => void; +}) { + return (state: AnilistMediaGuessRuntimeState): void => { + deps.setMediaKey(state.mediaKey); + deps.setMediaDurationSec(state.mediaDurationSec); + deps.setMediaGuess(state.mediaGuess); + deps.setMediaGuessPromise(state.mediaGuessPromise); + deps.setLastDurationProbeAtMs(state.lastDurationProbeAtMs); + }; +} + +export function createResetAnilistMediaGuessStateHandler(deps: { + setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void; + setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void; +}) { + return (): void => { + deps.setMediaGuess(null); + deps.setMediaGuessPromise(null); + }; +} diff --git a/src/main/runtime/anilist-setup-window.test.ts b/src/main/runtime/anilist-setup-window.test.ts index 628e87a..127d550 100644 --- a/src/main/runtime/anilist-setup-window.test.ts +++ b/src/main/runtime/anilist-setup-window.test.ts @@ -12,6 +12,7 @@ import { createAnilistSetupWillRedirectHandler, createAnilistSetupWindowOpenHandler, createHandleManualAnilistSetupSubmissionHandler, + createOpenAnilistSetupWindowHandler, } from './anilist-setup-window'; test('manual anilist setup submission forwards access token to callback consumer', () => { @@ -224,3 +225,143 @@ test('anilist setup window opened handler sets references', () => { handler(); assert.deepEqual(calls, ['set-window', 'opened:yes']); }); + +test('open anilist setup handler no-ops when existing setup window focused', () => { + const calls: string[] = []; + const handler = createOpenAnilistSetupWindowHandler({ + maybeFocusExistingSetupWindow: () => { + calls.push('focus-existing'); + return true; + }, + createSetupWindow: () => { + calls.push('create-window'); + throw new Error('should not create'); + }, + buildAuthorizeUrl: () => 'https://anilist.co/api/v2/oauth/authorize?client_id=36084', + consumeCallbackUrl: () => false, + openSetupInBrowser: () => {}, + loadManualTokenEntry: () => {}, + redirectUri: 'https://anilist.subminer.moe/', + developerSettingsUrl: 'https://anilist.co/settings/developer', + isAllowedExternalUrl: () => true, + isAllowedNavigationUrl: () => true, + logWarn: () => {}, + logError: () => {}, + clearSetupWindow: () => {}, + setSetupPageOpened: () => {}, + setSetupWindow: () => {}, + openExternal: () => {}, + }); + + handler(); + assert.deepEqual(calls, ['focus-existing']); +}); + +test('open anilist setup handler wires navigation, fallback, and lifecycle', () => { + let openHandler: ((params: { url: string }) => { action: 'deny' }) | null = null; + let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null; + let didNavigateHandler: ((event: unknown, url: string) => void) | null = null; + let didFinishLoadHandler: (() => void) | null = null; + let didFailLoadHandler: + | ((event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => void) + | null = null; + let closedHandler: (() => void) | null = null; + let prevented = false; + const calls: string[] = []; + + const fakeWindow = { + focus: () => {}, + webContents: { + setWindowOpenHandler: (handler: (params: { url: string }) => { action: 'deny' }) => { + openHandler = handler; + }, + on: ( + event: 'will-navigate' | 'will-redirect' | 'did-navigate' | 'did-fail-load' | 'did-finish-load', + handler: (...args: any[]) => void, + ) => { + if (event === 'will-navigate') willNavigateHandler = handler as never; + if (event === 'did-navigate') didNavigateHandler = handler as never; + if (event === 'did-finish-load') didFinishLoadHandler = handler as never; + if (event === 'did-fail-load') didFailLoadHandler = handler as never; + }, + getURL: () => 'about:blank', + }, + on: (event: 'closed', handler: () => void) => { + if (event === 'closed') closedHandler = handler; + }, + isDestroyed: () => false, + }; + + const handler = createOpenAnilistSetupWindowHandler({ + maybeFocusExistingSetupWindow: () => false, + createSetupWindow: () => fakeWindow, + buildAuthorizeUrl: () => 'https://anilist.co/api/v2/oauth/authorize?client_id=36084', + consumeCallbackUrl: (rawUrl) => { + calls.push(`consume:${rawUrl}`); + return rawUrl.includes('access_token='); + }, + openSetupInBrowser: () => calls.push('open-browser'), + loadManualTokenEntry: () => calls.push('load-manual'), + redirectUri: 'https://anilist.subminer.moe/', + developerSettingsUrl: 'https://anilist.co/settings/developer', + isAllowedExternalUrl: () => true, + isAllowedNavigationUrl: () => true, + logWarn: (message) => calls.push(`warn:${message}`), + logError: (message) => calls.push(`error:${message}`), + clearSetupWindow: () => calls.push('clear-window'), + setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`), + setSetupWindow: () => calls.push('set-window'), + openExternal: (url) => calls.push(`open:${url}`), + }); + + handler(); + assert.ok(openHandler); + assert.ok(willNavigateHandler); + assert.ok(didNavigateHandler); + assert.ok(didFinishLoadHandler); + assert.ok(didFailLoadHandler); + assert.ok(closedHandler); + assert.deepEqual(calls.slice(0, 3), ['load-manual', 'set-window', 'opened:yes']); + + const onOpen = openHandler as ((params: { url: string }) => { action: 'deny' }) | null; + if (!onOpen) throw new Error('missing window open handler'); + assert.deepEqual(onOpen({ url: 'https://anilist.co/settings/developer' }), { action: 'deny' }); + assert.ok(calls.includes('open:https://anilist.co/settings/developer')); + + const onWillNavigate = willNavigateHandler as + | ((event: { preventDefault: () => void }, url: string) => void) + | null; + if (!onWillNavigate) throw new Error('missing will navigate handler'); + onWillNavigate( + { + preventDefault: () => { + prevented = true; + }, + }, + 'https://anilist.subminer.moe/#access_token=abc', + ); + assert.equal(prevented, true); + + const onDidNavigate = didNavigateHandler as ((event: unknown, url: string) => void) | null; + if (!onDidNavigate) throw new Error('missing did navigate handler'); + onDidNavigate({}, 'https://anilist.subminer.moe/#access_token=abc'); + + const onDidFinishLoad = didFinishLoadHandler as (() => void) | null; + if (!onDidFinishLoad) throw new Error('missing did finish load handler'); + onDidFinishLoad(); + assert.ok(calls.includes('warn:AniList setup loaded a blank page; using fallback')); + assert.ok(calls.includes('open-browser')); + + const onDidFailLoad = didFailLoadHandler as + | ((event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => void) + | null; + if (!onDidFailLoad) throw new Error('missing did fail load handler'); + onDidFailLoad({}, -1, 'load failed', 'about:blank'); + assert.ok(calls.includes('error:AniList setup window failed to load')); + + const onClosed = closedHandler as (() => void) | null; + if (!onClosed) throw new Error('missing closed handler'); + onClosed(); + assert.ok(calls.includes('clear-window')); + assert.ok(calls.includes('opened:no')); +}); diff --git a/src/main/runtime/anilist-setup-window.ts b/src/main/runtime/anilist-setup-window.ts index 5ed6f0e..636f404 100644 --- a/src/main/runtime/anilist-setup-window.ts +++ b/src/main/runtime/anilist-setup-window.ts @@ -8,6 +8,18 @@ type FocusableWindowLike = { focus: () => void; }; +type AnilistSetupWebContentsLike = { + setWindowOpenHandler: (...args: any[]) => unknown; + on: (...args: any[]) => unknown; + getURL: () => string; +}; + +type AnilistSetupWindowLike = FocusableWindowLike & { + webContents: AnilistSetupWebContentsLike; + on: (...args: any[]) => unknown; + isDestroyed: () => boolean; +}; + export function createHandleManualAnilistSetupSubmissionHandler(deps: { consumeCallbackUrl: (rawUrl: string) => boolean; redirectUri: string; @@ -179,3 +191,133 @@ export function createAnilistSetupFallbackHandler(deps: { }, }; } + +export function createOpenAnilistSetupWindowHandler(deps: { + maybeFocusExistingSetupWindow: () => boolean; + createSetupWindow: () => TWindow; + buildAuthorizeUrl: () => string; + consumeCallbackUrl: (rawUrl: string) => boolean; + openSetupInBrowser: (authorizeUrl: string) => void; + loadManualTokenEntry: (setupWindow: TWindow, authorizeUrl: string) => void; + redirectUri: string; + developerSettingsUrl: string; + isAllowedExternalUrl: (url: string) => boolean; + isAllowedNavigationUrl: (url: string) => boolean; + logWarn: (message: string, details?: unknown) => void; + logError: (message: string, details: unknown) => void; + clearSetupWindow: () => void; + setSetupPageOpened: (opened: boolean) => void; + setSetupWindow: (window: TWindow) => void; + openExternal: (url: string) => void; +}) { + return (): void => { + if (deps.maybeFocusExistingSetupWindow()) { + return; + } + + const setupWindow = deps.createSetupWindow(); + const authorizeUrl = deps.buildAuthorizeUrl(); + const consumeCallbackUrl = (rawUrl: string): boolean => deps.consumeCallbackUrl(rawUrl); + const openSetupInBrowser = () => deps.openSetupInBrowser(authorizeUrl); + const loadManualTokenEntry = () => deps.loadManualTokenEntry(setupWindow, authorizeUrl); + const handleManualSubmission = createHandleManualAnilistSetupSubmissionHandler({ + consumeCallbackUrl: (rawUrl) => consumeCallbackUrl(rawUrl), + redirectUri: deps.redirectUri, + logWarn: (message) => deps.logWarn(message), + }); + const fallback = createAnilistSetupFallbackHandler({ + authorizeUrl, + developerSettingsUrl: deps.developerSettingsUrl, + setupWindow, + openSetupInBrowser, + loadManualTokenEntry, + logError: (message, details) => deps.logError(message, details), + logWarn: (message) => deps.logWarn(message), + }); + const handleWindowOpen = createAnilistSetupWindowOpenHandler({ + isAllowedExternalUrl: (url) => deps.isAllowedExternalUrl(url), + openExternal: (url) => deps.openExternal(url), + logWarn: (message, details) => deps.logWarn(message, details), + }); + const handleWillNavigate = createAnilistSetupWillNavigateHandler({ + handleManualSubmission: (url) => handleManualSubmission(url), + consumeCallbackUrl: (url) => consumeCallbackUrl(url), + redirectUri: deps.redirectUri, + isAllowedNavigationUrl: (url) => deps.isAllowedNavigationUrl(url), + logWarn: (message, details) => deps.logWarn(message, details), + }); + const handleWillRedirect = createAnilistSetupWillRedirectHandler({ + consumeCallbackUrl: (url) => consumeCallbackUrl(url), + }); + const handleDidNavigate = createAnilistSetupDidNavigateHandler({ + consumeCallbackUrl: (url) => consumeCallbackUrl(url), + }); + const handleDidFailLoad = createAnilistSetupDidFailLoadHandler({ + onLoadFailure: (details) => fallback.onLoadFailure(details), + }); + const handleDidFinishLoad = createAnilistSetupDidFinishLoadHandler({ + getLoadedUrl: () => setupWindow.webContents.getURL(), + onBlankPageLoaded: () => fallback.onBlankPageLoaded(), + }); + const handleWindowClosed = createHandleAnilistSetupWindowClosedHandler({ + clearSetupWindow: () => deps.clearSetupWindow(), + setSetupPageOpened: (opened) => deps.setSetupPageOpened(opened), + }); + const handleWindowOpened = createHandleAnilistSetupWindowOpenedHandler({ + setSetupWindow: () => deps.setSetupWindow(setupWindow), + setSetupPageOpened: (opened) => deps.setSetupPageOpened(opened), + }); + + setupWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => + handleWindowOpen({ url }), + ); + setupWindow.webContents.on('will-navigate', (event: unknown, url: string) => { + handleWillNavigate({ + url, + preventDefault: () => { + if (event && typeof event === 'object' && 'preventDefault' in event) { + const typedEvent = event as { preventDefault?: () => void }; + typedEvent.preventDefault?.(); + } + }, + }); + }); + setupWindow.webContents.on('will-redirect', (event: unknown, url: string) => { + handleWillRedirect({ + url, + preventDefault: () => { + if (event && typeof event === 'object' && 'preventDefault' in event) { + const typedEvent = event as { preventDefault?: () => void }; + typedEvent.preventDefault?.(); + } + }, + }); + }); + setupWindow.webContents.on('did-navigate', (_event: unknown, url: string) => { + handleDidNavigate(url); + }); + setupWindow.webContents.on( + 'did-fail-load', + ( + _event: unknown, + errorCode: number, + errorDescription: string, + validatedURL: string, + ) => { + handleDidFailLoad({ + errorCode, + errorDescription, + validatedURL, + }); + }, + ); + setupWindow.webContents.on('did-finish-load', () => { + handleDidFinishLoad(); + }); + loadManualTokenEntry(); + setupWindow.on('closed', () => { + handleWindowClosed(); + }); + handleWindowOpened(); + }; +} diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts new file mode 100644 index 0000000..b4216fd --- /dev/null +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createOnWillQuitCleanupHandler, + createRestoreWindowsOnActivateHandler, + createShouldRestoreWindowsOnActivateHandler, +} from './app-lifecycle-actions'; + +test('on will quit cleanup handler runs all cleanup steps', () => { + const calls: string[] = []; + const cleanup = createOnWillQuitCleanupHandler({ + destroyTray: () => calls.push('destroy-tray'), + stopConfigHotReload: () => calls.push('stop-config'), + restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'), + unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), + stopSubtitleWebsocket: () => calls.push('stop-ws'), + stopTexthookerService: () => calls.push('stop-texthooker'), + destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'), + clearYomitanParserState: () => calls.push('clear-yomitan-state'), + stopWindowTracker: () => calls.push('stop-tracker'), + destroyMpvSocket: () => calls.push('destroy-socket'), + clearReconnectTimer: () => calls.push('clear-reconnect'), + destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'), + destroyImmersionTracker: () => calls.push('destroy-immersion'), + destroyAnkiIntegration: () => calls.push('destroy-anki'), + destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'), + clearAnilistSetupWindow: () => calls.push('clear-anilist-window'), + destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'), + clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'), + stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), + }); + + cleanup(); + assert.equal(calls.length, 19); + assert.equal(calls[0], 'destroy-tray'); + assert.equal(calls[calls.length - 1], 'stop-jellyfin-remote'); +}); + +test('should restore windows on activate requires initialized runtime and no windows', () => { + let initialized = false; + let windowCount = 1; + const shouldRestore = createShouldRestoreWindowsOnActivateHandler({ + isOverlayRuntimeInitialized: () => initialized, + getAllWindowCount: () => windowCount, + }); + + assert.equal(shouldRestore(), false); + initialized = true; + assert.equal(shouldRestore(), false); + windowCount = 0; + assert.equal(shouldRestore(), true); +}); + +test('restore windows on activate recreates windows then syncs visibility', () => { + const calls: string[] = []; + const restore = createRestoreWindowsOnActivateHandler({ + createMainWindow: () => calls.push('main'), + createInvisibleWindow: () => calls.push('invisible'), + updateVisibleOverlayVisibility: () => calls.push('visible-sync'), + updateInvisibleOverlayVisibility: () => calls.push('invisible-sync'), + }); + + restore(); + assert.deepEqual(calls, ['main', 'invisible', 'visible-sync', 'invisible-sync']); +}); diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts new file mode 100644 index 0000000..31da1bf --- /dev/null +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -0,0 +1,64 @@ +export function createOnWillQuitCleanupHandler(deps: { + destroyTray: () => void; + stopConfigHotReload: () => void; + restorePreviousSecondarySubVisibility: () => void; + unregisterAllGlobalShortcuts: () => void; + stopSubtitleWebsocket: () => void; + stopTexthookerService: () => void; + destroyYomitanParserWindow: () => void; + clearYomitanParserState: () => void; + stopWindowTracker: () => void; + destroyMpvSocket: () => void; + clearReconnectTimer: () => void; + destroySubtitleTimingTracker: () => void; + destroyImmersionTracker: () => void; + destroyAnkiIntegration: () => void; + destroyAnilistSetupWindow: () => void; + clearAnilistSetupWindow: () => void; + destroyJellyfinSetupWindow: () => void; + clearJellyfinSetupWindow: () => void; + stopJellyfinRemoteSession: () => void; +}) { + return (): void => { + deps.destroyTray(); + deps.stopConfigHotReload(); + deps.restorePreviousSecondarySubVisibility(); + deps.unregisterAllGlobalShortcuts(); + deps.stopSubtitleWebsocket(); + deps.stopTexthookerService(); + deps.destroyYomitanParserWindow(); + deps.clearYomitanParserState(); + deps.stopWindowTracker(); + deps.destroyMpvSocket(); + deps.clearReconnectTimer(); + deps.destroySubtitleTimingTracker(); + deps.destroyImmersionTracker(); + deps.destroyAnkiIntegration(); + deps.destroyAnilistSetupWindow(); + deps.clearAnilistSetupWindow(); + deps.destroyJellyfinSetupWindow(); + deps.clearJellyfinSetupWindow(); + deps.stopJellyfinRemoteSession(); + }; +} + +export function createShouldRestoreWindowsOnActivateHandler(deps: { + isOverlayRuntimeInitialized: () => boolean; + getAllWindowCount: () => number; +}) { + return (): boolean => deps.isOverlayRuntimeInitialized() && deps.getAllWindowCount() === 0; +} + +export function createRestoreWindowsOnActivateHandler(deps: { + createMainWindow: () => void; + createInvisibleWindow: () => void; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; +}) { + return (): void => { + deps.createMainWindow(); + deps.createInvisibleWindow(); + deps.updateVisibleOverlayVisibility(); + deps.updateInvisibleOverlayVisibility(); + }; +} diff --git a/src/main/runtime/app-lifecycle-main-activate.test.ts b/src/main/runtime/app-lifecycle-main-activate.test.ts new file mode 100644 index 0000000..8569cc6 --- /dev/null +++ b/src/main/runtime/app-lifecycle-main-activate.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildRestoreWindowsOnActivateMainDepsHandler, + createBuildShouldRestoreWindowsOnActivateMainDepsHandler, +} from './app-lifecycle-main-activate'; + +test('should restore windows on activate deps builder maps visibility state checks', () => { + const deps = createBuildShouldRestoreWindowsOnActivateMainDepsHandler({ + isOverlayRuntimeInitialized: () => true, + getAllWindowCount: () => 0, + })(); + + assert.equal(deps.isOverlayRuntimeInitialized(), true); + assert.equal(deps.getAllWindowCount(), 0); +}); + +test('restore windows on activate deps builder maps all restoration callbacks', () => { + const calls: string[] = []; + const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({ + createMainWindow: () => calls.push('main'), + createInvisibleWindow: () => calls.push('invisible'), + updateVisibleOverlayVisibility: () => calls.push('visible'), + updateInvisibleOverlayVisibility: () => calls.push('invisible-visible'), + })(); + + deps.createMainWindow(); + deps.createInvisibleWindow(); + deps.updateVisibleOverlayVisibility(); + deps.updateInvisibleOverlayVisibility(); + assert.deepEqual(calls, ['main', 'invisible', 'visible', 'invisible-visible']); +}); diff --git a/src/main/runtime/app-lifecycle-main-activate.ts b/src/main/runtime/app-lifecycle-main-activate.ts new file mode 100644 index 0000000..3fde767 --- /dev/null +++ b/src/main/runtime/app-lifecycle-main-activate.ts @@ -0,0 +1,23 @@ +export function createBuildShouldRestoreWindowsOnActivateMainDepsHandler(deps: { + isOverlayRuntimeInitialized: () => boolean; + getAllWindowCount: () => number; +}) { + return () => ({ + isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), + getAllWindowCount: () => deps.getAllWindowCount(), + }); +} + +export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: { + createMainWindow: () => void; + createInvisibleWindow: () => void; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; +}) { + return () => ({ + createMainWindow: () => deps.createMainWindow(), + createInvisibleWindow: () => deps.createInvisibleWindow(), + updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(), + }); +} diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts new file mode 100644 index 0000000..8a415a7 --- /dev/null +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup'; +import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions'; + +test('cleanup deps builder returns handlers that guard optional runtime objects', () => { + const calls: string[] = []; + let reconnectTimer: ReturnType | null = setTimeout(() => {}, 60_000); + let immersionTracker: { destroy: () => void } | null = { + destroy: () => calls.push('destroy-immersion'), + }; + + const depsFactory = createBuildOnWillQuitCleanupDepsHandler({ + destroyTray: () => calls.push('destroy-tray'), + stopConfigHotReload: () => calls.push('stop-config'), + restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'), + unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), + stopSubtitleWebsocket: () => calls.push('stop-ws'), + stopTexthookerService: () => calls.push('stop-texthooker'), + + getYomitanParserWindow: () => ({ + isDestroyed: () => false, + destroy: () => calls.push('destroy-yomitan-window'), + }), + clearYomitanParserState: () => calls.push('clear-yomitan-state'), + + getWindowTracker: () => ({ stop: () => calls.push('stop-tracker') }), + getMpvSocket: () => ({ destroy: () => calls.push('destroy-socket') }), + getReconnectTimer: () => reconnectTimer, + clearReconnectTimerRef: () => { + reconnectTimer = null; + calls.push('clear-reconnect-ref'); + }, + + getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }), + getImmersionTracker: () => immersionTracker, + clearImmersionTracker: () => { + immersionTracker = null; + calls.push('clear-immersion-ref'); + }, + getAnkiIntegration: () => ({ destroy: () => calls.push('destroy-anki') }), + + getAnilistSetupWindow: () => ({ destroy: () => calls.push('destroy-anilist-window') }), + clearAnilistSetupWindow: () => calls.push('clear-anilist-window'), + getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }), + clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'), + + stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), + }); + + const cleanup = createOnWillQuitCleanupHandler(depsFactory()); + cleanup(); + + assert.ok(calls.includes('destroy-tray')); + assert.ok(calls.includes('destroy-yomitan-window')); + assert.ok(calls.includes('destroy-socket')); + assert.ok(calls.includes('clear-reconnect-ref')); + assert.ok(calls.includes('destroy-immersion')); + assert.ok(calls.includes('clear-immersion-ref')); + assert.ok(calls.includes('stop-jellyfin-remote')); + assert.equal(reconnectTimer, null); + assert.equal(immersionTracker, null); +}); + +test('cleanup deps builder skips destroyed yomitan window', () => { + const calls: string[] = []; + const depsFactory = createBuildOnWillQuitCleanupDepsHandler({ + destroyTray: () => {}, + stopConfigHotReload: () => {}, + restorePreviousSecondarySubVisibility: () => {}, + unregisterAllGlobalShortcuts: () => {}, + stopSubtitleWebsocket: () => {}, + stopTexthookerService: () => {}, + getYomitanParserWindow: () => ({ + isDestroyed: () => true, + destroy: () => calls.push('destroy-yomitan-window'), + }), + clearYomitanParserState: () => {}, + getWindowTracker: () => null, + getMpvSocket: () => null, + getReconnectTimer: () => null, + clearReconnectTimerRef: () => {}, + getSubtitleTimingTracker: () => null, + getImmersionTracker: () => null, + clearImmersionTracker: () => {}, + getAnkiIntegration: () => null, + getAnilistSetupWindow: () => null, + clearAnilistSetupWindow: () => {}, + getJellyfinSetupWindow: () => null, + clearJellyfinSetupWindow: () => {}, + stopJellyfinRemoteSession: () => {}, + }); + + const cleanup = createOnWillQuitCleanupHandler(depsFactory()); + cleanup(); + + assert.deepEqual(calls, []); +}); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts new file mode 100644 index 0000000..3d2f718 --- /dev/null +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -0,0 +1,98 @@ +// Narrow structural types used by cleanup assembly. +type Destroyable = { + destroy: () => void; +}; + +type DestroyableWindow = Destroyable & { + isDestroyed: () => boolean; +}; + +type Stoppable = { + stop: () => void; +}; + +type SocketLike = { + destroy: () => void; +}; + +type TimerLike = ReturnType; + +export function createBuildOnWillQuitCleanupDepsHandler(deps: { + destroyTray: () => void; + stopConfigHotReload: () => void; + restorePreviousSecondarySubVisibility: () => void; + unregisterAllGlobalShortcuts: () => void; + stopSubtitleWebsocket: () => void; + stopTexthookerService: () => void; + + getYomitanParserWindow: () => DestroyableWindow | null; + clearYomitanParserState: () => void; + + getWindowTracker: () => Stoppable | null; + getMpvSocket: () => SocketLike | null; + getReconnectTimer: () => TimerLike | null; + clearReconnectTimerRef: () => void; + + getSubtitleTimingTracker: () => Destroyable | null; + getImmersionTracker: () => Destroyable | null; + clearImmersionTracker: () => void; + getAnkiIntegration: () => Destroyable | null; + + getAnilistSetupWindow: () => Destroyable | null; + clearAnilistSetupWindow: () => void; + getJellyfinSetupWindow: () => Destroyable | null; + clearJellyfinSetupWindow: () => void; + + stopJellyfinRemoteSession: () => void; +}) { + return () => ({ + destroyTray: () => deps.destroyTray(), + stopConfigHotReload: () => deps.stopConfigHotReload(), + restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(), + unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(), + stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(), + stopTexthookerService: () => deps.stopTexthookerService(), + destroyYomitanParserWindow: () => { + const window = deps.getYomitanParserWindow(); + if (!window) return; + if (window.isDestroyed()) return; + window.destroy(); + }, + clearYomitanParserState: () => deps.clearYomitanParserState(), + stopWindowTracker: () => { + const tracker = deps.getWindowTracker(); + tracker?.stop(); + }, + destroyMpvSocket: () => { + const socket = deps.getMpvSocket(); + socket?.destroy(); + }, + clearReconnectTimer: () => { + const timer = deps.getReconnectTimer(); + if (!timer) return; + clearTimeout(timer); + deps.clearReconnectTimerRef(); + }, + destroySubtitleTimingTracker: () => { + deps.getSubtitleTimingTracker()?.destroy(); + }, + destroyImmersionTracker: () => { + const tracker = deps.getImmersionTracker(); + if (!tracker) return; + tracker.destroy(); + deps.clearImmersionTracker(); + }, + destroyAnkiIntegration: () => { + deps.getAnkiIntegration()?.destroy(); + }, + destroyAnilistSetupWindow: () => { + deps.getAnilistSetupWindow()?.destroy(); + }, + clearAnilistSetupWindow: () => deps.clearAnilistSetupWindow(), + destroyJellyfinSetupWindow: () => { + deps.getJellyfinSetupWindow()?.destroy(); + }, + clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(), + stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), + }); +} diff --git a/src/main/runtime/app-runtime-main-deps.test.ts b/src/main/runtime/app-runtime-main-deps.test.ts new file mode 100644 index 0000000..c0767a5 --- /dev/null +++ b/src/main/runtime/app-runtime-main-deps.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildDestroyTrayMainDepsHandler, + createBuildEnsureTrayMainDepsHandler, + createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler, + createBuildOpenYomitanSettingsMainDepsHandler, +} from './app-runtime-main-deps'; + +test('ensure tray main deps trigger overlay bootstrap on tray click when runtime not initialized', () => { + const calls: string[] = []; + const deps = createBuildEnsureTrayMainDepsHandler({ + getTray: () => null, + setTray: () => calls.push('set-tray'), + buildTrayMenu: () => ({}), + resolveTrayIconPath: () => null, + createImageFromPath: () => ({}), + createEmptyImage: () => ({}), + createTray: () => ({}), + trayTooltip: 'SubMiner', + platform: 'darwin', + logWarn: (message) => calls.push(`warn:${message}`), + initializeOverlayRuntime: () => calls.push('init-overlay'), + isOverlayRuntimeInitialized: () => false, + setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), + })(); + + deps.ensureOverlayVisibleFromTrayClick(); + assert.deepEqual(calls, ['init-overlay', 'set-visible:true']); +}); + +test('destroy tray main deps map passthrough getters/setters', () => { + let tray: unknown = { id: 'tray' }; + const deps = createBuildDestroyTrayMainDepsHandler({ + getTray: () => tray, + setTray: (next) => { + tray = next; + }, + })(); + + assert.deepEqual(deps.getTray(), { id: 'tray' }); + deps.setTray(null); + assert.equal(tray, null); +}); + +test('initialize overlay runtime main deps map build options and callbacks', () => { + const calls: string[] = []; + const options = { id: 'opts' }; + const deps = createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({ + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntimeCore: (value) => { + calls.push(`core:${JSON.stringify(value)}`); + return { invisibleOverlayVisible: true }; + }, + buildOptions: () => options, + setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`), + setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`), + startBackgroundWarmups: () => calls.push('warmups'), + })(); + + assert.equal(deps.isOverlayRuntimeInitialized(), false); + assert.equal(deps.buildOptions(), options); + assert.deepEqual(deps.initializeOverlayRuntimeCore(options), { invisibleOverlayVisible: true }); + deps.setInvisibleOverlayVisible(true); + deps.setOverlayRuntimeInitialized(true); + deps.startBackgroundWarmups(); + assert.deepEqual(calls, [ + 'core:{"id":"opts"}', + 'set-invisible:true', + 'set-initialized:true', + 'warmups', + ]); +}); + +test('open yomitan settings main deps map async open callbacks', async () => { + const calls: string[] = []; + let currentWindow: unknown = null; + const extension = { id: 'ext' }; + const deps = createBuildOpenYomitanSettingsMainDepsHandler({ + ensureYomitanExtensionLoaded: async () => extension, + openYomitanSettingsWindow: ({ yomitanExt }) => calls.push(`open:${(yomitanExt as { id: string }).id}`), + getExistingWindow: () => currentWindow, + setWindow: (window) => { + currentWindow = window; + calls.push('set-window'); + }, + logWarn: (message) => calls.push(`warn:${message}`), + logError: (message) => calls.push(`error:${message}`), + })(); + + assert.equal(await deps.ensureYomitanExtensionLoaded(), extension); + assert.equal(deps.getExistingWindow(), null); + deps.setWindow({ id: 'win' }); + deps.openYomitanSettingsWindow({ + yomitanExt: extension, + getExistingWindow: () => deps.getExistingWindow(), + setWindow: (window) => deps.setWindow(window), + }); + deps.logWarn('warn'); + deps.logError('error', new Error('boom')); + assert.deepEqual(calls, ['set-window', 'open:ext', 'warn:warn', 'error:error']); + assert.deepEqual(currentWindow, { id: 'win' }); +}); diff --git a/src/main/runtime/app-runtime-main-deps.ts b/src/main/runtime/app-runtime-main-deps.ts new file mode 100644 index 0000000..cdac61e --- /dev/null +++ b/src/main/runtime/app-runtime-main-deps.ts @@ -0,0 +1,89 @@ +export function createBuildEnsureTrayMainDepsHandler(deps: { + getTray: () => unknown | null; + setTray: (tray: unknown | null) => void; + buildTrayMenu: () => unknown; + resolveTrayIconPath: () => string | null; + createImageFromPath: (iconPath: string) => unknown; + createEmptyImage: () => unknown; + createTray: (icon: unknown) => unknown; + trayTooltip: string; + platform: string; + logWarn: (message: string) => void; + initializeOverlayRuntime: () => void; + isOverlayRuntimeInitialized: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; +}) { + return () => ({ + getTray: () => deps.getTray() as never, + setTray: (tray: unknown | null) => deps.setTray(tray), + buildTrayMenu: () => deps.buildTrayMenu() as never, + resolveTrayIconPath: () => deps.resolveTrayIconPath(), + createImageFromPath: (iconPath: string) => deps.createImageFromPath(iconPath) as never, + createEmptyImage: () => deps.createEmptyImage() as never, + createTray: (icon: unknown) => deps.createTray(icon) as never, + trayTooltip: deps.trayTooltip, + platform: deps.platform, + logWarn: (message: string) => deps.logWarn(message), + ensureOverlayVisibleFromTrayClick: () => { + if (!deps.isOverlayRuntimeInitialized()) { + deps.initializeOverlayRuntime(); + } + deps.setVisibleOverlayVisible(true); + }, + }); +} + +export function createBuildDestroyTrayMainDepsHandler(deps: { + getTray: () => unknown | null; + setTray: (tray: unknown | null) => void; +}) { + return () => ({ + getTray: () => deps.getTray() as never, + setTray: (tray: unknown | null) => deps.setTray(tray), + }); +} + +export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler(deps: { + isOverlayRuntimeInitialized: () => boolean; + initializeOverlayRuntimeCore: (options: unknown) => { invisibleOverlayVisible: boolean }; + buildOptions: () => unknown; + setInvisibleOverlayVisible: (visible: boolean) => void; + setOverlayRuntimeInitialized: (initialized: boolean) => void; + startBackgroundWarmups: () => void; +}) { + return () => ({ + isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), + initializeOverlayRuntimeCore: (options: unknown) => deps.initializeOverlayRuntimeCore(options), + buildOptions: () => deps.buildOptions() as never, + setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible), + setOverlayRuntimeInitialized: (initialized: boolean) => + deps.setOverlayRuntimeInitialized(initialized), + startBackgroundWarmups: () => deps.startBackgroundWarmups(), + }); +} + +export function createBuildOpenYomitanSettingsMainDepsHandler(deps: { + ensureYomitanExtensionLoaded: () => Promise; + openYomitanSettingsWindow: (params: { + yomitanExt: unknown; + getExistingWindow: () => unknown | null; + setWindow: (window: unknown | null) => void; + }) => void; + getExistingWindow: () => unknown | null; + setWindow: (window: unknown | null) => void; + logWarn: (message: string) => void; + logError: (message: string, error: unknown) => void; +}) { + return () => ({ + ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(), + openYomitanSettingsWindow: (params: { + yomitanExt: unknown; + getExistingWindow: () => unknown | null; + setWindow: (window: unknown | null) => void; + }) => deps.openYomitanSettingsWindow(params), + getExistingWindow: () => deps.getExistingWindow(), + setWindow: (window: unknown | null) => deps.setWindow(window), + logWarn: (message: string) => deps.logWarn(message), + logError: (message: string, error: unknown) => deps.logError(message, error), + }); +} diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts new file mode 100644 index 0000000..116706a --- /dev/null +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps'; + +test('cli command context main deps builder maps state and callbacks', async () => { + const calls: string[] = []; + const appState = { + mpvSocketPath: '/tmp/mpv.sock', + mpvClient: null as unknown, + texthookerPort: 5174, + overlayRuntimeInitialized: false, + }; + + const build = createBuildCliCommandContextMainDepsHandler({ + appState, + texthookerService: { start: () => null }, + getResolvedConfig: () => ({ texthooker: { openBrowser: true } }), + openExternal: async (url) => { + calls.push(`open:${url}`); + }, + logBrowserOpenError: (url) => calls.push(`open-error:${url}`), + showMpvOsd: (text) => calls.push(`osd:${text}`), + + initializeOverlayRuntime: () => calls.push('init-overlay'), + toggleVisibleOverlay: () => calls.push('toggle-visible'), + toggleInvisibleOverlay: () => calls.push('toggle-invisible'), + setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), + setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`), + + copyCurrentSubtitle: () => calls.push('copy-sub'), + startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`), + mineSentenceCard: async () => { + calls.push('mine'); + }, + startPendingMineSentenceMultiple: (timeoutMs) => calls.push(`mine-multi:${timeoutMs}`), + updateLastCardFromClipboard: async () => { + calls.push('update-last-card'); + }, + refreshKnownWordCache: async () => { + calls.push('refresh-known'); + }, + triggerFieldGrouping: async () => { + calls.push('field-grouping'); + }, + triggerSubsyncFromConfig: async () => { + calls.push('subsync'); + }, + markLastCardAsAudioCard: async () => { + calls.push('mark-audio'); + }, + + getAnilistStatus: () => ({ status: 'ok' }), + clearAnilistToken: () => calls.push('clear-token'), + openAnilistSetupWindow: () => calls.push('open-anilist-setup'), + openJellyfinSetupWindow: () => calls.push('open-jellyfin-setup'), + getAnilistQueueStatus: () => ({ queued: 1 }), + processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }), + runJellyfinCommand: async () => { + calls.push('run-jellyfin'); + }, + + openYomitanSettings: () => calls.push('open-yomitan'), + cycleSecondarySubMode: () => calls.push('cycle-secondary'), + openRuntimeOptionsPalette: () => calls.push('open-runtime-options'), + printHelp: () => calls.push('help'), + stopApp: () => calls.push('stop-app'), + hasMainWindow: () => true, + getMultiCopyTimeoutMs: () => 5000, + schedule: (fn) => { + fn(); + return setTimeout(() => {}, 0); + }, + logInfo: (message) => calls.push(`info:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + logError: (message) => calls.push(`error:${message}`), + }); + + const deps = build(); + assert.equal(deps.getSocketPath(), '/tmp/mpv.sock'); + deps.setSocketPath('/tmp/next.sock'); + assert.equal(appState.mpvSocketPath, '/tmp/next.sock'); + assert.equal(deps.getTexthookerPort(), 5174); + deps.setTexthookerPort(5175); + assert.equal(appState.texthookerPort, 5175); + assert.equal(deps.shouldOpenBrowser(), true); + deps.showOsd('hello'); + deps.initializeOverlay(); + deps.setVisibleOverlay(true); + deps.setInvisibleOverlay(false); + deps.printHelp(); + + assert.deepEqual(calls, [ + 'osd:hello', + 'init-overlay', + 'set-visible:true', + 'set-invisible:false', + 'help', + ]); + + const retry = await deps.retryAnilistQueueNow(); + assert.deepEqual(retry, { ok: true, message: 'ok' }); +}); diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts new file mode 100644 index 0000000..e6204c4 --- /dev/null +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -0,0 +1,102 @@ +import type { CliArgs } from '../../cli/args'; + +export function createBuildCliCommandContextMainDepsHandler(deps: { + appState: { + mpvSocketPath: string; + mpvClient: unknown | null; + texthookerPort: number; + overlayRuntimeInitialized: boolean; + }; + texthookerService: unknown; + getResolvedConfig: () => { texthooker?: { openBrowser?: boolean } }; + openExternal: (url: string) => Promise; + logBrowserOpenError: (url: string, error: unknown) => void; + showMpvOsd: (text: string) => void; + + initializeOverlayRuntime: () => void; + toggleVisibleOverlay: () => void; + toggleInvisibleOverlay: () => void; + setVisibleOverlayVisible: (visible: boolean) => void; + setInvisibleOverlayVisible: (visible: boolean) => void; + + copyCurrentSubtitle: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + mineSentenceCard: () => Promise; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + refreshKnownWordCache: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; + + getAnilistStatus: () => unknown; + clearAnilistToken: () => void; + openAnilistSetupWindow: () => void; + openJellyfinSetupWindow: () => void; + getAnilistQueueStatus: () => unknown; + processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>; + runJellyfinCommand: (args: CliArgs) => Promise; + + openYomitanSettings: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; + stopApp: () => void; + hasMainWindow: () => boolean; + getMultiCopyTimeoutMs: () => number; + schedule: (fn: () => void, delayMs: number) => ReturnType; + logInfo: (message: string) => void; + logWarn: (message: string) => void; + logError: (message: string, err: unknown) => void; +}) { + return () => ({ + getSocketPath: () => deps.appState.mpvSocketPath, + setSocketPath: (socketPath: string) => { + deps.appState.mpvSocketPath = socketPath; + }, + getMpvClient: () => deps.appState.mpvClient as never, + showOsd: (text: string) => deps.showMpvOsd(text), + texthookerService: deps.texthookerService as never, + getTexthookerPort: () => deps.appState.texthookerPort, + setTexthookerPort: (port: number) => { + deps.appState.texthookerPort = port; + }, + shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false, + openExternal: (url: string) => deps.openExternal(url), + logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error), + isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized, + initializeOverlay: () => deps.initializeOverlayRuntime(), + toggleVisibleOverlay: () => deps.toggleVisibleOverlay(), + toggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(), + setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible), + setInvisibleOverlay: (visible: boolean) => deps.setInvisibleOverlayVisible(visible), + copyCurrentSubtitle: () => deps.copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => deps.mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs: number) => + deps.startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(), + refreshKnownWordCache: () => deps.refreshKnownWordCache(), + triggerFieldGrouping: () => deps.triggerFieldGrouping(), + triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), + markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(), + getAnilistStatus: () => deps.getAnilistStatus() as never, + clearAnilistToken: () => deps.clearAnilistToken(), + openAnilistSetup: () => deps.openAnilistSetupWindow(), + openJellyfinSetup: () => deps.openJellyfinSetupWindow(), + getAnilistQueueStatus: () => deps.getAnilistQueueStatus() as never, + retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(), + runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args), + openYomitanSettings: () => deps.openYomitanSettings(), + cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), + openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), + printHelp: () => deps.printHelp(), + stopApp: () => deps.stopApp(), + hasMainWindow: () => deps.hasMainWindow(), + getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(), + schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs), + logInfo: (message: string) => deps.logInfo(message), + logWarn: (message: string) => deps.logWarn(message), + logError: (message: string, err: unknown) => deps.logError(message, err), + }); +} diff --git a/src/main/runtime/global-shortcuts-main-deps.test.ts b/src/main/runtime/global-shortcuts-main-deps.test.ts new file mode 100644 index 0000000..4a9cb00 --- /dev/null +++ b/src/main/runtime/global-shortcuts-main-deps.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildGetConfiguredShortcutsMainDepsHandler, + createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler, + createBuildRegisterGlobalShortcutsMainDepsHandler, +} from './global-shortcuts-main-deps'; + +test('get configured shortcuts main deps map config resolver inputs', () => { + const config = { shortcuts: { copySubtitle: 's' } } as never; + const defaults = { shortcuts: { copySubtitle: 'c' } } as never; + const build = createBuildGetConfiguredShortcutsMainDepsHandler({ + getResolvedConfig: () => config, + defaultConfig: defaults, + resolveConfiguredShortcuts: (nextConfig, nextDefaults) => ({ nextConfig, nextDefaults }) as never, + }); + + const deps = build(); + assert.equal(deps.getResolvedConfig(), config); + assert.equal(deps.defaultConfig, defaults); + assert.deepEqual(deps.resolveConfiguredShortcuts(config, defaults), { nextConfig: config, nextDefaults: defaults }); +}); + +test('register global shortcuts main deps map callbacks and flags', () => { + const calls: string[] = []; + const mainWindow = { id: 'main' }; + const build = createBuildRegisterGlobalShortcutsMainDepsHandler({ + getConfiguredShortcuts: () => ({ copySubtitle: 's' } as never), + registerGlobalShortcutsCore: () => calls.push('register'), + toggleVisibleOverlay: () => calls.push('toggle-visible'), + toggleInvisibleOverlay: () => calls.push('toggle-invisible'), + openYomitanSettings: () => calls.push('open-yomitan'), + isDev: true, + getMainWindow: () => mainWindow as never, + }); + + const deps = build(); + deps.registerGlobalShortcutsCore({ + shortcuts: deps.getConfiguredShortcuts(), + onToggleVisibleOverlay: () => undefined, + onToggleInvisibleOverlay: () => undefined, + onOpenYomitanSettings: () => undefined, + isDev: deps.isDev, + getMainWindow: deps.getMainWindow, + }); + deps.onToggleVisibleOverlay(); + deps.onToggleInvisibleOverlay(); + deps.onOpenYomitanSettings(); + assert.equal(deps.isDev, true); + assert.deepEqual(deps.getMainWindow(), mainWindow); + assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']); +}); + +test('refresh global shortcuts main deps map passthrough handlers', () => { + const calls: string[] = []; + const deps = createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler({ + unregisterAllGlobalShortcuts: () => calls.push('unregister'), + registerGlobalShortcuts: () => calls.push('register'), + syncOverlayShortcuts: () => calls.push('sync'), + })(); + + deps.unregisterAllGlobalShortcuts(); + deps.registerGlobalShortcuts(); + deps.syncOverlayShortcuts(); + assert.deepEqual(calls, ['unregister', 'register', 'sync']); +}); diff --git a/src/main/runtime/global-shortcuts-main-deps.ts b/src/main/runtime/global-shortcuts-main-deps.ts new file mode 100644 index 0000000..779950f --- /dev/null +++ b/src/main/runtime/global-shortcuts-main-deps.ts @@ -0,0 +1,49 @@ +import type { Config } from '../../types'; +import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config'; +import type { RegisterGlobalShortcutsServiceOptions } from '../../core/services/shortcut'; + +export function createBuildGetConfiguredShortcutsMainDepsHandler(deps: { + getResolvedConfig: () => Config; + defaultConfig: Config; + resolveConfiguredShortcuts: (config: Config, defaultConfig: Config) => ConfiguredShortcuts; +}) { + return () => ({ + getResolvedConfig: () => deps.getResolvedConfig(), + defaultConfig: deps.defaultConfig, + resolveConfiguredShortcuts: (config: Config, defaultConfig: Config) => + deps.resolveConfiguredShortcuts(config, defaultConfig), + }); +} + +export function createBuildRegisterGlobalShortcutsMainDepsHandler(deps: { + getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts']; + registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void; + toggleVisibleOverlay: () => void; + toggleInvisibleOverlay: () => void; + openYomitanSettings: () => void; + isDev: boolean; + getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow']; +}) { + return () => ({ + getConfiguredShortcuts: () => deps.getConfiguredShortcuts(), + registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => + deps.registerGlobalShortcutsCore(options), + onToggleVisibleOverlay: () => deps.toggleVisibleOverlay(), + onToggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(), + onOpenYomitanSettings: () => deps.openYomitanSettings(), + isDev: deps.isDev, + getMainWindow: deps.getMainWindow, + }); +} + +export function createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler(deps: { + unregisterAllGlobalShortcuts: () => void; + registerGlobalShortcuts: () => void; + syncOverlayShortcuts: () => void; +}) { + return () => ({ + unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(), + registerGlobalShortcuts: () => deps.registerGlobalShortcuts(), + syncOverlayShortcuts: () => deps.syncOverlayShortcuts(), + }); +} diff --git a/src/main/runtime/initial-args-main-deps.test.ts b/src/main/runtime/initial-args-main-deps.test.ts new file mode 100644 index 0000000..efd3fc3 --- /dev/null +++ b/src/main/runtime/initial-args-main-deps.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildHandleInitialArgsMainDepsHandler } from './initial-args-main-deps'; + +test('initial args main deps builder maps runtime callbacks and state readers', () => { + const calls: string[] = []; + const args = { start: true } as never; + const mpvClient = { connected: false, connect: () => calls.push('connect') }; + const deps = createBuildHandleInitialArgsMainDepsHandler({ + getInitialArgs: () => args, + isBackgroundMode: () => true, + ensureTray: () => calls.push('ensure-tray'), + isTexthookerOnlyMode: () => false, + hasImmersionTracker: () => true, + getMpvClient: () => mpvClient, + logInfo: (message) => calls.push(`info:${message}`), + handleCliCommand: (_args, source) => calls.push(`cli:${source}`), + })(); + + assert.equal(deps.getInitialArgs(), args); + assert.equal(deps.isBackgroundMode(), true); + assert.equal(deps.isTexthookerOnlyMode(), false); + assert.equal(deps.hasImmersionTracker(), true); + assert.equal(deps.getMpvClient(), mpvClient); + + deps.ensureTray(); + deps.logInfo('x'); + deps.handleCliCommand(args, 'initial'); + assert.deepEqual(calls, ['ensure-tray', 'info:x', 'cli:initial']); +}); diff --git a/src/main/runtime/initial-args-main-deps.ts b/src/main/runtime/initial-args-main-deps.ts new file mode 100644 index 0000000..f0b7a64 --- /dev/null +++ b/src/main/runtime/initial-args-main-deps.ts @@ -0,0 +1,23 @@ +import type { CliArgs } from '../../cli/args'; + +export function createBuildHandleInitialArgsMainDepsHandler(deps: { + getInitialArgs: () => CliArgs | null; + isBackgroundMode: () => boolean; + ensureTray: () => void; + isTexthookerOnlyMode: () => boolean; + hasImmersionTracker: () => boolean; + getMpvClient: () => { connected: boolean; connect: () => void } | null; + logInfo: (message: string) => void; + handleCliCommand: (args: CliArgs, source: 'initial') => void; +}) { + return () => ({ + getInitialArgs: () => deps.getInitialArgs(), + isBackgroundMode: () => deps.isBackgroundMode(), + ensureTray: () => deps.ensureTray(), + isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(), + hasImmersionTracker: () => deps.hasImmersionTracker(), + getMpvClient: () => deps.getMpvClient(), + logInfo: (message: string) => deps.logInfo(message), + handleCliCommand: (args: CliArgs, source: 'initial') => deps.handleCliCommand(args, source), + }); +} diff --git a/src/main/runtime/jellyfin-command-dispatch.test.ts b/src/main/runtime/jellyfin-command-dispatch.test.ts new file mode 100644 index 0000000..5f0b39b --- /dev/null +++ b/src/main/runtime/jellyfin-command-dispatch.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createRunJellyfinCommandHandler } from './jellyfin-command-dispatch'; +import type { CliArgs } from '../../cli/args'; + +function createArgs(overrides: Partial = {}): CliArgs { + return { + raw: [], + target: null, + start: false, + stats: false, + listRecent: false, + listMediaInfo: false, + subs: false, + noAnki: false, + noKnown: false, + noAnilist: false, + anilistStatus: false, + clearAnilistToken: false, + anilistSetup: false, + anilistQueueStatus: false, + anilistQueueRetry: false, + yomitanSettings: false, + toggleOverlay: false, + hideOverlay: false, + showOverlay: false, + toggleInvisibleOverlay: false, + hideInvisibleOverlay: false, + showInvisibleOverlay: false, + copyCurrentSubtitle: false, + multiCopy: false, + mineSentence: false, + mineSentenceMultiple: false, + updateLastCardFromClipboard: false, + refreshKnownCache: false, + triggerFieldGrouping: false, + manualSubsync: false, + markAudioCard: false, + cycleSecondarySub: false, + runtimeOptions: false, + debugOverlay: false, + jellyfinSetup: false, + jellyfinLogin: false, + jellyfinLibraries: false, + jellyfinItems: false, + jellyfinSubtitles: false, + jellyfinPlay: false, + jellyfinRemoteAnnounce: false, + help: false, + ...overrides, + } as CliArgs; +} + +test('run jellyfin command returns after auth branch handles command', async () => { + const calls: string[] = []; + const run = createRunJellyfinCommandHandler({ + getJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096' }), + defaultServerUrl: 'http://127.0.0.1:8096', + getJellyfinClientInfo: () => ({ clientName: 'SubMiner' }), + handleAuthCommands: async () => { + calls.push('auth'); + return true; + }, + handleRemoteAnnounceCommand: async () => { + calls.push('remote'); + return false; + }, + handleListCommands: async () => { + calls.push('list'); + return false; + }, + handlePlayCommand: async () => { + calls.push('play'); + return false; + }, + }); + + await run(createArgs()); + assert.deepEqual(calls, ['auth']); +}); + +test('run jellyfin command throws when session missing after auth', async () => { + const run = createRunJellyfinCommandHandler({ + getJellyfinConfig: () => ({ serverUrl: '', accessToken: '', userId: '' }), + defaultServerUrl: '', + getJellyfinClientInfo: () => ({ clientName: 'SubMiner' }), + handleAuthCommands: async () => false, + handleRemoteAnnounceCommand: async () => false, + handleListCommands: async () => false, + handlePlayCommand: async () => false, + }); + + await assert.rejects(() => run(createArgs()), /Missing Jellyfin session/); +}); + +test('run jellyfin command dispatches remote/list/play in order until handled', async () => { + const calls: string[] = []; + const seenServerUrls: string[] = []; + const run = createRunJellyfinCommandHandler({ + getJellyfinConfig: () => ({ + serverUrl: 'http://localhost:8096', + accessToken: 'token', + userId: 'uid', + username: 'alice', + }), + defaultServerUrl: 'http://127.0.0.1:8096', + getJellyfinClientInfo: () => ({ clientName: 'SubMiner' }), + handleAuthCommands: async ({ serverUrl }) => { + calls.push('auth'); + seenServerUrls.push(serverUrl); + return false; + }, + handleRemoteAnnounceCommand: async () => { + calls.push('remote'); + return false; + }, + handleListCommands: async ({ session }) => { + calls.push('list'); + seenServerUrls.push(session.serverUrl); + return true; + }, + handlePlayCommand: async () => { + calls.push('play'); + return false; + }, + }); + + await run(createArgs({ jellyfinServer: 'http://override:8096' })); + + assert.deepEqual(calls, ['auth', 'remote', 'list']); + assert.deepEqual(seenServerUrls, ['http://override:8096', 'http://override:8096']); +}); diff --git a/src/main/runtime/jellyfin-command-dispatch.ts b/src/main/runtime/jellyfin-command-dispatch.ts new file mode 100644 index 0000000..109e661 --- /dev/null +++ b/src/main/runtime/jellyfin-command-dispatch.ts @@ -0,0 +1,100 @@ +import type { CliArgs } from '../../cli/args'; + +type JellyfinConfigBase = { + serverUrl?: string; + accessToken?: string; + userId?: string; + username?: string; +}; + +type JellyfinSession = { + serverUrl: string; + accessToken: string; + userId: string; + username: string; +}; + +export function createRunJellyfinCommandHandler< + TClientInfo, + TConfig extends JellyfinConfigBase, +>(deps: { + getJellyfinConfig: () => TConfig; + defaultServerUrl: string; + getJellyfinClientInfo: (config: TConfig) => TClientInfo; + handleAuthCommands: (params: { + args: CliArgs; + jellyfinConfig: TConfig; + serverUrl: string; + clientInfo: TClientInfo; + }) => Promise; + handleRemoteAnnounceCommand: (args: CliArgs) => Promise; + handleListCommands: (params: { + args: CliArgs; + session: JellyfinSession; + clientInfo: TClientInfo; + jellyfinConfig: TConfig; + }) => Promise; + handlePlayCommand: (params: { + args: CliArgs; + session: JellyfinSession; + clientInfo: TClientInfo; + jellyfinConfig: TConfig; + }) => Promise; +}) { + return async (args: CliArgs): Promise => { + const jellyfinConfig = deps.getJellyfinConfig(); + const serverUrl = + args.jellyfinServer?.trim() || jellyfinConfig.serverUrl || deps.defaultServerUrl; + const clientInfo = deps.getJellyfinClientInfo(jellyfinConfig); + + if ( + await deps.handleAuthCommands({ + args, + jellyfinConfig, + serverUrl, + clientInfo, + }) + ) { + return; + } + + const accessToken = jellyfinConfig.accessToken; + const userId = jellyfinConfig.userId; + if (!serverUrl || !accessToken || !userId) { + throw new Error('Missing Jellyfin session. Run --jellyfin-login first.'); + } + + const session: JellyfinSession = { + serverUrl, + accessToken, + userId, + username: jellyfinConfig.username || '', + }; + + if (await deps.handleRemoteAnnounceCommand(args)) { + return; + } + + if ( + await deps.handleListCommands({ + args, + session, + clientInfo, + jellyfinConfig, + }) + ) { + return; + } + + if ( + await deps.handlePlayCommand({ + args, + session, + clientInfo, + jellyfinConfig, + }) + ) { + return; + } + }; +} diff --git a/src/main/runtime/jellyfin-playback-launch.test.ts b/src/main/runtime/jellyfin-playback-launch.test.ts new file mode 100644 index 0000000..c5decfc --- /dev/null +++ b/src/main/runtime/jellyfin-playback-launch.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createPlayJellyfinItemInMpvHandler } from './jellyfin-playback-launch'; + +const baseSession = { + serverUrl: 'http://localhost:8096', + accessToken: 'token', + userId: 'uid', + username: 'alice', +}; + +const baseClientInfo = { + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'did', +}; + +test('playback handler throws when mpv is not connected', async () => { + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => false, + getMpvClient: () => null, + resolvePlaybackPlan: async () => { + throw new Error('unreachable'); + }, + applyJellyfinMpvDefaults: () => {}, + sendMpvCommand: () => {}, + armQuitOnDisconnect: () => {}, + schedule: () => {}, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => {}, + setActivePlayback: () => {}, + setLastProgressAtMs: () => {}, + reportPlaying: () => {}, + showMpvOsd: () => {}, + }); + + await assert.rejects( + () => + handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-1', + }), + /MPV not connected and auto-launch failed/, + ); +}); + +test('playback handler drives mpv commands and playback state', async () => { + const commands: Array> = []; + const scheduled: Array<{ delay: number; callback: () => void }> = []; + const calls: string[] = []; + const activeStates: Array> = []; + const reportPayloads: Array> = []; + const handler = createPlayJellyfinItemInMpvHandler({ + ensureMpvConnectedForPlayback: async () => true, + getMpvClient: () => ({ connected: true }), + resolvePlaybackPlan: async () => ({ + url: 'https://stream.example/video.m3u8', + mode: 'direct', + title: 'Episode 1', + startTimeTicks: 12_000_000, + audioStreamIndex: 1, + subtitleStreamIndex: 2, + }), + applyJellyfinMpvDefaults: () => calls.push('defaults'), + sendMpvCommand: (command) => commands.push(command), + armQuitOnDisconnect: () => calls.push('arm'), + schedule: (callback, delayMs) => { + scheduled.push({ delay: delayMs, callback }); + }, + convertTicksToSeconds: (ticks) => ticks / 10_000_000, + preloadExternalSubtitles: () => calls.push('preload'), + setActivePlayback: (state) => activeStates.push(state as Record), + setLastProgressAtMs: (value) => calls.push(`progress:${value}`), + reportPlaying: (payload) => reportPayloads.push(payload as Record), + showMpvOsd: (text) => calls.push(`osd:${text}`), + }); + + await handler({ + session: baseSession, + clientInfo: baseClientInfo, + jellyfinConfig: {}, + itemId: 'item-1', + }); + + assert.deepEqual(commands.slice(0, 5), [ + ['set_property', 'sub-auto', 'no'], + ['loadfile', 'https://stream.example/video.m3u8', 'replace'], + ['set_property', 'force-media-title', '[Jellyfin/direct] Episode 1'], + ['set_property', 'sid', 'no'], + ['seek', 1.2, 'absolute+exact'], + ]); + assert.equal(scheduled.length, 1); + assert.equal(scheduled[0]?.delay, 500); + scheduled[0]?.callback(); + assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']); + + assert.ok(calls.includes('defaults')); + assert.ok(calls.includes('arm')); + assert.ok(calls.includes('preload')); + assert.ok(calls.includes('progress:0')); + assert.ok(calls.includes('osd:Jellyfin direct: Episode 1')); + + assert.equal(activeStates.length, 1); + assert.equal(activeStates[0]?.playMethod, 'DirectPlay'); + assert.equal(reportPayloads.length, 1); + assert.equal(reportPayloads[0]?.eventName, 'start'); +}); diff --git a/src/main/runtime/jellyfin-playback-launch.ts b/src/main/runtime/jellyfin-playback-launch.ts new file mode 100644 index 0000000..402e7b7 --- /dev/null +++ b/src/main/runtime/jellyfin-playback-launch.ts @@ -0,0 +1,138 @@ +type JellyfinSession = { + serverUrl: string; + accessToken: string; + userId: string; + username: string; +}; + +type JellyfinClientInfo = { + clientName: string; + clientVersion: string; + deviceId: string; +}; + +type JellyfinPlaybackPlan = { + url: string; + mode: 'direct' | 'transcode'; + title: string; + startTimeTicks: number; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; +}; + +type ActivePlaybackState = { + itemId: string; + mediaSourceId: undefined; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + playMethod: 'DirectPlay' | 'Transcode'; +}; + +type MpvClientLike = unknown; + +export function createPlayJellyfinItemInMpvHandler(deps: { + ensureMpvConnectedForPlayback: () => Promise; + getMpvClient: () => MpvClientLike | null; + resolvePlaybackPlan: (params: { + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + jellyfinConfig: unknown; + itemId: string; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + }) => Promise; + applyJellyfinMpvDefaults: (mpvClient: MpvClientLike) => void; + sendMpvCommand: (command: Array) => void; + armQuitOnDisconnect: () => void; + schedule: (callback: () => void, delayMs: number) => void; + convertTicksToSeconds: (ticks: number) => number; + preloadExternalSubtitles: (params: { + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + itemId: string; + }) => void; + setActivePlayback: (state: ActivePlaybackState) => void; + setLastProgressAtMs: (value: number) => void; + reportPlaying: (payload: { + itemId: string; + mediaSourceId: undefined; + playMethod: 'DirectPlay' | 'Transcode'; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + eventName: 'start'; + }) => void; + showMpvOsd: (text: string) => void; +}) { + return async (params: { + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + jellyfinConfig: unknown; + itemId: string; + audioStreamIndex?: number | null; + subtitleStreamIndex?: number | null; + startTimeTicksOverride?: number; + setQuitOnDisconnectArm?: boolean; + }): Promise => { + const connected = await deps.ensureMpvConnectedForPlayback(); + const mpvClient = deps.getMpvClient(); + if (!connected || !mpvClient) { + throw new Error( + 'MPV not connected and auto-launch failed. Ensure mpv is installed and available in PATH.', + ); + } + + const plan = await deps.resolvePlaybackPlan({ + session: params.session, + clientInfo: params.clientInfo, + jellyfinConfig: params.jellyfinConfig, + itemId: params.itemId, + audioStreamIndex: params.audioStreamIndex, + subtitleStreamIndex: params.subtitleStreamIndex, + }); + + deps.applyJellyfinMpvDefaults(mpvClient); + deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); + deps.sendMpvCommand(['loadfile', plan.url, 'replace']); + if (params.setQuitOnDisconnectArm !== false) { + deps.armQuitOnDisconnect(); + } + deps.sendMpvCommand(['set_property', 'force-media-title', `[Jellyfin/${plan.mode}] ${plan.title}`]); + deps.sendMpvCommand(['set_property', 'sid', 'no']); + deps.schedule(() => { + deps.sendMpvCommand(['set_property', 'sid', 'no']); + }, 500); + + const startTimeTicks = + typeof params.startTimeTicksOverride === 'number' + ? Math.max(0, params.startTimeTicksOverride) + : plan.startTimeTicks; + if (startTimeTicks > 0) { + deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']); + } + + deps.preloadExternalSubtitles({ + session: params.session, + clientInfo: params.clientInfo, + itemId: params.itemId, + }); + + const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode'; + deps.setActivePlayback({ + itemId: params.itemId, + mediaSourceId: undefined, + audioStreamIndex: plan.audioStreamIndex, + subtitleStreamIndex: plan.subtitleStreamIndex, + playMethod, + }); + deps.setLastProgressAtMs(0); + deps.reportPlaying({ + itemId: params.itemId, + mediaSourceId: undefined, + playMethod, + audioStreamIndex: plan.audioStreamIndex, + subtitleStreamIndex: plan.subtitleStreamIndex, + eventName: 'start', + }); + deps.showMpvOsd(`Jellyfin ${plan.mode}: ${plan.title}`); + }; +} diff --git a/src/main/runtime/jellyfin-setup-window.test.ts b/src/main/runtime/jellyfin-setup-window.test.ts index 3803dae..6046103 100644 --- a/src/main/runtime/jellyfin-setup-window.test.ts +++ b/src/main/runtime/jellyfin-setup-window.test.ts @@ -7,6 +7,7 @@ import { createHandleJellyfinSetupSubmissionHandler, createHandleJellyfinSetupWindowOpenedHandler, createMaybeFocusExistingJellyfinSetupWindowHandler, + createOpenJellyfinSetupWindowHandler, parseJellyfinSetupSubmissionUrl, } from './jellyfin-setup-window'; @@ -144,3 +145,114 @@ test('createHandleJellyfinSetupWindowOpenedHandler sets setup window ref', () => handler(); assert.equal(set, true); }); + +test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is focused', () => { + const calls: string[] = []; + const handler = createOpenJellyfinSetupWindowHandler({ + maybeFocusExistingSetupWindow: () => { + calls.push('focus-existing'); + return true; + }, + createSetupWindow: () => { + calls.push('create-window'); + throw new Error('should not create'); + }, + getResolvedJellyfinConfig: () => ({}), + buildSetupFormHtml: () => '', + parseSubmissionUrl: () => null, + authenticateWithPassword: async () => { + throw new Error('should not auth'); + }, + getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }), + patchJellyfinConfig: () => {}, + logInfo: () => {}, + logError: () => {}, + showMpvOsd: () => {}, + clearSetupWindow: () => {}, + setSetupWindow: () => {}, + encodeURIComponent: (value) => value, + }); + + handler(); + assert.deepEqual(calls, ['focus-existing']); +}); + +test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window lifecycle', async () => { + let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null; + let closedHandler: (() => void) | null = null; + let prevented = false; + const calls: string[] = []; + const fakeWindow = { + focus: () => {}, + webContents: { + on: (event: 'will-navigate', handler: (event: { preventDefault: () => void }, url: string) => void) => { + if (event === 'will-navigate') { + willNavigateHandler = handler; + } + }, + }, + loadURL: (url: string) => { + calls.push(`load:${url.startsWith('data:text/html;charset=utf-8,') ? 'data-url' : 'other'}`); + }, + on: (event: 'closed', handler: () => void) => { + if (event === 'closed') { + closedHandler = handler; + } + }, + isDestroyed: () => false, + close: () => calls.push('close'), + }; + + const handler = createOpenJellyfinSetupWindowHandler({ + maybeFocusExistingSetupWindow: () => false, + createSetupWindow: () => fakeWindow, + getResolvedJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096', username: 'alice' }), + buildSetupFormHtml: (server, username) => `${server}|${username}`, + parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), + authenticateWithPassword: async () => ({ + serverUrl: 'http://localhost:8096', + username: 'alice', + accessToken: 'token', + userId: 'uid', + }), + getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did' }), + patchJellyfinConfig: () => calls.push('patch'), + logInfo: () => calls.push('info'), + logError: () => calls.push('error'), + showMpvOsd: (message) => calls.push(`osd:${message}`), + clearSetupWindow: () => calls.push('clear-window'), + setSetupWindow: () => calls.push('set-window'), + encodeURIComponent: (value) => encodeURIComponent(value), + }); + + handler(); + assert.ok(willNavigateHandler); + assert.ok(closedHandler); + assert.deepEqual(calls.slice(0, 2), ['load:data-url', 'set-window']); + + const navHandler = willNavigateHandler as ((event: { preventDefault: () => void }, url: string) => void) | null; + if (!navHandler) { + throw new Error('missing will-navigate handler'); + } + navHandler( + { + preventDefault: () => { + prevented = true; + }, + }, + 'subminer://jellyfin-setup?server=http%3A%2F%2Flocalhost&username=alice&password=pass', + ); + await Promise.resolve(); + + assert.equal(prevented, true); + assert.ok(calls.includes('patch')); + assert.ok(calls.includes('osd:Jellyfin login success')); + assert.ok(calls.includes('close')); + + const onClosed = closedHandler as (() => void) | null; + if (!onClosed) { + throw new Error('missing closed handler'); + } + onClosed(); + assert.ok(calls.includes('clear-window')); +}); diff --git a/src/main/runtime/jellyfin-setup-window.ts b/src/main/runtime/jellyfin-setup-window.ts index c8c3f64..92f8488 100644 --- a/src/main/runtime/jellyfin-setup-window.ts +++ b/src/main/runtime/jellyfin-setup-window.ts @@ -15,6 +15,18 @@ type FocusableWindowLike = { focus: () => void; }; +type JellyfinSetupWebContentsLike = { + on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void; +}; + +type JellyfinSetupWindowLike = FocusableWindowLike & { + webContents: JellyfinSetupWebContentsLike; + loadURL: (url: string) => unknown; + on: (event: 'closed', handler: () => void) => void; + isDestroyed: () => boolean; + close: () => void; +}; + function escapeHtmlAttr(value: string): string { return value.replace(/"/g, '"'); } @@ -169,3 +181,82 @@ export function createHandleJellyfinSetupWindowOpenedHandler(deps: { deps.setSetupWindow(); }; } + +export function createOpenJellyfinSetupWindowHandler(deps: { + maybeFocusExistingSetupWindow: () => boolean; + createSetupWindow: () => TWindow; + getResolvedJellyfinConfig: () => { serverUrl?: string | null; username?: string | null }; + buildSetupFormHtml: (defaultServer: string, defaultUser: string) => string; + parseSubmissionUrl: (rawUrl: string) => { server: string; username: string; password: string } | null; + authenticateWithPassword: ( + server: string, + username: string, + password: string, + clientInfo: JellyfinClientInfo, + ) => Promise; + getJellyfinClientInfo: () => JellyfinClientInfo; + patchJellyfinConfig: (session: JellyfinSession) => void; + logInfo: (message: string) => void; + logError: (message: string, error: unknown) => void; + showMpvOsd: (message: string) => void; + clearSetupWindow: () => void; + setSetupWindow: (window: TWindow) => void; + encodeURIComponent: (value: string) => string; +}) { + return (): void => { + if (deps.maybeFocusExistingSetupWindow()) { + return; + } + + const setupWindow = deps.createSetupWindow(); + const defaults = deps.getResolvedJellyfinConfig(); + const defaultServer = defaults.serverUrl || 'http://127.0.0.1:8096'; + const defaultUser = defaults.username || ''; + const formHtml = deps.buildSetupFormHtml(defaultServer, defaultUser); + const handleSubmission = createHandleJellyfinSetupSubmissionHandler({ + parseSubmissionUrl: (rawUrl) => deps.parseSubmissionUrl(rawUrl), + authenticateWithPassword: (server, username, password, clientInfo) => + deps.authenticateWithPassword(server, username, password, clientInfo), + getJellyfinClientInfo: () => deps.getJellyfinClientInfo(), + patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session), + logInfo: (message) => deps.logInfo(message), + logError: (message, error) => deps.logError(message, error), + showMpvOsd: (message) => deps.showMpvOsd(message), + closeSetupWindow: () => { + if (!setupWindow.isDestroyed()) { + setupWindow.close(); + } + }, + }); + const handleNavigation = createHandleJellyfinSetupNavigationHandler({ + setupSchemePrefix: 'subminer://jellyfin-setup', + handleSubmission: (rawUrl) => handleSubmission(rawUrl), + logError: (message, error) => deps.logError(message, error), + }); + const handleWindowClosed = createHandleJellyfinSetupWindowClosedHandler({ + clearSetupWindow: () => deps.clearSetupWindow(), + }); + const handleWindowOpened = createHandleJellyfinSetupWindowOpenedHandler({ + setSetupWindow: () => deps.setSetupWindow(setupWindow), + }); + + setupWindow.webContents.on('will-navigate', (event, url) => { + handleNavigation({ + url, + preventDefault: () => { + if (event && typeof event === 'object' && 'preventDefault' in event) { + const typedEvent = event as { preventDefault?: () => void }; + typedEvent.preventDefault?.(); + } + }, + }); + }); + void setupWindow.loadURL( + `data:text/html;charset=utf-8,${deps.encodeURIComponent(formHtml)}`, + ); + setupWindow.on('closed', () => { + handleWindowClosed(); + }); + handleWindowOpened(); + }; +} diff --git a/src/main/runtime/jellyfin-subtitle-preload.test.ts b/src/main/runtime/jellyfin-subtitle-preload.test.ts new file mode 100644 index 0000000..696a917 --- /dev/null +++ b/src/main/runtime/jellyfin-subtitle-preload.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createPreloadJellyfinExternalSubtitlesHandler } from './jellyfin-subtitle-preload'; + +const session = { + serverUrl: 'http://localhost:8096', + accessToken: 'token', + userId: 'uid', + username: 'alice', +}; + +const clientInfo = { + clientName: 'SubMiner', + clientVersion: '1.0', + deviceId: 'dev', +}; + +test('preload jellyfin subtitles adds external tracks and chooses japanese+english tracks', async () => { + const commands: Array> = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + { index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, + { index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, + ], + getMpvClient: () => ({ + requestProperty: async () => [ + { type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true }, + { type: 'sub', id: 6, lang: 'eng', title: 'English', external: true }, + ], + }), + sendMpvCommand: (command) => commands.push(command), + wait: async () => {}, + logDebug: () => {}, + }); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.deepEqual(commands, [ + ['sub-add', 'https://sub/a.srt', 'cached', 'Japanese', 'jpn'], + ['sub-add', 'https://sub/b.srt', 'cached', 'English SDH', 'eng'], + ['set_property', 'sid', 5], + ['set_property', 'secondary-sid', 6], + ]); +}); + +test('preload jellyfin subtitles exits quietly when no external tracks', async () => { + const commands: Array> = []; + let waited = false; + const preload = createPreloadJellyfinExternalSubtitlesHandler({ + listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }], + getMpvClient: () => ({ requestProperty: async () => [] }), + sendMpvCommand: (command) => commands.push(command), + wait: async () => { + waited = true; + }, + logDebug: () => {}, + }); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.equal(waited, false); + assert.deepEqual(commands, []); +}); + +test('preload jellyfin subtitles logs debug on failure', async () => { + const logs: string[] = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler({ + listJellyfinSubtitleTracks: async () => { + throw new Error('network down'); + }, + getMpvClient: () => null, + sendMpvCommand: () => {}, + wait: async () => {}, + logDebug: (message) => logs.push(message), + }); + + await preload({ session, clientInfo, itemId: 'item-1' }); + + assert.deepEqual(logs, ['Failed to preload Jellyfin external subtitles']); +}); diff --git a/src/main/runtime/jellyfin-subtitle-preload.ts b/src/main/runtime/jellyfin-subtitle-preload.ts new file mode 100644 index 0000000..beb2e5f --- /dev/null +++ b/src/main/runtime/jellyfin-subtitle-preload.ts @@ -0,0 +1,163 @@ +type JellyfinSession = { + serverUrl: string; + accessToken: string; + userId: string; + username: string; +}; + +type JellyfinClientInfo = { + clientName: string; + clientVersion: string; + deviceId: string; +}; + +type JellyfinSubtitleTrack = { + index: number; + language?: string; + title?: string; + deliveryUrl?: string | null; +}; + +type MpvClientLike = { + requestProperty: (name: string) => Promise; +}; + +function normalizeLang(value: unknown): string { + return String(value || '') + .trim() + .toLowerCase() + .replace(/_/g, '-'); +} + +function isJapanese(value: string): boolean { + const v = normalizeLang(value); + return ( + v === 'ja' || + v === 'jp' || + v === 'jpn' || + v === 'japanese' || + v.startsWith('ja-') || + v.startsWith('jp-') + ); +} + +function isEnglish(value: string): boolean { + const v = normalizeLang(value); + return ( + v === 'en' || + v === 'eng' || + v === 'english' || + v === 'enus' || + v === 'en-us' || + v.startsWith('en-') + ); +} + +function isLikelyHearingImpaired(title: string): boolean { + return /\b(hearing impaired|sdh|closed captions?|cc)\b/i.test(title); +} + +function pickBestTrackId( + tracks: Array<{ + id: number; + lang: string; + title: string; + external: boolean; + }>, + languageMatcher: (value: string) => boolean, + excludeId: number | null = null, +): number | null { + const ranked = tracks + .filter((track) => languageMatcher(track.lang)) + .filter((track) => track.id !== excludeId) + .map((track) => ({ + track, + score: + (track.external ? 100 : 0) + + (isLikelyHearingImpaired(track.title) ? -10 : 10) + + (/\bdefault\b/i.test(track.title) ? 3 : 0), + })) + .sort((a, b) => b.score - a.score); + return ranked[0]?.track.id ?? null; +} + +export function createPreloadJellyfinExternalSubtitlesHandler(deps: { + listJellyfinSubtitleTracks: ( + session: JellyfinSession, + clientInfo: JellyfinClientInfo, + itemId: string, + ) => Promise; + getMpvClient: () => MpvClientLike | null; + sendMpvCommand: (command: Array) => void; + wait: (ms: number) => Promise; + logDebug: (message: string, error: unknown) => void; +}) { + return async (params: { + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + itemId: string; + }): Promise => { + try { + const tracks = await deps.listJellyfinSubtitleTracks( + params.session, + params.clientInfo, + params.itemId, + ); + const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl)); + if (externalTracks.length === 0) { + return; + } + + await deps.wait(300); + const seenUrls = new Set(); + for (const track of externalTracks) { + if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) { + continue; + } + seenUrls.add(track.deliveryUrl); + const labelBase = (track.title || track.language || '').trim(); + const label = labelBase || `Jellyfin Subtitle ${track.index}`; + deps.sendMpvCommand([ + 'sub-add', + track.deliveryUrl, + 'cached', + label, + track.language || '', + ]); + } + + await deps.wait(250); + const trackListRaw = await deps.getMpvClient()?.requestProperty('track-list'); + const subtitleTracks = Array.isArray(trackListRaw) + ? trackListRaw + .filter( + (track): track is Record => + Boolean(track) && + typeof track === 'object' && + track.type === 'sub' && + typeof track.id === 'number', + ) + .map((track) => ({ + id: track.id as number, + lang: String(track.lang || ''), + title: String(track.title || ''), + external: track.external === true, + })) + : []; + + const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese); + if (japanesePrimaryId !== null) { + deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); + } else { + deps.sendMpvCommand(['set_property', 'sid', 'no']); + } + + const englishSecondaryId = pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId); + if (englishSecondaryId !== null) { + deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]); + } + } catch (error) { + deps.logDebug('Failed to preload Jellyfin external subtitles', error); + } + }; +} diff --git a/src/main/runtime/mpv-client-runtime-service-main-deps.test.ts b/src/main/runtime/mpv-client-runtime-service-main-deps.test.ts new file mode 100644 index 0000000..351dd8f --- /dev/null +++ b/src/main/runtime/mpv-client-runtime-service-main-deps.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from './mpv-client-runtime-service-main-deps'; + +test('mpv runtime service main deps builder maps state and callbacks', () => { + const calls: string[] = []; + let reconnectTimer: ReturnType | null = null; + + class FakeClient { + constructor(public socketPath: string, public options: unknown) {} + } + + const build = createBuildMpvClientRuntimeServiceFactoryDepsHandler({ + createClient: FakeClient, + getSocketPath: () => '/tmp/mpv.sock', + getResolvedConfig: () => ({ mode: 'test' }), + isAutoStartOverlayEnabled: () => true, + setOverlayVisible: (visible) => calls.push(`overlay:${visible}`), + shouldBindVisibleOverlayToMpvSubVisibility: () => true, + isVisibleOverlayVisible: () => false, + getReconnectTimer: () => reconnectTimer, + setReconnectTimer: (timer) => { + reconnectTimer = timer; + calls.push('set-reconnect'); + }, + bindEventHandlers: () => calls.push('bind'), + }); + + const deps = build(); + assert.equal(deps.socketPath, '/tmp/mpv.sock'); + assert.equal(deps.options.autoStartOverlay, true); + assert.equal(deps.options.shouldBindVisibleOverlayToMpvSubVisibility(), true); + assert.equal(deps.options.isVisibleOverlayVisible(), false); + assert.deepEqual(deps.options.getResolvedConfig(), { mode: 'test' }); + + deps.options.setOverlayVisible(true); + deps.options.setReconnectTimer(setTimeout(() => {}, 0)); + deps.bindEventHandlers(new FakeClient('/tmp/mpv.sock', {})); + + assert.ok(calls.includes('overlay:true')); + assert.ok(calls.includes('set-reconnect')); + assert.ok(calls.includes('bind')); + assert.ok(reconnectTimer); +}); diff --git a/src/main/runtime/mpv-client-runtime-service-main-deps.ts b/src/main/runtime/mpv-client-runtime-service-main-deps.ts new file mode 100644 index 0000000..f9831f7 --- /dev/null +++ b/src/main/runtime/mpv-client-runtime-service-main-deps.ts @@ -0,0 +1,32 @@ +export function createBuildMpvClientRuntimeServiceFactoryDepsHandler< + TClient, + TResolvedConfig, + TOptions, +>(deps: { + createClient: new (socketPath: string, options: TOptions) => TClient; + getSocketPath: () => string; + getResolvedConfig: () => TResolvedConfig; + isAutoStartOverlayEnabled: () => boolean; + setOverlayVisible: (visible: boolean) => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isVisibleOverlayVisible: () => boolean; + getReconnectTimer: () => ReturnType | null; + setReconnectTimer: (timer: ReturnType | null) => void; + bindEventHandlers: (client: TClient) => void; +}) { + return () => ({ + createClient: deps.createClient, + socketPath: deps.getSocketPath(), + options: { + getResolvedConfig: () => deps.getResolvedConfig(), + autoStartOverlay: deps.isAutoStartOverlayEnabled(), + setOverlayVisible: (visible: boolean) => deps.setOverlayVisible(visible), + shouldBindVisibleOverlayToMpvSubVisibility: () => + deps.shouldBindVisibleOverlayToMpvSubVisibility(), + isVisibleOverlayVisible: () => deps.isVisibleOverlayVisible(), + getReconnectTimer: () => deps.getReconnectTimer(), + setReconnectTimer: (timer: ReturnType | null) => deps.setReconnectTimer(timer), + }, + bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client), + }); +} diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts new file mode 100644 index 0000000..beca837 --- /dev/null +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createHandleMpvMediaPathChangeHandler, + createHandleMpvMediaTitleChangeHandler, + createHandleMpvPauseChangeHandler, + createHandleMpvSecondarySubtitleChangeHandler, + createHandleMpvSecondarySubtitleVisibilityHandler, + createHandleMpvSubtitleAssChangeHandler, + createHandleMpvSubtitleChangeHandler, + createHandleMpvSubtitleMetricsChangeHandler, + createHandleMpvTimePosChangeHandler, +} from './mpv-main-event-actions'; + +test('subtitle change handler updates state, broadcasts, and forwards', () => { + const calls: string[] = []; + const handler = createHandleMpvSubtitleChangeHandler({ + setCurrentSubText: (text) => calls.push(`set:${text}`), + broadcastSubtitle: (payload) => calls.push(`broadcast:${payload.text}`), + onSubtitleChange: (text) => calls.push(`process:${text}`), + }); + + handler({ text: 'line' }); + assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line']); +}); + +test('subtitle ass change handler updates state and broadcasts', () => { + const calls: string[] = []; + const handler = createHandleMpvSubtitleAssChangeHandler({ + setCurrentSubAssText: (text) => calls.push(`set:${text}`), + broadcastSubtitleAss: (text) => calls.push(`broadcast:${text}`), + }); + + handler({ text: '{\\an8}line' }); + assert.deepEqual(calls, ['set:{\\an8}line', 'broadcast:{\\an8}line']); +}); + +test('secondary subtitle change handler broadcasts text', () => { + const seen: string[] = []; + const handler = createHandleMpvSecondarySubtitleChangeHandler({ + broadcastSecondarySubtitle: (text) => seen.push(text), + }); + + handler({ text: 'secondary' }); + assert.deepEqual(seen, ['secondary']); +}); + +test('media path change handler reports stop for empty path and probes media key', () => { + const calls: string[] = []; + const handler = createHandleMpvMediaPathChangeHandler({ + updateCurrentMediaPath: (path) => calls.push(`path:${path}`), + reportJellyfinRemoteStopped: () => calls.push('stopped'), + getCurrentAnilistMediaKey: () => 'show:1', + resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`), + maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), + ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), + syncImmersionMediaState: () => calls.push('sync'), + }); + + handler({ path: '' }); + assert.deepEqual(calls, [ + 'path:', + 'stopped', + 'reset:show:1', + 'probe:show:1', + 'guess:show:1', + 'sync', + ]); +}); + +test('media title change handler clears guess state and syncs immersion', () => { + const calls: string[] = []; + const handler = createHandleMpvMediaTitleChangeHandler({ + updateCurrentMediaTitle: (title) => calls.push(`title:${title}`), + resetAnilistMediaGuessState: () => calls.push('reset-guess'), + notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`), + syncImmersionMediaState: () => calls.push('sync'), + }); + + handler({ title: 'Episode 1' }); + assert.deepEqual(calls, ['title:Episode 1', 'reset-guess', 'notify:Episode 1', 'sync']); +}); + +test('time-pos and pause handlers report progress with correct urgency', () => { + const calls: string[] = []; + const timeHandler = createHandleMpvTimePosChangeHandler({ + recordPlaybackPosition: (time) => calls.push(`time:${time}`), + reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`), + }); + const pauseHandler = createHandleMpvPauseChangeHandler({ + recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`), + reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`), + }); + + timeHandler({ time: 12.5 }); + pauseHandler({ paused: true }); + assert.deepEqual(calls, ['time:12.5', 'progress:normal', 'pause:yes', 'progress:force']); +}); + +test('subtitle metrics change handler forwards patch payload', () => { + let received: Record | null = null; + const handler = createHandleMpvSubtitleMetricsChangeHandler({ + updateSubtitleRenderMetrics: (patch) => { + received = patch; + }, + }); + + const patch = { fontSize: 48 }; + handler({ patch }); + assert.deepEqual(received, patch); +}); + +test('secondary subtitle visibility handler stores visibility flag', () => { + const seen: boolean[] = []; + const handler = createHandleMpvSecondarySubtitleVisibilityHandler({ + setPreviousSecondarySubVisibility: (visible) => seen.push(visible), + }); + + handler({ visible: true }); + handler({ visible: false }); + assert.deepEqual(seen, [true, false]); +}); diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts new file mode 100644 index 0000000..c33f527 --- /dev/null +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -0,0 +1,103 @@ +export function createHandleMpvSubtitleChangeHandler(deps: { + setCurrentSubText: (text: string) => void; + broadcastSubtitle: (payload: { text: string; tokens: null }) => void; + onSubtitleChange: (text: string) => void; +}) { + return ({ text }: { text: string }): void => { + deps.setCurrentSubText(text); + deps.broadcastSubtitle({ text, tokens: null }); + deps.onSubtitleChange(text); + }; +} + +export function createHandleMpvSubtitleAssChangeHandler(deps: { + setCurrentSubAssText: (text: string) => void; + broadcastSubtitleAss: (text: string) => void; +}) { + return ({ text }: { text: string }): void => { + deps.setCurrentSubAssText(text); + deps.broadcastSubtitleAss(text); + }; +} + +export function createHandleMpvSecondarySubtitleChangeHandler(deps: { + broadcastSecondarySubtitle: (text: string) => void; +}) { + return ({ text }: { text: string }): void => { + deps.broadcastSecondarySubtitle(text); + }; +} + +export function createHandleMpvMediaPathChangeHandler(deps: { + updateCurrentMediaPath: (path: string) => void; + reportJellyfinRemoteStopped: () => void; + getCurrentAnilistMediaKey: () => string | null; + resetAnilistMediaTracking: (mediaKey: string | null) => void; + maybeProbeAnilistDuration: (mediaKey: string) => void; + ensureAnilistMediaGuess: (mediaKey: string) => void; + syncImmersionMediaState: () => void; +}) { + return ({ path }: { path: string }): void => { + deps.updateCurrentMediaPath(path); + if (!path) { + deps.reportJellyfinRemoteStopped(); + } + const mediaKey = deps.getCurrentAnilistMediaKey(); + deps.resetAnilistMediaTracking(mediaKey); + if (mediaKey) { + deps.maybeProbeAnilistDuration(mediaKey); + deps.ensureAnilistMediaGuess(mediaKey); + } + deps.syncImmersionMediaState(); + }; +} + +export function createHandleMpvMediaTitleChangeHandler(deps: { + updateCurrentMediaTitle: (title: string) => void; + resetAnilistMediaGuessState: () => void; + notifyImmersionTitleUpdate: (title: string) => void; + syncImmersionMediaState: () => void; +}) { + return ({ title }: { title: string }): void => { + deps.updateCurrentMediaTitle(title); + deps.resetAnilistMediaGuessState(); + deps.notifyImmersionTitleUpdate(title); + deps.syncImmersionMediaState(); + }; +} + +export function createHandleMpvTimePosChangeHandler(deps: { + recordPlaybackPosition: (time: number) => void; + reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; +}) { + return ({ time }: { time: number }): void => { + deps.recordPlaybackPosition(time); + deps.reportJellyfinRemoteProgress(false); + }; +} + +export function createHandleMpvPauseChangeHandler(deps: { + recordPauseState: (paused: boolean) => void; + reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; +}) { + return ({ paused }: { paused: boolean }): void => { + deps.recordPauseState(paused); + deps.reportJellyfinRemoteProgress(true); + }; +} + +export function createHandleMpvSubtitleMetricsChangeHandler(deps: { + updateSubtitleRenderMetrics: (patch: Record) => void; +}) { + return ({ patch }: { patch: Record }): void => { + deps.updateSubtitleRenderMetrics(patch); + }; +} + +export function createHandleMpvSecondarySubtitleVisibilityHandler(deps: { + setPreviousSecondarySubVisibility: (visible: boolean) => void; +}) { + return ({ visible }: { visible: boolean }): void => { + deps.setPreviousSecondarySubVisibility(visible); + }; +} diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts new file mode 100644 index 0000000..f20ada0 --- /dev/null +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings'; + +test('main mpv event binder wires callbacks through to runtime deps', () => { + const handlers = new Map void>(); + const calls: string[] = []; + + const bind = createBindMpvMainEventHandlersHandler({ + reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), + hasInitialJellyfinPlayArg: () => false, + isOverlayRuntimeInitialized: () => false, + isQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => { + calls.push('schedule-quit-check'); + }, + isMpvConnected: () => false, + quitApp: () => calls.push('quit-app'), + + recordImmersionSubtitleLine: (text) => calls.push(`immersion:${text}`), + hasSubtitleTimingTracker: () => false, + recordSubtitleTiming: () => calls.push('record-timing'), + maybeRunAnilistPostWatchUpdate: async () => { + calls.push('post-watch'); + }, + logSubtitleTimingError: () => calls.push('subtitle-error'), + + setCurrentSubText: (text) => calls.push(`set-sub:${text}`), + broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`), + onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`), + + setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`), + broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`), + broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`), + + updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`), + getCurrentAnilistMediaKey: () => 'media-key', + resetAnilistMediaTracking: (key) => calls.push(`reset-media:${String(key)}`), + maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), + ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), + syncImmersionMediaState: () => calls.push('sync-immersion'), + + updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`), + resetAnilistMediaGuessState: () => calls.push('reset-guess-state'), + notifyImmersionTitleUpdate: (title) => calls.push(`notify-title:${title}`), + + recordPlaybackPosition: (time) => calls.push(`time-pos:${time}`), + reportJellyfinRemoteProgress: (forceImmediate) => + calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`), + recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`), + + updateSubtitleRenderMetrics: () => calls.push('subtitle-metrics'), + setPreviousSecondarySubVisibility: (visible) => + calls.push(`secondary-visible:${visible ? 'yes' : 'no'}`), + }); + + bind({ + on: (event, handler) => { + handlers.set(event, handler as (payload: unknown) => void); + }, + }); + + handlers.get('subtitle-change')?.({ text: 'line' }); + handlers.get('media-title-change')?.({ title: 'Episode 1' }); + handlers.get('time-pos-change')?.({ time: 2.5 }); + handlers.get('pause-change')?.({ paused: true }); + + assert.ok(calls.includes('set-sub:line')); + assert.ok(calls.includes('broadcast-sub:line')); + assert.ok(calls.includes('subtitle-change:line')); + assert.ok(calls.includes('media-title:Episode 1')); + assert.ok(calls.includes('reset-guess-state')); + assert.ok(calls.includes('notify-title:Episode 1')); + assert.ok(calls.includes('progress:normal')); + assert.ok(calls.includes('progress:force')); +}); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts new file mode 100644 index 0000000..6957e57 --- /dev/null +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -0,0 +1,139 @@ +import { + createBindMpvClientEventHandlers, + createHandleMpvConnectionChangeHandler, + createHandleMpvSubtitleTimingHandler, +} from './mpv-client-event-bindings'; +import { + createHandleMpvMediaPathChangeHandler, + createHandleMpvMediaTitleChangeHandler, + createHandleMpvPauseChangeHandler, + createHandleMpvSecondarySubtitleChangeHandler, + createHandleMpvSecondarySubtitleVisibilityHandler, + createHandleMpvSubtitleAssChangeHandler, + createHandleMpvSubtitleChangeHandler, + createHandleMpvSubtitleMetricsChangeHandler, + createHandleMpvTimePosChangeHandler, +} from './mpv-main-event-actions'; + +type MpvEventClient = { + on: (...args: any[]) => unknown; +}; + +export function createBindMpvMainEventHandlersHandler(deps: { + reportJellyfinRemoteStopped: () => void; + hasInitialJellyfinPlayArg: () => boolean; + isOverlayRuntimeInitialized: () => boolean; + isQuitOnDisconnectArmed: () => boolean; + scheduleQuitCheck: (callback: () => void) => void; + isMpvConnected: () => boolean; + quitApp: () => void; + + recordImmersionSubtitleLine: (text: string, start: number, end: number) => void; + hasSubtitleTimingTracker: () => boolean; + recordSubtitleTiming: (text: string, start: number, end: number) => void; + maybeRunAnilistPostWatchUpdate: () => Promise; + logSubtitleTimingError: (message: string, error: unknown) => void; + + setCurrentSubText: (text: string) => void; + broadcastSubtitle: (payload: { text: string; tokens: null }) => void; + onSubtitleChange: (text: string) => void; + + setCurrentSubAssText: (text: string) => void; + broadcastSubtitleAss: (text: string) => void; + broadcastSecondarySubtitle: (text: string) => void; + + updateCurrentMediaPath: (path: string) => void; + getCurrentAnilistMediaKey: () => string | null; + resetAnilistMediaTracking: (mediaKey: string | null) => void; + maybeProbeAnilistDuration: (mediaKey: string) => void; + ensureAnilistMediaGuess: (mediaKey: string) => void; + syncImmersionMediaState: () => void; + + updateCurrentMediaTitle: (title: string) => void; + resetAnilistMediaGuessState: () => void; + notifyImmersionTitleUpdate: (title: string) => void; + + recordPlaybackPosition: (time: number) => void; + reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + recordPauseState: (paused: boolean) => void; + + updateSubtitleRenderMetrics: (patch: Record) => void; + setPreviousSecondarySubVisibility: (visible: boolean) => void; +}) { + return (mpvClient: MpvEventClient): void => { + const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({ + reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), + hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(), + isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), + isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(), + scheduleQuitCheck: (callback) => deps.scheduleQuitCheck(callback), + isMpvConnected: () => deps.isMpvConnected(), + quitApp: () => deps.quitApp(), + }); + const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({ + recordImmersionSubtitleLine: (text, start, end) => + deps.recordImmersionSubtitleLine(text, start, end), + hasSubtitleTimingTracker: () => deps.hasSubtitleTimingTracker(), + recordSubtitleTiming: (text, start, end) => deps.recordSubtitleTiming(text, start, end), + maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(), + logError: (message, error) => deps.logSubtitleTimingError(message, error), + }); + const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({ + setCurrentSubText: (text) => deps.setCurrentSubText(text), + broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload), + onSubtitleChange: (text) => deps.onSubtitleChange(text), + }); + const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({ + setCurrentSubAssText: (text) => deps.setCurrentSubAssText(text), + broadcastSubtitleAss: (text) => deps.broadcastSubtitleAss(text), + }); + const handleMpvSecondarySubtitleChange = createHandleMpvSecondarySubtitleChangeHandler({ + broadcastSecondarySubtitle: (text) => deps.broadcastSecondarySubtitle(text), + }); + const handleMpvMediaPathChange = createHandleMpvMediaPathChangeHandler({ + updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path), + reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), + getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), + resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey), + maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey), + ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey), + syncImmersionMediaState: () => deps.syncImmersionMediaState(), + }); + const handleMpvMediaTitleChange = createHandleMpvMediaTitleChangeHandler({ + updateCurrentMediaTitle: (title) => deps.updateCurrentMediaTitle(title), + resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(), + notifyImmersionTitleUpdate: (title) => deps.notifyImmersionTitleUpdate(title), + syncImmersionMediaState: () => deps.syncImmersionMediaState(), + }); + const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({ + recordPlaybackPosition: (time) => deps.recordPlaybackPosition(time), + reportJellyfinRemoteProgress: (forceImmediate) => + deps.reportJellyfinRemoteProgress(forceImmediate), + }); + const handleMpvPauseChange = createHandleMpvPauseChangeHandler({ + recordPauseState: (paused) => deps.recordPauseState(paused), + reportJellyfinRemoteProgress: (forceImmediate) => + deps.reportJellyfinRemoteProgress(forceImmediate), + }); + const handleMpvSubtitleMetricsChange = createHandleMpvSubtitleMetricsChangeHandler({ + updateSubtitleRenderMetrics: (patch) => deps.updateSubtitleRenderMetrics(patch), + }); + const handleMpvSecondarySubtitleVisibility = createHandleMpvSecondarySubtitleVisibilityHandler({ + setPreviousSecondarySubVisibility: (visible) => deps.setPreviousSecondarySubVisibility(visible), + }); + + createBindMpvClientEventHandlers({ + onConnectionChange: handleMpvConnectionChange, + onSubtitleChange: handleMpvSubtitleChange, + onSubtitleAssChange: handleMpvSubtitleAssChange, + onSecondarySubtitleChange: handleMpvSecondarySubtitleChange, + onSubtitleTiming: handleMpvSubtitleTiming, + onMediaPathChange: handleMpvMediaPathChange, + onMediaTitleChange: handleMpvMediaTitleChange, + onTimePosChange: handleMpvTimePosChange, + onPauseChange: handleMpvPauseChange, + onSubtitleMetricsChange: handleMpvSubtitleMetricsChange, + onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility, + })(mpvClient as never); + }; +} diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts new file mode 100644 index 0000000..e6651e1 --- /dev/null +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps'; + +test('mpv main event main deps map app state updates and delegate callbacks', async () => { + const calls: string[] = []; + const appState = { + initialArgs: { jellyfinPlay: true }, + overlayRuntimeInitialized: true, + mpvClient: { connected: true }, + immersionTracker: { + recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`), + handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`), + recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`), + recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`), + }, + subtitleTimingTracker: { + recordSubtitle: (text: string) => calls.push(`timing:${text}`), + }, + currentSubText: '', + currentSubAssText: '', + previousSecondarySubVisibility: false, + }; + + const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({ + appState, + getQuitOnDisconnectArmed: () => true, + scheduleQuitCheck: (callback) => { + calls.push('schedule'); + callback(); + }, + quitApp: () => calls.push('quit'), + reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), + maybeRunAnilistPostWatchUpdate: async () => { + calls.push('anilist-post-watch'); + }, + logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`), + broadcastToOverlayWindows: (channel, payload) => calls.push(`broadcast:${channel}:${String(payload)}`), + onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`), + updateCurrentMediaPath: (path) => calls.push(`path:${path}`), + getCurrentAnilistMediaKey: () => 'media-key', + resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`), + maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), + ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), + syncImmersionMediaState: () => calls.push('sync-immersion'), + updateCurrentMediaTitle: (title) => calls.push(`title:${title}`), + resetAnilistMediaGuessState: () => calls.push('reset-guess'), + reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`), + updateSubtitleRenderMetrics: () => calls.push('metrics'), + })(); + + assert.equal(deps.hasInitialJellyfinPlayArg(), true); + assert.equal(deps.isOverlayRuntimeInitialized(), true); + assert.equal(deps.isQuitOnDisconnectArmed(), true); + assert.equal(deps.isMpvConnected(), true); + deps.scheduleQuitCheck(() => calls.push('scheduled-callback')); + deps.quitApp(); + deps.reportJellyfinRemoteStopped(); + deps.recordImmersionSubtitleLine('x', 0, 1); + assert.equal(deps.hasSubtitleTimingTracker(), true); + deps.recordSubtitleTiming('y', 0, 1); + await deps.maybeRunAnilistPostWatchUpdate(); + deps.logSubtitleTimingError('err', new Error('boom')); + deps.setCurrentSubText('sub'); + deps.broadcastSubtitle({ text: 'sub', tokens: null }); + deps.onSubtitleChange('sub'); + deps.setCurrentSubAssText('ass'); + deps.broadcastSubtitleAss('ass'); + deps.broadcastSecondarySubtitle('sec'); + deps.updateCurrentMediaPath('/tmp/video'); + assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key'); + deps.resetAnilistMediaTracking('media-key'); + deps.maybeProbeAnilistDuration('media-key'); + deps.ensureAnilistMediaGuess('media-key'); + deps.syncImmersionMediaState(); + deps.updateCurrentMediaTitle('title'); + deps.resetAnilistMediaGuessState(); + deps.notifyImmersionTitleUpdate('title'); + deps.recordPlaybackPosition(10); + deps.reportJellyfinRemoteProgress(true); + deps.recordPauseState(true); + deps.updateSubtitleRenderMetrics({}); + deps.setPreviousSecondarySubVisibility(true); + + assert.equal(appState.currentSubText, 'sub'); + assert.equal(appState.currentSubAssText, 'ass'); + assert.equal(appState.previousSecondarySubVisibility, true); + assert.ok(calls.includes('remote-stopped')); + assert.ok(calls.includes('anilist-post-watch')); + assert.ok(calls.includes('sync-immersion')); + assert.ok(calls.includes('metrics')); +}); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts new file mode 100644 index 0000000..2468fce --- /dev/null +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -0,0 +1,86 @@ +export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { + appState: { + initialArgs?: { jellyfinPlay?: unknown } | null; + overlayRuntimeInitialized: boolean; + mpvClient: { connected?: boolean } | null; + immersionTracker: { + recordSubtitleLine?: (text: string, start: number, end: number) => void; + handleMediaTitleUpdate?: (title: string) => void; + recordPlaybackPosition?: (time: number) => void; + recordPauseState?: (paused: boolean) => void; + } | null; + subtitleTimingTracker: { + recordSubtitle?: (text: string, start: number, end: number) => void; + } | null; + currentSubText: string; + currentSubAssText: string; + previousSecondarySubVisibility: boolean | null; + }; + getQuitOnDisconnectArmed: () => boolean; + scheduleQuitCheck: (callback: () => void) => void; + quitApp: () => void; + reportJellyfinRemoteStopped: () => void; + maybeRunAnilistPostWatchUpdate: () => Promise; + logSubtitleTimingError: (message: string, error: unknown) => void; + broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + onSubtitleChange: (text: string) => void; + updateCurrentMediaPath: (path: string) => void; + getCurrentAnilistMediaKey: () => string | null; + resetAnilistMediaTracking: (mediaKey: string | null) => void; + maybeProbeAnilistDuration: (mediaKey: string) => void; + ensureAnilistMediaGuess: (mediaKey: string) => void; + syncImmersionMediaState: () => void; + updateCurrentMediaTitle: (title: string) => void; + resetAnilistMediaGuessState: () => void; + reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + updateSubtitleRenderMetrics: (patch: Record) => void; +}) { + return () => ({ + reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), + hasInitialJellyfinPlayArg: () => Boolean(deps.appState.initialArgs?.jellyfinPlay), + isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized, + isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(), + scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback), + isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected), + quitApp: () => deps.quitApp(), + recordImmersionSubtitleLine: (text: string, start: number, end: number) => + deps.appState.immersionTracker?.recordSubtitleLine?.(text, start, end), + hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker), + recordSubtitleTiming: (text: string, start: number, end: number) => + deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end), + maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(), + logSubtitleTimingError: (message: string, error: unknown) => + deps.logSubtitleTimingError(message, error), + setCurrentSubText: (text: string) => { + deps.appState.currentSubText = text; + }, + broadcastSubtitle: (payload: { text: string; tokens: null }) => + deps.broadcastToOverlayWindows('subtitle:set', payload), + onSubtitleChange: (text: string) => deps.onSubtitleChange(text), + setCurrentSubAssText: (text: string) => { + deps.appState.currentSubAssText = text; + }, + broadcastSubtitleAss: (text: string) => deps.broadcastToOverlayWindows('subtitle-ass:set', text), + broadcastSecondarySubtitle: (text: string) => + deps.broadcastToOverlayWindows('secondary-subtitle:set', text), + updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path), + getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), + resetAnilistMediaTracking: (mediaKey: string | null) => deps.resetAnilistMediaTracking(mediaKey), + maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey), + ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey), + syncImmersionMediaState: () => deps.syncImmersionMediaState(), + updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title), + resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(), + notifyImmersionTitleUpdate: (title: string) => + deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title), + recordPlaybackPosition: (time: number) => deps.appState.immersionTracker?.recordPlaybackPosition?.(time), + reportJellyfinRemoteProgress: (forceImmediate: boolean) => + deps.reportJellyfinRemoteProgress(forceImmediate), + recordPauseState: (paused: boolean) => deps.appState.immersionTracker?.recordPauseState?.(paused), + updateSubtitleRenderMetrics: (patch: Record) => + deps.updateSubtitleRenderMetrics(patch), + setPreviousSecondarySubVisibility: (visible: boolean) => { + deps.appState.previousSecondarySubVisibility = visible; + }, + }); +} diff --git a/src/main/runtime/mpv-osd-log-main-deps.test.ts b/src/main/runtime/mpv-osd-log-main-deps.test.ts new file mode 100644 index 0000000..95a6a0a --- /dev/null +++ b/src/main/runtime/mpv-osd-log-main-deps.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildAppendToMpvLogMainDepsHandler, + createBuildShowMpvOsdMainDepsHandler, +} from './mpv-osd-log-main-deps'; + +test('append to mpv log main deps map filesystem functions and log path', () => { + const calls: string[] = []; + const deps = createBuildAppendToMpvLogMainDepsHandler({ + logPath: '/tmp/mpv.log', + dirname: (targetPath) => { + calls.push(`dirname:${targetPath}`); + return '/tmp'; + }, + mkdirSync: (targetPath) => calls.push(`mkdir:${targetPath}`), + appendFileSync: (_targetPath, data) => calls.push(`append:${data}`), + now: () => new Date('2026-02-20T00:00:00.000Z'), + })(); + + assert.equal(deps.logPath, '/tmp/mpv.log'); + assert.equal(deps.dirname('/tmp/mpv.log'), '/tmp'); + deps.mkdirSync('/tmp', { recursive: true }); + deps.appendFileSync('/tmp/mpv.log', 'line', { encoding: 'utf8' }); + assert.equal(deps.now().toISOString(), '2026-02-20T00:00:00.000Z'); + assert.deepEqual(calls, ['dirname:/tmp/mpv.log', 'mkdir:/tmp', 'append:line']); +}); + +test('show mpv osd main deps map runtime delegates and logging callback', () => { + const calls: string[] = []; + const client = { id: 'mpv' }; + const deps = createBuildShowMpvOsdMainDepsHandler({ + appendToMpvLog: (message) => calls.push(`append:${message}`), + showMpvOsdRuntime: (_mpvClient, text, fallbackLog) => { + calls.push(`show:${text}`); + fallbackLog('fallback'); + }, + getMpvClient: () => client, + logInfo: (line) => calls.push(`info:${line}`), + })(); + + assert.deepEqual(deps.getMpvClient(), client); + deps.appendToMpvLog('hello'); + deps.showMpvOsdRuntime(deps.getMpvClient(), 'subtitle', (line) => deps.logInfo(line)); + assert.deepEqual(calls, ['append:hello', 'show:subtitle', 'info:fallback']); +}); diff --git a/src/main/runtime/mpv-osd-log-main-deps.ts b/src/main/runtime/mpv-osd-log-main-deps.ts new file mode 100644 index 0000000..60a87f0 --- /dev/null +++ b/src/main/runtime/mpv-osd-log-main-deps.ts @@ -0,0 +1,39 @@ +export function createBuildAppendToMpvLogMainDepsHandler(deps: { + logPath: string; + dirname: (targetPath: string) => string; + mkdirSync: (targetPath: string, options: { recursive: boolean }) => void; + appendFileSync: (targetPath: string, data: string, options: { encoding: 'utf8' }) => void; + now: () => Date; +}) { + return () => ({ + logPath: deps.logPath, + dirname: (targetPath: string) => deps.dirname(targetPath), + mkdirSync: (targetPath: string, options: { recursive: boolean }) => + deps.mkdirSync(targetPath, options), + appendFileSync: (targetPath: string, data: string, options: { encoding: 'utf8' }) => + deps.appendFileSync(targetPath, data, options), + now: () => deps.now(), + }); +} + +export function createBuildShowMpvOsdMainDepsHandler(deps: { + appendToMpvLog: (message: string) => void; + showMpvOsdRuntime: ( + mpvClient: unknown | null, + text: string, + fallbackLog: (line: string) => void, + ) => void; + getMpvClient: () => unknown | null; + logInfo: (line: string) => void; +}) { + return () => ({ + appendToMpvLog: (message: string) => deps.appendToMpvLog(message), + showMpvOsdRuntime: ( + mpvClient: unknown | null, + text: string, + fallbackLog: (line: string) => void, + ) => deps.showMpvOsdRuntime(mpvClient, text, fallbackLog), + getMpvClient: () => deps.getMpvClient() as never, + logInfo: (line: string) => deps.logInfo(line), + }); +} diff --git a/src/main/runtime/overlay-runtime-options-main-deps.test.ts b/src/main/runtime/overlay-runtime-options-main-deps.test.ts new file mode 100644 index 0000000..e5bc63e --- /dev/null +++ b/src/main/runtime/overlay-runtime-options-main-deps.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildInitializeOverlayRuntimeMainDepsHandler } from './overlay-runtime-options-main-deps'; + +test('overlay runtime main deps builder maps runtime state and callbacks', () => { + const calls: string[] = []; + const appState = { + backendOverride: 'x11' as string | null, + windowTracker: null as unknown, + subtitleTimingTracker: { id: 'tracker' } as unknown, + mpvClient: null as { send?: (payload: { command: string[] }) => void } | null, + mpvSocketPath: '/tmp/mpv.sock', + runtimeOptionsManager: null, + ankiIntegration: null as unknown, + }; + + const build = createBuildInitializeOverlayRuntimeMainDepsHandler({ + appState, + overlayManager: { + getVisibleOverlayVisible: () => true, + getInvisibleOverlayVisible: () => false, + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => calls.push('update-visible'), + updateInvisibleOverlayVisibility: () => calls.push('update-invisible'), + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => calls.push('sync-shortcuts'), + }, + getInitialInvisibleOverlayVisibility: () => true, + createMainWindow: () => calls.push('create-main'), + createInvisibleWindow: () => calls.push('create-invisible'), + registerGlobalShortcuts: () => calls.push('register-shortcuts'), + updateVisibleOverlayBounds: () => calls.push('visible-bounds'), + updateInvisibleOverlayBounds: () => calls.push('invisible-bounds'), + getOverlayWindows: () => [], + getResolvedConfig: () => ({}), + showDesktopNotification: () => calls.push('notify'), + createFieldGroupingCallback: () => async () => ({ cancelled: true }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + const deps = build(); + assert.equal(deps.getBackendOverride(), 'x11'); + assert.equal(deps.getInitialInvisibleOverlayVisibility(), true); + assert.equal(deps.isVisibleOverlayVisible(), true); + assert.equal(deps.isInvisibleOverlayVisible(), false); + assert.equal(deps.getMpvSocketPath(), '/tmp/mpv.sock'); + assert.equal(deps.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json'); + + deps.createMainWindow(); + deps.createInvisibleWindow(); + deps.registerGlobalShortcuts(); + deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); + deps.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); + deps.updateVisibleOverlayVisibility(); + deps.updateInvisibleOverlayVisibility(); + deps.syncOverlayShortcuts(); + deps.showDesktopNotification('title', {}); + + deps.setWindowTracker({ id: 'tracker' }); + deps.setAnkiIntegration({ id: 'anki' }); + + assert.deepEqual(calls, [ + 'create-main', + 'create-invisible', + 'register-shortcuts', + 'visible-bounds', + 'invisible-bounds', + 'update-visible', + 'update-invisible', + 'sync-shortcuts', + 'notify', + ]); + assert.deepEqual(appState.windowTracker, { id: 'tracker' }); + assert.deepEqual(appState.ankiIntegration, { id: 'anki' }); +}); diff --git a/src/main/runtime/overlay-runtime-options-main-deps.ts b/src/main/runtime/overlay-runtime-options-main-deps.ts new file mode 100644 index 0000000..5b1595a --- /dev/null +++ b/src/main/runtime/overlay-runtime-options-main-deps.ts @@ -0,0 +1,81 @@ +import type { AnkiConnectConfig } from '../../types'; + +export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { + appState: { + backendOverride: string | null; + windowTracker: unknown | null; + subtitleTimingTracker: unknown | null; + mpvClient: unknown | null; + mpvSocketPath: string; + runtimeOptionsManager: unknown | null; + ankiIntegration: unknown | null; + }; + overlayManager: { + getVisibleOverlayVisible: () => boolean; + getInvisibleOverlayVisible: () => boolean; + }; + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + }; + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => void; + }; + getInitialInvisibleOverlayVisibility: () => boolean; + createMainWindow: () => void; + createInvisibleWindow: () => void; + registerGlobalShortcuts: () => void; + updateVisibleOverlayBounds: (geometry: { x: number; y: number; width: number; height: number }) => void; + updateInvisibleOverlayBounds: (geometry: { + x: number; + y: number; + width: number; + height: number; + }) => void; + getOverlayWindows: () => unknown[]; + getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; + showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + createFieldGroupingCallback: () => unknown; + getKnownWordCacheStatePath: () => string; +}) { + return () => ({ + getBackendOverride: () => deps.appState.backendOverride, + getInitialInvisibleOverlayVisibility: () => deps.getInitialInvisibleOverlayVisibility(), + createMainWindow: () => deps.createMainWindow(), + createInvisibleWindow: () => deps.createInvisibleWindow(), + registerGlobalShortcuts: () => deps.registerGlobalShortcuts(), + updateVisibleOverlayBounds: (geometry: { x: number; y: number; width: number; height: number }) => + deps.updateVisibleOverlayBounds(geometry), + updateInvisibleOverlayBounds: (geometry: { + x: number; + y: number; + width: number; + height: number; + }) => deps.updateInvisibleOverlayBounds(geometry), + isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(), + isInvisibleOverlayVisible: () => deps.overlayManager.getInvisibleOverlayVisible(), + updateVisibleOverlayVisibility: () => deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => + deps.overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), + getOverlayWindows: () => deps.getOverlayWindows() as never, + syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(), + setWindowTracker: (tracker: unknown | null) => { + deps.appState.windowTracker = tracker; + }, + getResolvedConfig: () => deps.getResolvedConfig(), + getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker, + getMpvClient: () => + (deps.appState.mpvClient as { send?: (payload: { command: string[] }) => void } | null), + getMpvSocketPath: () => deps.appState.mpvSocketPath, + getRuntimeOptionsManager: () => + deps.appState.runtimeOptionsManager as + | { getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig } + | null, + setAnkiIntegration: (integration: unknown | null) => { + deps.appState.ankiIntegration = integration; + }, + showDesktopNotification: deps.showDesktopNotification, + createFieldGroupingCallback: () => deps.createFieldGroupingCallback() as never, + getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), + }); +} diff --git a/src/main/runtime/overlay-visibility-actions.test.ts b/src/main/runtime/overlay-visibility-actions.test.ts new file mode 100644 index 0000000..afaae12 --- /dev/null +++ b/src/main/runtime/overlay-visibility-actions.test.ts @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createSetInvisibleOverlayVisibleHandler, + createSetVisibleOverlayVisibleHandler, + createToggleInvisibleOverlayHandler, + createToggleVisibleOverlayHandler, +} from './overlay-visibility-actions'; + +test('set visible overlay handler forwards dependencies to core', () => { + const calls: string[] = []; + const setVisible = createSetVisibleOverlayVisibleHandler({ + setVisibleOverlayVisibleCore: (options) => { + calls.push(`core:${options.visible}`); + options.setVisibleOverlayVisibleState(options.visible); + options.updateVisibleOverlayVisibility(); + options.updateInvisibleOverlayVisibility(); + options.syncInvisibleOverlayMousePassthrough(); + options.setMpvSubVisibility(!options.visible); + }, + setVisibleOverlayVisibleState: (visible) => calls.push(`state:${visible}`), + updateVisibleOverlayVisibility: () => calls.push('update-visible'), + updateInvisibleOverlayVisibility: () => calls.push('update-invisible'), + syncInvisibleOverlayMousePassthrough: () => calls.push('sync-mouse'), + shouldBindVisibleOverlayToMpvSubVisibility: () => true, + isMpvConnected: () => true, + setMpvSubVisibility: (visible) => calls.push(`mpv-sub:${visible}`), + }); + + setVisible(true); + assert.deepEqual(calls, [ + 'core:true', + 'state:true', + 'update-visible', + 'update-invisible', + 'sync-mouse', + 'mpv-sub:false', + ]); +}); + +test('set invisible overlay handler forwards dependencies to core', () => { + const calls: string[] = []; + const setInvisible = createSetInvisibleOverlayVisibleHandler({ + setInvisibleOverlayVisibleCore: (options) => { + calls.push(`core:${options.visible}`); + options.setInvisibleOverlayVisibleState(options.visible); + options.updateInvisibleOverlayVisibility(); + options.syncInvisibleOverlayMousePassthrough(); + }, + setInvisibleOverlayVisibleState: (visible) => calls.push(`state:${visible}`), + updateInvisibleOverlayVisibility: () => calls.push('update-invisible'), + syncInvisibleOverlayMousePassthrough: () => calls.push('sync-mouse'), + }); + + setInvisible(false); + assert.deepEqual(calls, ['core:false', 'state:false', 'update-invisible', 'sync-mouse']); +}); + +test('toggle visible overlay flips current visible state', () => { + const calls: string[] = []; + let current = false; + const toggle = createToggleVisibleOverlayHandler({ + getVisibleOverlayVisible: () => current, + setVisibleOverlayVisible: (visible) => { + current = visible; + calls.push(`set:${visible}`); + }, + }); + + toggle(); + toggle(); + assert.deepEqual(calls, ['set:true', 'set:false']); +}); + +test('toggle invisible overlay flips current invisible state', () => { + const calls: string[] = []; + let current = true; + const toggle = createToggleInvisibleOverlayHandler({ + getInvisibleOverlayVisible: () => current, + setInvisibleOverlayVisible: (visible) => { + current = visible; + calls.push(`set:${visible}`); + }, + }); + + toggle(); + toggle(); + assert.deepEqual(calls, ['set:false', 'set:true']); +}); diff --git a/src/main/runtime/overlay-visibility-actions.ts b/src/main/runtime/overlay-visibility-actions.ts new file mode 100644 index 0000000..a43cd15 --- /dev/null +++ b/src/main/runtime/overlay-visibility-actions.ts @@ -0,0 +1,72 @@ +export function createSetVisibleOverlayVisibleHandler(deps: { + setVisibleOverlayVisibleCore: (options: { + visible: boolean; + setVisibleOverlayVisibleState: (visible: boolean) => void; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isMpvConnected: () => boolean; + setMpvSubVisibility: (visible: boolean) => void; + }) => void; + setVisibleOverlayVisibleState: (visible: boolean) => void; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; + shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; + isMpvConnected: () => boolean; + setMpvSubVisibility: (visible: boolean) => void; +}) { + return (visible: boolean): void => { + deps.setVisibleOverlayVisibleCore({ + visible, + setVisibleOverlayVisibleState: deps.setVisibleOverlayVisibleState, + updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility, + updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility, + syncInvisibleOverlayMousePassthrough: deps.syncInvisibleOverlayMousePassthrough, + shouldBindVisibleOverlayToMpvSubVisibility: + deps.shouldBindVisibleOverlayToMpvSubVisibility, + isMpvConnected: deps.isMpvConnected, + setMpvSubVisibility: deps.setMpvSubVisibility, + }); + }; +} + +export function createSetInvisibleOverlayVisibleHandler(deps: { + setInvisibleOverlayVisibleCore: (options: { + visible: boolean; + setInvisibleOverlayVisibleState: (visible: boolean) => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; + }) => void; + setInvisibleOverlayVisibleState: (visible: boolean) => void; + updateInvisibleOverlayVisibility: () => void; + syncInvisibleOverlayMousePassthrough: () => void; +}) { + return (visible: boolean): void => { + deps.setInvisibleOverlayVisibleCore({ + visible, + setInvisibleOverlayVisibleState: deps.setInvisibleOverlayVisibleState, + updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility, + syncInvisibleOverlayMousePassthrough: deps.syncInvisibleOverlayMousePassthrough, + }); + }; +} + +export function createToggleVisibleOverlayHandler(deps: { + getVisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; +}) { + return (): void => { + deps.setVisibleOverlayVisible(!deps.getVisibleOverlayVisible()); + }; +} + +export function createToggleInvisibleOverlayHandler(deps: { + getInvisibleOverlayVisible: () => boolean; + setInvisibleOverlayVisible: (visible: boolean) => void; +}) { + return (): void => { + deps.setInvisibleOverlayVisible(!deps.getInvisibleOverlayVisible()); + }; +} diff --git a/src/main/runtime/overlay-window-factory-main-deps.test.ts b/src/main/runtime/overlay-window-factory-main-deps.test.ts new file mode 100644 index 0000000..20e88be --- /dev/null +++ b/src/main/runtime/overlay-window-factory-main-deps.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildCreateInvisibleWindowMainDepsHandler, + createBuildCreateMainWindowMainDepsHandler, + createBuildCreateOverlayWindowMainDepsHandler, +} from './overlay-window-factory-main-deps'; + +test('overlay window factory main deps builders return mapped handlers', () => { + const calls: string[] = []; + const buildOverlayDeps = createBuildCreateOverlayWindowMainDepsHandler({ + createOverlayWindowCore: (kind) => ({ kind }), + isDev: true, + getOverlayDebugVisualizationEnabled: () => false, + ensureOverlayWindowLevel: () => calls.push('ensure-level'), + onRuntimeOptionsChanged: () => calls.push('runtime-options-changed'), + setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`), + isOverlayVisible: (kind) => kind === 'visible', + tryHandleOverlayShortcutLocalFallback: () => false, + onWindowClosed: (kind) => calls.push(`closed:${kind}`), + }); + + const overlayDeps = buildOverlayDeps(); + assert.equal(overlayDeps.isDev, true); + assert.equal(overlayDeps.getOverlayDebugVisualizationEnabled(), false); + assert.equal(overlayDeps.isOverlayVisible('visible'), true); + + const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({ + createOverlayWindow: () => ({ id: 'visible' }), + setMainWindow: () => calls.push('set-main'), + }); + const mainDeps = buildMainDeps(); + mainDeps.setMainWindow(null); + + const buildInvisibleDeps = createBuildCreateInvisibleWindowMainDepsHandler({ + createOverlayWindow: () => ({ id: 'invisible' }), + setInvisibleWindow: () => calls.push('set-invisible'), + }); + const invisibleDeps = buildInvisibleDeps(); + invisibleDeps.setInvisibleWindow(null); + + assert.deepEqual(calls, ['set-main', 'set-invisible']); +}); diff --git a/src/main/runtime/overlay-window-factory-main-deps.ts b/src/main/runtime/overlay-window-factory-main-deps.ts new file mode 100644 index 0000000..a00ed44 --- /dev/null +++ b/src/main/runtime/overlay-window-factory-main-deps.ts @@ -0,0 +1,55 @@ +export function createBuildCreateOverlayWindowMainDepsHandler(deps: { + createOverlayWindowCore: ( + kind: 'visible' | 'invisible', + options: { + isDev: boolean; + overlayDebugVisualizationEnabled: boolean; + ensureOverlayWindowLevel: (window: TWindow) => void; + onRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + isOverlayVisible: (windowKind: 'visible' | 'invisible') => boolean; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + onWindowClosed: (windowKind: 'visible' | 'invisible') => void; + }, + ) => TWindow; + isDev: boolean; + getOverlayDebugVisualizationEnabled: () => boolean; + ensureOverlayWindowLevel: (window: TWindow) => void; + onRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + isOverlayVisible: (windowKind: 'visible' | 'invisible') => boolean; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + onWindowClosed: (windowKind: 'visible' | 'invisible') => void; +}) { + return () => ({ + createOverlayWindowCore: deps.createOverlayWindowCore, + isDev: deps.isDev, + getOverlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled, + ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel, + onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged, + setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled, + isOverlayVisible: deps.isOverlayVisible, + tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, + onWindowClosed: deps.onWindowClosed, + }); +} + +export function createBuildCreateMainWindowMainDepsHandler(deps: { + createOverlayWindow: (kind: 'visible' | 'invisible') => TWindow; + setMainWindow: (window: TWindow | null) => void; +}) { + return () => ({ + createOverlayWindow: deps.createOverlayWindow, + setMainWindow: deps.setMainWindow, + }); +} + +export function createBuildCreateInvisibleWindowMainDepsHandler(deps: { + createOverlayWindow: (kind: 'visible' | 'invisible') => TWindow; + setInvisibleWindow: (window: TWindow | null) => void; +}) { + return () => ({ + createOverlayWindow: deps.createOverlayWindow, + setInvisibleWindow: deps.setInvisibleWindow, + }); +} diff --git a/src/main/runtime/startup-bootstrap-deps-builder.test.ts b/src/main/runtime/startup-bootstrap-deps-builder.test.ts new file mode 100644 index 0000000..d21b49e --- /dev/null +++ b/src/main/runtime/startup-bootstrap-deps-builder.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createBuildStartupBootstrapRuntimeFactoryDepsHandler } from './startup-bootstrap-deps-builder'; + +test('startup bootstrap deps builder returns mapped runtime factory deps', () => { + const calls: string[] = []; + const factory = createBuildStartupBootstrapRuntimeFactoryDepsHandler({ + argv: ['node', 'main.js'], + parseArgs: () => ({}) as never, + setLogLevel: (level) => calls.push(`log:${level}`), + forceX11Backend: () => calls.push('force-x11'), + enforceUnsupportedWaylandMode: () => calls.push('wayland-guard'), + shouldStartApp: () => true, + getDefaultSocketPath: () => '/tmp/mpv.sock', + defaultTexthookerPort: 5174, + configDir: '/tmp/config', + defaultConfig: {} as never, + generateConfigTemplate: () => 'template', + generateDefaultConfigFile: async () => 0, + onConfigGenerated: (exitCode) => calls.push(`generated:${exitCode}`), + onGenerateConfigError: (error) => calls.push(`error:${error.message}`), + startAppLifecycle: () => calls.push('start-lifecycle'), + }); + + const deps = factory(); + assert.deepEqual(deps.argv, ['node', 'main.js']); + assert.equal(deps.getDefaultSocketPath(), '/tmp/mpv.sock'); + assert.equal(deps.defaultTexthookerPort, 5174); + deps.setLogLevel('debug', 'config'); + deps.forceX11Backend({} as never); + deps.enforceUnsupportedWaylandMode({} as never); + deps.onConfigGenerated(0); + deps.onGenerateConfigError(new Error('oops')); + deps.startAppLifecycle({} as never); + + assert.deepEqual(calls, [ + 'log:debug', + 'force-x11', + 'wayland-guard', + 'generated:0', + 'error:oops', + 'start-lifecycle', + ]); +}); diff --git a/src/main/runtime/startup-bootstrap-deps-builder.ts b/src/main/runtime/startup-bootstrap-deps-builder.ts new file mode 100644 index 0000000..5cd0364 --- /dev/null +++ b/src/main/runtime/startup-bootstrap-deps-builder.ts @@ -0,0 +1,32 @@ +import type { CliArgs } from '../../cli/args'; +import type { ResolvedConfig } from '../../types'; +import type { LogLevelSource } from '../../logger'; +import type { StartupBootstrapRuntimeFactoryDeps } from '../startup'; + +export function createBuildStartupBootstrapRuntimeFactoryDepsHandler( + deps: StartupBootstrapRuntimeFactoryDeps, +) { + return (): StartupBootstrapRuntimeFactoryDeps => ({ + argv: deps.argv, + parseArgs: deps.parseArgs, + setLogLevel: deps.setLogLevel, + forceX11Backend: deps.forceX11Backend, + enforceUnsupportedWaylandMode: deps.enforceUnsupportedWaylandMode, + shouldStartApp: deps.shouldStartApp, + getDefaultSocketPath: deps.getDefaultSocketPath, + defaultTexthookerPort: deps.defaultTexthookerPort, + configDir: deps.configDir, + defaultConfig: deps.defaultConfig, + generateConfigTemplate: deps.generateConfigTemplate, + generateDefaultConfigFile: deps.generateDefaultConfigFile, + onConfigGenerated: deps.onConfigGenerated, + onGenerateConfigError: deps.onGenerateConfigError, + startAppLifecycle: deps.startAppLifecycle, + }); +} + +export type { + CliArgs as StartupBuilderCliArgs, + ResolvedConfig as StartupBuilderResolvedConfig, + LogLevelSource as StartupBuilderLogLevelSource, +}; diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts new file mode 100644 index 0000000..ec697a5 --- /dev/null +++ b/src/main/runtime/tray-main-deps.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildResolveTrayIconPathMainDepsHandler, + createBuildTrayMenuTemplateMainDepsHandler, +} from './tray-main-deps'; + +test('tray main deps builders return mapped handlers', () => { + const calls: string[] = []; + const resolveDeps = createBuildResolveTrayIconPathMainDepsHandler({ + resolveTrayIconPathRuntime: () => '/tmp/icon.png', + platform: 'darwin', + resourcesPath: '/resources', + appPath: '/app', + dirname: '/dir', + joinPath: (...parts) => parts.join('/'), + fileExists: () => true, + })(); + + assert.equal(resolveDeps.platform, 'darwin'); + assert.equal(resolveDeps.joinPath('a', 'b'), 'a/b'); + + const menuDeps = createBuildTrayMenuTemplateMainDepsHandler({ + buildTrayMenuTemplateRuntime: () => [{ label: 'tray' }] as never, + initializeOverlayRuntime: () => calls.push('init'), + isOverlayRuntimeInitialized: () => false, + setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`), + openYomitanSettings: () => calls.push('yomitan'), + openRuntimeOptionsPalette: () => calls.push('runtime-options'), + openJellyfinSetupWindow: () => calls.push('jellyfin'), + openAnilistSetupWindow: () => calls.push('anilist'), + quitApp: () => calls.push('quit'), + })(); + + const template = menuDeps.buildTrayMenuTemplateRuntime({ + openOverlay: () => calls.push('open-overlay'), + openYomitanSettings: () => calls.push('open-yomitan'), + openRuntimeOptions: () => calls.push('open-runtime-options'), + openJellyfinSetup: () => calls.push('open-jellyfin'), + openAnilistSetup: () => calls.push('open-anilist'), + quitApp: () => calls.push('quit-app'), + }); + + assert.deepEqual(template, [{ label: 'tray' }]); +}); diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts new file mode 100644 index 0000000..4172768 --- /dev/null +++ b/src/main/runtime/tray-main-deps.ts @@ -0,0 +1,57 @@ +export function createBuildResolveTrayIconPathMainDepsHandler(deps: { + resolveTrayIconPathRuntime: (options: { + platform: string; + resourcesPath: string; + appPath: string; + dirname: string; + joinPath: (...parts: string[]) => string; + fileExists: (path: string) => boolean; + }) => string | null; + platform: string; + resourcesPath: string; + appPath: string; + dirname: string; + joinPath: (...parts: string[]) => string; + fileExists: (path: string) => boolean; +}) { + return () => ({ + resolveTrayIconPathRuntime: deps.resolveTrayIconPathRuntime, + platform: deps.platform, + resourcesPath: deps.resourcesPath, + appPath: deps.appPath, + dirname: deps.dirname, + joinPath: deps.joinPath, + fileExists: deps.fileExists, + }); +} + +export function createBuildTrayMenuTemplateMainDepsHandler(deps: { + buildTrayMenuTemplateRuntime: (handlers: { + openOverlay: () => void; + openYomitanSettings: () => void; + openRuntimeOptions: () => void; + openJellyfinSetup: () => void; + openAnilistSetup: () => void; + quitApp: () => void; + }) => TMenuItem[]; + initializeOverlayRuntime: () => void; + isOverlayRuntimeInitialized: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + openYomitanSettings: () => void; + openRuntimeOptionsPalette: () => void; + openJellyfinSetupWindow: () => void; + openAnilistSetupWindow: () => void; + quitApp: () => void; +}) { + return () => ({ + buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime, + initializeOverlayRuntime: deps.initializeOverlayRuntime, + isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized, + setVisibleOverlayVisible: deps.setVisibleOverlayVisible, + openYomitanSettings: deps.openYomitanSettings, + openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, + openJellyfinSetupWindow: deps.openJellyfinSetupWindow, + openAnilistSetupWindow: deps.openAnilistSetupWindow, + quitApp: deps.quitApp, + }); +}