diff --git a/Makefile b/Makefile index 381e2aa..65908f9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help deps build install build-linux build-macos build-macos-unsigned install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-pnpm generate-config generate-example-config docs-dev docs-build docs-preview +.PHONY: help deps build install build-linux build-macos build-macos-unsigned install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-pnpm generate-config generate-example-config docs-dev docs docs-preview APP_NAME := subminer THEME_FILE := subminer.rasi @@ -49,7 +49,7 @@ help: " build-macos Build macOS DMG/ZIP (signed if configured)" \ " build-macos-unsigned Build macOS DMG/ZIP without signing/notarization" \ " docs-dev Run VitePress docs dev server" \ - " docs-build Build VitePress static docs" \ + " docs Build VitePress static docs" \ " docs-preview Preview built VitePress docs" \ " install-linux Install Linux wrapper/theme/app artifacts" \ " install-macos Install macOS wrapper/theme/app artifacts" \ @@ -137,7 +137,7 @@ generate-example-config: ensure-pnpm docs-dev: ensure-pnpm @pnpm run docs:dev -docs-build: ensure-pnpm +docs: ensure-pnpm @pnpm run docs:build docs-preview: ensure-pnpm diff --git a/README.md b/README.md index 2520e3c..61796bb 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ For macOS app bundle / signing / permissions details, use `docs/installation.md` ## Quick Start -1. Copy and customize [`config.example.jsonc`](config.example.jsonc) to `~/.config/SubMiner/config.jsonc`. +1. Copy and customize [`config.example.jsonc`](config.example.jsonc) to `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` if `XDG_CONFIG_HOME` is unset). 2. Start mpv with IPC enabled: ```bash @@ -100,6 +100,7 @@ Detailed guides live in [`docs/`](docs/README.md): - [Usage](docs/usage.md) - [Configuration](docs/configuration.md) - [Development](docs/development.md) +- [Architecture](docs/architecture.md) (includes `OverlayManager` state ownership and deps wiring rules) ### Third-Party Components diff --git a/config.example.jsonc b/config.example.jsonc index 64c607b..2e7ebc2 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -2,7 +2,7 @@ * SubMiner Example Configuration File * * This file is auto-generated from src/config/definitions.ts. - * Copy to ~/.config/SubMiner/config.jsonc and edit as needed. + * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. */ { @@ -115,7 +115,8 @@ "multiCopyTimeoutMs": 3000, "toggleSecondarySub": "CommandOrControl+Shift+V", "markAudioCard": "CommandOrControl+Shift+A", - "openRuntimeOptions": "CommandOrControl+Shift+O" + "openRuntimeOptions": "CommandOrControl+Shift+O", + "openJimaku": "Ctrl+Alt+J" }, // ========================================== diff --git a/docs/README.md b/docs/README.md index 57df32c..a6d58ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,10 +28,10 @@ pnpm run docs:build - Full config file reference and option details - [Development](/development) - Contributor notes - - Architecture migration overview + - Architecture and extension rules - Environment variables - License and acknowledgments - [Architecture](/architecture) - - Composability migration status - - Core runtime structure + - Service-oriented runtime structure + - Composition and lifecycle model - Extension design rules diff --git a/docs/architecture.md b/docs/architecture.md index 1145a8a..be179ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,51 +1,75 @@ # Architecture -SubMiner is migrating from a single, monolithic `src/main.ts` runtime toward a composable architecture with clear extension points. +SubMiner uses a service-oriented Electron main-process architecture where `src/main.ts` acts as the composition root and behavior lives in small runtime services under `src/core/services`. + +## Goals + +- Keep behavior stable while reducing coupling. +- Prefer small, single-purpose units that can be tested in isolation. +- Keep `main.ts` focused on wiring and state ownership, not implementation detail. +- Follow Unix-style composability: + - each service does one job + - services compose through explicit inputs/outputs + - orchestration is separate from implementation ## Current Structure -- `src/main.ts`: bootstrap/composition root plus remaining legacy orchestration. -- `src/core/`: shared runtime primitives: - - `app-orchestrator.ts`: lifecycle wiring for ready/activate/quit hooks. - - `action-bus.ts`: typed action dispatch path. - - `actions.ts`: canonical app action types. - - `module.ts` / `module-registry.ts`: module lifecycle contract. - - `services/`: extracted runtime services (IPC, shortcuts, subtitle, overlay, MPV command routing). -- `src/ipc/`: shared IPC contract + wrappers used by main, preload, and renderer. -- `src/subtitle/`: staged subtitle pipeline (`normalize` -> `tokenize` -> `merge`). -- `src/modules/`: feature modules: - - `runtime-options` - - `jimaku` - - `subsync` - - `anki` - - `texthooker` -- provider registries: - - `src/window-trackers/index.ts` - - `src/tokenizers/index.ts` - - `src/token-mergers/index.ts` - - `src/translators/index.ts` - - `src/subsync/engines.ts` +- `src/main.ts` + - Composition root for lifecycle wiring and non-overlay runtime state. + - Owns long-lived process state for trackers, runtime flags, and client instances. + - Delegates behavior to services. +- `src/core/services/overlay-manager-service.ts` + - Owns overlay/window state (`mainWindow`, `invisibleWindow`, visible/invisible overlay flags). + - Provides a narrow state API used by `main.ts` and overlay services. +- `src/core/services/*` + - Stateless or narrowly stateful units for a specific responsibility. + - Examples: startup bootstrap, app lifecycle hooks, CLI command handling, IPC registration, overlay visibility, MPV IPC behavior, shortcut registration, subtitle websocket, jimaku/subsync helpers. +- `src/core/utils/*` + - Pure helpers and coercion/config utilities. +- `src/cli/*` + - CLI parsing and help output. +- `src/config/*` + - Config schema/definitions, defaults, validation, and template generation. +- `src/window-trackers/*` + - Backend-specific tracker implementations plus selection index. +- `src/jimaku/*`, `src/subsync/*` + - Domain-specific integration helpers. -## Migration Status +## Composition Pattern -- Completed: - - Action bus wired for CLI, shortcuts, and IPC-triggered commands. - - Command/action mapping covered by focused core tests. - - Shared IPC channel contract adopted across main/preload/renderer. - - Runtime options extracted into module lifecycle. - - Provider registries replace hardcoded backend selection. - - Subtitle tokenization/merge/enrich flow moved to staged pipeline. - - Stage-level subtitle pipeline tests added for deterministic behavior. - - Jimaku, Subsync, Anki, and texthooker/websocket flows moduleized. -- In progress: - - Further shrink `src/main.ts` by moving orchestration into dedicated services/orchestrator files. - - Continue moduleizing remaining integrations with complex lifecycle coupling. +Most runtime code follows a dependency-injection pattern: -## Design Rules +1. Define a service interface in `src/core/services/*`. +2. Keep core logic in pure or side-effect-bounded functions. +3. Build runtime deps in `main.ts`; use `*-deps-runtime-service.ts` helpers only when they add real adaptation logic. +4. Call the service from lifecycle/command wiring points. -- New feature behavior should be added as: - - a module (`src/modules/*`) and/or - - a provider registration (`src/*/index.ts` registries), - instead of editing unrelated branches in `src/main.ts`. -- New command triggers should dispatch `AppAction` and reuse existing action handlers. -- New IPC channels should be added only via `src/ipc/contract.ts`. +This keeps side effects explicit and makes behavior easy to unit-test with fakes. + +## Lifecycle Model + +- Startup: + - `startup-bootstrap-runtime-service` handles initial argv/env/backend setup and decides generate-config flow vs app lifecycle start. + - `app-lifecycle-service` handles Electron single-instance + lifecycle event registration. + - `startup-lifecycle-hooks-runtime-service` wires app-ready and app-shutdown hooks. +- Runtime: + - CLI/shortcut/IPC events map to service calls. + - Overlay and MPV state sync through dedicated services. + - Runtime options and mining flows are coordinated via service boundaries. +- Shutdown: + - `app-shutdown-runtime-service` coordinates cleanup ordering (shortcuts, sockets, trackers, integrations). + +## Why This Design + +- Smaller blast radius: changing one feature usually touches one service. +- Better testability: most behavior can be tested without Electron windows/mpv. +- Better reviewability: PRs can be scoped to one subsystem. +- Backward compatibility: CLI flags and IPC channels can remain stable while internals evolve. + +## Extension Rules + +- Add behavior to an existing service or a new `src/core/services/*` file, not as ad-hoc logic in `main.ts`. +- Keep service APIs explicit and narrowly scoped. +- Prefer additive changes that preserve existing CLI flags and IPC channel behavior. +- Add/update unit tests for each service extraction or behavior change. +- For cross-cutting changes, extract-first then refactor internals after parity is verified. diff --git a/docs/configuration.md b/docs/configuration.md index f5da729..d2815b7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration -Settings are stored in `~/.config/SubMiner/config.jsonc` +Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset). For backward compatibility, SubMiner also reads existing configs from lowercase `subminer` directories. ### Configuration File diff --git a/docs/development.md b/docs/development.md index c40da38..69acbaa 100644 --- a/docs/development.md +++ b/docs/development.md @@ -4,7 +4,12 @@ To add or change a config option, update `src/config/definitions.ts` first. Defa ## Architecture -The composability migration state and extension-point guidelines are documented in [`architecture.md`](/architecture). +The current runtime design, composition model, and extension guidelines are documented in [`architecture.md`](/architecture). + +Contributor guidance: +- Overlay window/visibility state is owned by `src/core/services/overlay-manager-service.ts`. +- Prefer direct inline deps objects in `main.ts` for simple pass-through wiring. +- Add a `*-deps-runtime-service.ts` helper only when it performs meaningful adaptation (not identity mapping). ## Environment Variables diff --git a/docs/index.md b/docs/index.md index 114cd9a..f995aa9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,10 +34,10 @@ features: details: Build, test, and package SubMiner with the development notes in this docs set. --- -## Documentation Sections + diff --git a/docs/installation.md b/docs/installation.md index 3b84f23..5499ef4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -127,7 +127,7 @@ mpv --input-ipc-server=/tmp/subminer-socket video.mkv **Config Location:** -Settings are stored in `~/.config/SubMiner/config.jsonc` (same as Linux). +Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`, same as Linux). **MeCab Installation Paths:** diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index a1aebff..2e7ebc2 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -2,7 +2,7 @@ * SubMiner Example Configuration File * * This file is auto-generated from src/config/definitions.ts. - * Copy to ~/.config/SubMiner/config.jsonc and edit as needed. + * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. */ { diff --git a/docs/usage.md b/docs/usage.md index 8996fe3..cd63da5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -7,8 +7,6 @@ There are two ways to use SubMiner: | **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, starts the overlay automatically, and cleans up on exit. | | **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control visible and invisible overlay layers. Requires `--input-ipc-server=/tmp/subminer-socket`. | -Jimaku modal shortcut is an overlay shortcut, not an MPV plugin chord: default `Ctrl+Alt+J` via `shortcuts.openJimaku`. - You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow. `subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer video.mkv`. @@ -85,7 +83,7 @@ Notes: - Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset). - `subminer` prefers subtitle tracks from yt-dlp first, then falls back to local `whisper.cpp` (`whisper-cli`) when tracks are missing. - Whisper translation fallback currently only supports English secondary targets; non-English secondary targets rely on yt-dlp subtitle availability. -- Configure defaults in `~/.config/SubMiner/config.jsonc` under `youtubeSubgen` and `secondarySub`, or override mode/tool paths via CLI flags/environment variables. +- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtubeSubgen` and `secondarySub`, or override mode/tool paths via CLI flags/environment variables. ## Keybindings @@ -117,12 +115,6 @@ Notes: These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization. -### Overlay Chord Shortcuts - -| Chord | Action | -| --------- | ------------------------- | -| `y` → `j` | Open Jimaku subtitle menu | - ## How It Works 1. MPV runs with an IPC socket at `/tmp/subminer-socket` diff --git a/package.json b/package.json index 05a20d0..c740579 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,11 @@ "check:main-lines:gate3": "bash scripts/check-main-lines.sh 2500", "check:main-lines:gate4": "bash scripts/check-main-lines.sh 1800", "check:main-lines:gate5": "bash scripts/check-main-lines.sh 1500", - "docs:dev": "vitepress dev docs --host 0.0.0.0 --port 5173 --strictPort", - "docs:build": "vitepress build docs", - "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", + "docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev docs --host 0.0.0.0 --port 5173 --strictPort", + "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", + "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "test:config": "pnpm run build && node --test dist/config/config.test.js", - "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/cli-command-deps-runtime-service.test.js dist/core/services/ipc-deps-runtime-service.test.js dist/core/services/anki-jimaku-ipc-deps-runtime-service.test.js dist/core/services/field-grouping-overlay-runtime-service.test.js dist/core/services/subsync-deps-runtime-service.test.js dist/core/services/numeric-shortcut-runtime-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/overlay-visibility-facade-deps-runtime-service.test.js dist/core/services/mpv-command-ipc-deps-runtime-service.test.js dist/core/services/runtime-options-ipc-deps-runtime-service.test.js dist/core/services/tokenizer-deps-runtime-service.test.js dist/core/services/overlay-runtime-deps-service.test.js dist/core/services/startup-lifecycle-runtime-deps-service.test.js dist/core/services/startup-lifecycle-hooks-runtime-service.test.js dist/core/services/overlay-shortcut-runtime-deps-service.test.js dist/core/services/mining-runtime-deps-service.test.js dist/core/services/shortcut-ui-runtime-deps-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-runtime-service.test.js dist/core/services/runtime-options-runtime-service.test.js dist/core/services/overlay-modal-restore-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/overlay-bridge-runtime-service.test.js dist/core/services/overlay-visibility-facade-service.test.js dist/core/services/overlay-broadcast-runtime-service.test.js dist/core/services/app-ready-runtime-service.test.js dist/core/services/app-shutdown-runtime-service.test.js dist/core/services/mpv-client-deps-runtime-service.test.js dist/core/services/app-lifecycle-deps-runtime-service.test.js dist/core/services/runtime-options-manager-runtime-service.test.js dist/core/services/config-warning-runtime-service.test.js dist/core/services/app-logging-runtime-service.test.js dist/core/services/startup-resource-runtime-service.test.js dist/core/services/config-generation-runtime-service.test.js", + "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/cli-command-deps-runtime-service.test.js dist/core/services/ipc-deps-runtime-service.test.js dist/core/services/field-grouping-overlay-runtime-service.test.js dist/core/services/subsync-deps-runtime-service.test.js dist/core/services/numeric-shortcut-runtime-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/mpv-command-ipc-deps-runtime-service.test.js dist/core/services/runtime-options-ipc-deps-runtime-service.test.js dist/core/services/tokenizer-deps-runtime-service.test.js dist/core/services/overlay-deps-runtime-service.test.js dist/core/services/startup-lifecycle-hooks-runtime-service.test.js dist/core/services/shortcut-ui-deps-runtime-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-runtime-service.test.js dist/core/services/runtime-options-runtime-service.test.js dist/core/services/overlay-modal-restore-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/overlay-bridge-runtime-service.test.js dist/core/services/overlay-visibility-facade-service.test.js dist/core/services/overlay-broadcast-runtime-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/app-ready-runtime-service.test.js dist/core/services/app-shutdown-runtime-service.test.js dist/core/services/app-lifecycle-deps-runtime-service.test.js dist/core/services/runtime-options-manager-runtime-service.test.js dist/core/services/config-warning-runtime-service.test.js dist/core/services/app-logging-runtime-service.test.js dist/core/services/startup-resource-runtime-service.test.js dist/core/services/config-generation-runtime-service.test.js", "test:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js", "generate:config-example": "pnpm run build && node dist/generate-config-example.js", "start": "pnpm run build && electron . --start", diff --git a/src/config/template.ts b/src/config/template.ts index 7099f3a..5350945 100644 --- a/src/config/template.ts +++ b/src/config/template.ts @@ -55,7 +55,7 @@ export function generateConfigTemplate(config: ResolvedConfig = deepCloneConfig( lines.push(" * SubMiner Example Configuration File"); lines.push(" *"); lines.push(" * This file is auto-generated from src/config/definitions.ts."); - lines.push(" * Copy to ~/.config/SubMiner/config.jsonc and edit as needed."); + lines.push(" * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed."); lines.push(" */"); lines.push("{"); diff --git a/src/core/services/anki-jimaku-ipc-deps-runtime-service.test.ts b/src/core/services/anki-jimaku-ipc-deps-runtime-service.test.ts deleted file mode 100644 index c11dd9d..0000000 --- a/src/core/services/anki-jimaku-ipc-deps-runtime-service.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - AnkiJimakuIpcDepsRuntimeOptions, - createAnkiJimakuIpcDepsRuntimeService, -} from "./anki-jimaku-ipc-deps-runtime-service"; - -test("createAnkiJimakuIpcDepsRuntimeService returns passthrough runtime options", async () => { - const calls: string[] = []; - const options = { - patchAnkiConnectEnabled: () => calls.push("patch"), - getResolvedConfig: () => ({ ankiConnect: undefined }), - getRuntimeOptionsManager: () => null, - getSubtitleTimingTracker: () => null, - getMpvClient: () => null, - getAnkiIntegration: () => null, - setAnkiIntegration: () => calls.push("set-integration"), - showDesktopNotification: () => calls.push("notify"), - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 0, - deleteNoteId: 0, - deleteDuplicate: false, - cancelled: true, - }), - broadcastRuntimeOptionsChanged: () => calls.push("broadcast"), - getFieldGroupingResolver: () => null, - setFieldGroupingResolver: () => calls.push("set-resolver"), - parseMediaInfo: () => ({ mediaPath: null, baseName: null, episode: null }), - getCurrentMediaPath: () => "/tmp/a.mp4", - jimakuFetchJson: async () => ({ ok: true, data: [] }), - getJimakuMaxEntryResults: () => 100, - getJimakuLanguagePreference: () => "prefer-japanese", - resolveJimakuApiKey: async () => "abc", - isRemoteMediaPath: () => false, - downloadToFile: async () => ({ ok: true, path: "/tmp/a.srt" }), - } as unknown as AnkiJimakuIpcDepsRuntimeOptions; - - const runtime = createAnkiJimakuIpcDepsRuntimeService(options); - - runtime.patchAnkiConnectEnabled(true); - runtime.broadcastRuntimeOptionsChanged(); - runtime.setFieldGroupingResolver(null); - - assert.deepEqual(calls, ["patch", "broadcast", "set-resolver"]); - assert.equal(runtime.getCurrentMediaPath(), "/tmp/a.mp4"); - assert.equal(runtime.getJimakuMaxEntryResults(), 100); - assert.equal(await runtime.resolveJimakuApiKey(), "abc"); -}); diff --git a/src/core/services/anki-jimaku-ipc-deps-runtime-service.ts b/src/core/services/anki-jimaku-ipc-deps-runtime-service.ts deleted file mode 100644 index 4176c71..0000000 --- a/src/core/services/anki-jimaku-ipc-deps-runtime-service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - AnkiJimakuIpcRuntimeOptions, -} from "./anki-jimaku-runtime-service"; - -export type AnkiJimakuIpcDepsRuntimeOptions = AnkiJimakuIpcRuntimeOptions; - -export function createAnkiJimakuIpcDepsRuntimeService( - options: AnkiJimakuIpcDepsRuntimeOptions, -): AnkiJimakuIpcRuntimeOptions { - return { - patchAnkiConnectEnabled: options.patchAnkiConnectEnabled, - getResolvedConfig: options.getResolvedConfig, - getRuntimeOptionsManager: options.getRuntimeOptionsManager, - getSubtitleTimingTracker: options.getSubtitleTimingTracker, - getMpvClient: options.getMpvClient, - getAnkiIntegration: options.getAnkiIntegration, - setAnkiIntegration: options.setAnkiIntegration, - showDesktopNotification: options.showDesktopNotification, - createFieldGroupingCallback: options.createFieldGroupingCallback, - broadcastRuntimeOptionsChanged: options.broadcastRuntimeOptionsChanged, - getFieldGroupingResolver: options.getFieldGroupingResolver, - setFieldGroupingResolver: options.setFieldGroupingResolver, - parseMediaInfo: options.parseMediaInfo, - getCurrentMediaPath: options.getCurrentMediaPath, - jimakuFetchJson: options.jimakuFetchJson, - getJimakuMaxEntryResults: options.getJimakuMaxEntryResults, - getJimakuLanguagePreference: options.getJimakuLanguagePreference, - resolveJimakuApiKey: options.resolveJimakuApiKey, - isRemoteMediaPath: options.isRemoteMediaPath, - downloadToFile: options.downloadToFile, - }; -} diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 9dacd8b..71d2e85 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -73,15 +73,12 @@ export { getOverlayWindowsRuntimeService, setOverlayDebugVisualizationEnabledRuntimeService, } from "./overlay-broadcast-runtime-service"; -export { createMpvIpcClientDepsRuntimeService } from "./mpv-client-deps-runtime-service"; export { createAppLifecycleDepsRuntimeService } from "./app-lifecycle-deps-runtime-service"; export { createCliCommandDepsRuntimeService } from "./cli-command-deps-runtime-service"; export { createIpcDepsRuntimeService } from "./ipc-deps-runtime-service"; -export { createAnkiJimakuIpcDepsRuntimeService } from "./anki-jimaku-ipc-deps-runtime-service"; export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-runtime-service"; export { createSubsyncRuntimeDepsService } from "./subsync-deps-runtime-service"; export { createNumericShortcutRuntimeService } from "./numeric-shortcut-runtime-service"; -export { createOverlayVisibilityFacadeDepsRuntimeService } from "./overlay-visibility-facade-deps-runtime-service"; export { createMpvCommandIpcDepsRuntimeService } from "./mpv-command-ipc-deps-runtime-service"; export { createRuntimeOptionsIpcDepsRuntimeService } from "./runtime-options-ipc-deps-runtime-service"; export { createTokenizerDepsRuntimeService } from "./tokenizer-deps-runtime-service"; @@ -90,26 +87,8 @@ export { createInvisibleOverlayVisibilityDepsRuntimeService, createOverlayWindowRuntimeDepsService, createVisibleOverlayVisibilityDepsRuntimeService, -} from "./overlay-runtime-deps-service"; -export { - createOverlayShortcutLifecycleDepsRuntimeService, - createOverlayShortcutRuntimeDepsService, -} from "./overlay-shortcut-runtime-deps-service"; -export { - createCopyCurrentSubtitleDepsRuntimeService, - createHandleMineSentenceDigitDepsRuntimeService, - createHandleMultiCopyDigitDepsRuntimeService, - createMarkLastCardAsAudioCardDepsRuntimeService, - createMineSentenceCardDepsRuntimeService, - createTriggerFieldGroupingDepsRuntimeService, - createUpdateLastCardFromClipboardDepsRuntimeService, -} from "./mining-runtime-deps-service"; -export { - createGlobalShortcutRegistrationDepsRuntimeService, - createSecondarySubtitleCycleDepsRuntimeService, - createYomitanSettingsWindowDepsRuntimeService, - runOverlayShortcutLocalFallbackRuntimeService, -} from "./shortcut-ui-runtime-deps-service"; +} from "./overlay-deps-runtime-service"; +export { runOverlayShortcutLocalFallbackRuntimeService } from "./shortcut-ui-deps-runtime-service"; export { createStartupLifecycleHooksRuntimeService } from "./startup-lifecycle-hooks-runtime-service"; export { createRuntimeOptionsManagerRuntimeService } from "./runtime-options-manager-runtime-service"; export { createAppLoggingRuntimeService } from "./app-logging-runtime-service"; @@ -122,3 +101,4 @@ export { runStartupBootstrapRuntimeService } from "./startup-bootstrap-runtime-s export { runSubsyncManualFromIpcRuntimeService, triggerSubsyncFromConfigRuntimeService } from "./subsync-runtime-service"; export { updateInvisibleOverlayVisibilityService, updateVisibleOverlayVisibilityService } from "./overlay-visibility-service"; export { registerAnkiJimakuIpcRuntimeService } from "./anki-jimaku-runtime-service"; +export { createOverlayManagerService } from "./overlay-manager-service"; diff --git a/src/core/services/mining-runtime-deps-service.test.ts b/src/core/services/mining-runtime-deps-service.test.ts deleted file mode 100644 index 5b49637..0000000 --- a/src/core/services/mining-runtime-deps-service.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - createCopyCurrentSubtitleDepsRuntimeService, - createHandleMineSentenceDigitDepsRuntimeService, - createHandleMultiCopyDigitDepsRuntimeService, - createMarkLastCardAsAudioCardDepsRuntimeService, - createMineSentenceCardDepsRuntimeService, - createTriggerFieldGroupingDepsRuntimeService, - createUpdateLastCardFromClipboardDepsRuntimeService, -} from "./mining-runtime-deps-service"; - -test("mining runtime deps builders preserve references", () => { - const showMpvOsd = (_text: string) => {}; - const writeClipboardText = (_text: string) => {}; - const readClipboardText = () => "x"; - const logError = (_message: string, _err: unknown) => {}; - const subtitleTimingTracker = null; - const ankiIntegration = null; - const mpvClient = null; - - const multiCopy = createHandleMultiCopyDigitDepsRuntimeService({ - subtitleTimingTracker, - writeClipboardText, - showMpvOsd, - }); - const copyCurrent = createCopyCurrentSubtitleDepsRuntimeService({ - subtitleTimingTracker, - writeClipboardText, - showMpvOsd, - }); - const updateLast = createUpdateLastCardFromClipboardDepsRuntimeService({ - ankiIntegration, - readClipboardText, - showMpvOsd, - }); - const fieldGrouping = createTriggerFieldGroupingDepsRuntimeService({ - ankiIntegration, - showMpvOsd, - }); - const markAudio = createMarkLastCardAsAudioCardDepsRuntimeService({ - ankiIntegration, - showMpvOsd, - }); - const mineCard = createMineSentenceCardDepsRuntimeService({ - ankiIntegration, - mpvClient, - showMpvOsd, - }); - const mineDigit = createHandleMineSentenceDigitDepsRuntimeService({ - subtitleTimingTracker, - ankiIntegration, - getCurrentSecondarySubText: () => undefined, - showMpvOsd, - logError, - }); - - assert.equal(multiCopy.writeClipboardText, writeClipboardText); - assert.equal(copyCurrent.showMpvOsd, showMpvOsd); - assert.equal(updateLast.readClipboardText, readClipboardText); - assert.equal(fieldGrouping.ankiIntegration, ankiIntegration); - assert.equal(markAudio.showMpvOsd, showMpvOsd); - assert.equal(mineCard.mpvClient, mpvClient); - assert.equal(mineDigit.logError, logError); -}); diff --git a/src/core/services/mining-runtime-deps-service.ts b/src/core/services/mining-runtime-deps-service.ts deleted file mode 100644 index d33c3ec..0000000 --- a/src/core/services/mining-runtime-deps-service.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - copyCurrentSubtitleService, - handleMineSentenceDigitService, - handleMultiCopyDigitService, - markLastCardAsAudioCardService, - mineSentenceCardService, - triggerFieldGroupingService, - updateLastCardFromClipboardService, -} from "./mining-runtime-service"; - -export function createHandleMultiCopyDigitDepsRuntimeService( - options: { - subtitleTimingTracker: Parameters[1]["subtitleTimingTracker"]; - writeClipboardText: Parameters[1]["writeClipboardText"]; - showMpvOsd: Parameters[1]["showMpvOsd"]; - }, -): Parameters[1] { - return { - subtitleTimingTracker: options.subtitleTimingTracker, - writeClipboardText: options.writeClipboardText, - showMpvOsd: options.showMpvOsd, - }; -} - -export function createCopyCurrentSubtitleDepsRuntimeService( - options: { - subtitleTimingTracker: Parameters[0]["subtitleTimingTracker"]; - writeClipboardText: Parameters[0]["writeClipboardText"]; - showMpvOsd: Parameters[0]["showMpvOsd"]; - }, -): Parameters[0] { - return { - subtitleTimingTracker: options.subtitleTimingTracker, - writeClipboardText: options.writeClipboardText, - showMpvOsd: options.showMpvOsd, - }; -} - -export function createUpdateLastCardFromClipboardDepsRuntimeService( - options: { - ankiIntegration: Parameters[0]["ankiIntegration"]; - readClipboardText: Parameters[0]["readClipboardText"]; - showMpvOsd: Parameters[0]["showMpvOsd"]; - }, -): Parameters[0] { - return { - ankiIntegration: options.ankiIntegration, - readClipboardText: options.readClipboardText, - showMpvOsd: options.showMpvOsd, - }; -} - -export function createTriggerFieldGroupingDepsRuntimeService( - options: { - ankiIntegration: Parameters[0]["ankiIntegration"]; - showMpvOsd: Parameters[0]["showMpvOsd"]; - }, -): Parameters[0] { - return { - ankiIntegration: options.ankiIntegration, - showMpvOsd: options.showMpvOsd, - }; -} - -export function createMarkLastCardAsAudioCardDepsRuntimeService( - options: { - ankiIntegration: Parameters[0]["ankiIntegration"]; - showMpvOsd: Parameters[0]["showMpvOsd"]; - }, -): Parameters[0] { - return { - ankiIntegration: options.ankiIntegration, - showMpvOsd: options.showMpvOsd, - }; -} - -export function createMineSentenceCardDepsRuntimeService( - options: { - ankiIntegration: Parameters[0]["ankiIntegration"]; - mpvClient: Parameters[0]["mpvClient"]; - showMpvOsd: Parameters[0]["showMpvOsd"]; - }, -): Parameters[0] { - return { - ankiIntegration: options.ankiIntegration, - mpvClient: options.mpvClient, - showMpvOsd: options.showMpvOsd, - }; -} - -export function createHandleMineSentenceDigitDepsRuntimeService( - options: { - subtitleTimingTracker: Parameters[1]["subtitleTimingTracker"]; - ankiIntegration: Parameters[1]["ankiIntegration"]; - getCurrentSecondarySubText: Parameters[1]["getCurrentSecondarySubText"]; - showMpvOsd: Parameters[1]["showMpvOsd"]; - logError: Parameters[1]["logError"]; - }, -): Parameters[1] { - return { - subtitleTimingTracker: options.subtitleTimingTracker, - ankiIntegration: options.ankiIntegration, - getCurrentSecondarySubText: options.getCurrentSecondarySubText, - showMpvOsd: options.showMpvOsd, - logError: options.logError, - }; -} diff --git a/src/core/services/mpv-client-deps-runtime-service.test.ts b/src/core/services/mpv-client-deps-runtime-service.test.ts deleted file mode 100644 index 7076f21..0000000 --- a/src/core/services/mpv-client-deps-runtime-service.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createMpvIpcClientDepsRuntimeService } from "./mpv-client-deps-runtime-service"; - -test("createMpvIpcClientDepsRuntimeService returns passthrough dep object", async () => { - const marker = { - getResolvedConfig: () => ({ auto_start_overlay: false } as never), - autoStartOverlay: true, - setOverlayVisible: () => {}, - shouldBindVisibleOverlayToMpvSubVisibility: () => true, - isVisibleOverlayVisible: () => false, - getReconnectTimer: () => null, - setReconnectTimer: () => {}, - getCurrentSubText: () => "x", - setCurrentSubText: () => {}, - setCurrentSubAssText: () => {}, - getSubtitleTimingTracker: () => null, - subtitleWsBroadcast: () => {}, - getOverlayWindowsCount: () => 0, - tokenizeSubtitle: async () => ({ text: "x", tokens: [], mergedTokens: [] }), - broadcastToOverlayWindows: () => {}, - updateCurrentMediaPath: () => {}, - updateMpvSubtitleRenderMetrics: () => {}, - getMpvSubtitleRenderMetrics: () => ({ - subPos: 100, - subFontSize: 40, - subScale: 1, - subMarginY: 0, - subMarginX: 0, - subFont: "sans", - subSpacing: 0, - subBold: false, - subItalic: false, - subBorderSize: 0, - subShadowOffset: 0, - subAssOverride: "yes", - subScaleByWindow: true, - subUseMargins: true, - osdHeight: 720, - osdDimensions: null, - }), - setPreviousSecondarySubVisibility: () => {}, - showMpvOsd: () => {}, - }; - - const deps = createMpvIpcClientDepsRuntimeService(marker); - assert.equal(deps.autoStartOverlay, true); - assert.equal(deps.getCurrentSubText(), "x"); - assert.equal(deps.getOverlayWindowsCount(), 0); - assert.equal(deps.shouldBindVisibleOverlayToMpvSubVisibility(), true); -}); diff --git a/src/core/services/mpv-client-deps-runtime-service.ts b/src/core/services/mpv-client-deps-runtime-service.ts deleted file mode 100644 index 1fd8841..0000000 --- a/src/core/services/mpv-client-deps-runtime-service.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - MpvIpcClientDeps, -} from "./mpv-service"; -import { Config, MpvSubtitleRenderMetrics, SubtitleData } from "../../types"; - -interface SubtitleTimingTrackerLike { - recordSubtitle: (text: string, start: number, end: number) => void; -} - -export interface MpvClientDepsRuntimeOptions { - getResolvedConfig: () => Config; - autoStartOverlay: boolean; - setOverlayVisible: (visible: boolean) => void; - shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; - isVisibleOverlayVisible: () => boolean; - getReconnectTimer: () => ReturnType | null; - setReconnectTimer: (timer: ReturnType | null) => void; - getCurrentSubText: () => string; - setCurrentSubText: (text: string) => void; - setCurrentSubAssText: (text: string) => void; - getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null; - subtitleWsBroadcast: (text: string) => void; - getOverlayWindowsCount: () => number; - tokenizeSubtitle: (text: string) => Promise; - broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; - updateCurrentMediaPath: (mediaPath: unknown) => void; - updateMpvSubtitleRenderMetrics: ( - patch: Partial, - ) => void; - getMpvSubtitleRenderMetrics: () => MpvSubtitleRenderMetrics; - setPreviousSecondarySubVisibility: (value: boolean | null) => void; - showMpvOsd: (text: string) => void; -} - -export function createMpvIpcClientDepsRuntimeService( - options: MpvClientDepsRuntimeOptions, -): MpvIpcClientDeps { - return { - getResolvedConfig: options.getResolvedConfig, - autoStartOverlay: options.autoStartOverlay, - setOverlayVisible: options.setOverlayVisible, - shouldBindVisibleOverlayToMpvSubVisibility: - options.shouldBindVisibleOverlayToMpvSubVisibility, - isVisibleOverlayVisible: options.isVisibleOverlayVisible, - getReconnectTimer: options.getReconnectTimer, - setReconnectTimer: options.setReconnectTimer, - getCurrentSubText: options.getCurrentSubText, - setCurrentSubText: options.setCurrentSubText, - setCurrentSubAssText: options.setCurrentSubAssText, - getSubtitleTimingTracker: options.getSubtitleTimingTracker, - subtitleWsBroadcast: options.subtitleWsBroadcast, - getOverlayWindowsCount: options.getOverlayWindowsCount, - tokenizeSubtitle: options.tokenizeSubtitle, - broadcastToOverlayWindows: options.broadcastToOverlayWindows, - updateCurrentMediaPath: options.updateCurrentMediaPath, - updateMpvSubtitleRenderMetrics: options.updateMpvSubtitleRenderMetrics, - getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics, - setPreviousSecondarySubVisibility: options.setPreviousSecondarySubVisibility, - showMpvOsd: options.showMpvOsd, - }; -} diff --git a/src/core/services/overlay-runtime-deps-service.test.ts b/src/core/services/overlay-deps-runtime-service.test.ts similarity index 98% rename from src/core/services/overlay-runtime-deps-service.test.ts rename to src/core/services/overlay-deps-runtime-service.test.ts index a28c24f..57672bb 100644 --- a/src/core/services/overlay-runtime-deps-service.test.ts +++ b/src/core/services/overlay-deps-runtime-service.test.ts @@ -5,7 +5,7 @@ import { createInvisibleOverlayVisibilityDepsRuntimeService, createOverlayWindowRuntimeDepsService, createVisibleOverlayVisibilityDepsRuntimeService, -} from "./overlay-runtime-deps-service"; +} from "./overlay-deps-runtime-service"; test("createOverlayWindowRuntimeDepsService maps runtime state providers", () => { let visible = true; diff --git a/src/core/services/overlay-runtime-deps-service.ts b/src/core/services/overlay-deps-runtime-service.ts similarity index 100% rename from src/core/services/overlay-runtime-deps-service.ts rename to src/core/services/overlay-deps-runtime-service.ts diff --git a/src/core/services/overlay-manager-service.test.ts b/src/core/services/overlay-manager-service.test.ts new file mode 100644 index 0000000..57565d4 --- /dev/null +++ b/src/core/services/overlay-manager-service.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createOverlayManagerService } from "./overlay-manager-service"; + +test("overlay manager initializes with empty windows and hidden overlays", () => { + const manager = createOverlayManagerService(); + assert.equal(manager.getMainWindow(), null); + assert.equal(manager.getInvisibleWindow(), null); + assert.equal(manager.getVisibleOverlayVisible(), false); + assert.equal(manager.getInvisibleOverlayVisible(), false); + assert.deepEqual(manager.getOverlayWindows(), []); +}); + +test("overlay manager stores window references and returns stable window order", () => { + const manager = createOverlayManagerService(); + const visibleWindow = { isDestroyed: () => false } as unknown as Electron.BrowserWindow; + const invisibleWindow = { isDestroyed: () => false } as unknown as Electron.BrowserWindow; + + manager.setMainWindow(visibleWindow); + manager.setInvisibleWindow(invisibleWindow); + + assert.equal(manager.getMainWindow(), visibleWindow); + assert.equal(manager.getInvisibleWindow(), invisibleWindow); + assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow]); +}); + +test("overlay manager excludes destroyed windows", () => { + const manager = createOverlayManagerService(); + manager.setMainWindow({ isDestroyed: () => true } as unknown as Electron.BrowserWindow); + manager.setInvisibleWindow({ isDestroyed: () => false } as unknown as Electron.BrowserWindow); + + assert.equal(manager.getOverlayWindows().length, 1); +}); + +test("overlay manager stores visibility state", () => { + const manager = createOverlayManagerService(); + + manager.setVisibleOverlayVisible(true); + manager.setInvisibleOverlayVisible(true); + assert.equal(manager.getVisibleOverlayVisible(), true); + assert.equal(manager.getInvisibleOverlayVisible(), true); +}); diff --git a/src/core/services/overlay-manager-service.ts b/src/core/services/overlay-manager-service.ts new file mode 100644 index 0000000..05344da --- /dev/null +++ b/src/core/services/overlay-manager-service.ts @@ -0,0 +1,49 @@ +import { BrowserWindow } from "electron"; + +export interface OverlayManagerService { + getMainWindow: () => BrowserWindow | null; + setMainWindow: (window: BrowserWindow | null) => void; + getInvisibleWindow: () => BrowserWindow | null; + setInvisibleWindow: (window: BrowserWindow | null) => void; + getVisibleOverlayVisible: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + getInvisibleOverlayVisible: () => boolean; + setInvisibleOverlayVisible: (visible: boolean) => void; + getOverlayWindows: () => BrowserWindow[]; +} + +export function createOverlayManagerService(): OverlayManagerService { + let mainWindow: BrowserWindow | null = null; + let invisibleWindow: BrowserWindow | null = null; + let visibleOverlayVisible = false; + let invisibleOverlayVisible = false; + + return { + getMainWindow: () => mainWindow, + setMainWindow: (window) => { + mainWindow = window; + }, + getInvisibleWindow: () => invisibleWindow, + setInvisibleWindow: (window) => { + invisibleWindow = window; + }, + getVisibleOverlayVisible: () => visibleOverlayVisible, + setVisibleOverlayVisible: (visible) => { + visibleOverlayVisible = visible; + }, + getInvisibleOverlayVisible: () => invisibleOverlayVisible, + setInvisibleOverlayVisible: (visible) => { + invisibleOverlayVisible = visible; + }, + getOverlayWindows: () => { + const windows: BrowserWindow[] = []; + if (mainWindow && !mainWindow.isDestroyed()) { + windows.push(mainWindow); + } + if (invisibleWindow && !invisibleWindow.isDestroyed()) { + windows.push(invisibleWindow); + } + return windows; + }, + }; +} diff --git a/src/core/services/overlay-shortcut-runtime-deps-service.test.ts b/src/core/services/overlay-shortcut-runtime-deps-service.test.ts deleted file mode 100644 index f3be2f1..0000000 --- a/src/core/services/overlay-shortcut-runtime-deps-service.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - createOverlayShortcutLifecycleDepsRuntimeService, - createOverlayShortcutRuntimeDepsService, -} from "./overlay-shortcut-runtime-deps-service"; - -test("createOverlayShortcutRuntimeDepsService returns callable runtime deps", async () => { - const calls: string[] = []; - const deps = createOverlayShortcutRuntimeDepsService({ - showMpvOsd: () => calls.push("showMpvOsd"), - openRuntimeOptions: () => calls.push("openRuntimeOptions"), - openJimaku: () => calls.push("openJimaku"), - markAudioCard: async () => { - calls.push("markAudioCard"); - }, - copySubtitleMultiple: () => calls.push("copySubtitleMultiple"), - copySubtitle: () => calls.push("copySubtitle"), - toggleSecondarySub: () => calls.push("toggleSecondarySub"), - updateLastCardFromClipboard: async () => { - calls.push("updateLastCardFromClipboard"); - }, - triggerFieldGrouping: async () => { - calls.push("triggerFieldGrouping"); - }, - triggerSubsync: async () => { - calls.push("triggerSubsync"); - }, - mineSentence: async () => { - calls.push("mineSentence"); - }, - mineSentenceMultiple: () => calls.push("mineSentenceMultiple"), - }); - - deps.copySubtitle(); - await deps.mineSentence(); - deps.mineSentenceMultiple(2); - - assert.deepEqual(calls, ["copySubtitle", "mineSentence", "mineSentenceMultiple"]); -}); - -test("createOverlayShortcutLifecycleDepsRuntimeService returns lifecycle passthrough", () => { - const deps = createOverlayShortcutLifecycleDepsRuntimeService({ - getConfiguredShortcuts: () => ({ actions: [] } as never), - getOverlayHandlers: () => ({} as never), - cancelPendingMultiCopy: () => {}, - cancelPendingMineSentenceMultiple: () => {}, - }); - - assert.ok(deps.getConfiguredShortcuts()); - assert.ok(deps.getOverlayHandlers()); -}); diff --git a/src/core/services/overlay-shortcut-runtime-deps-service.ts b/src/core/services/overlay-shortcut-runtime-deps-service.ts deleted file mode 100644 index 40699df..0000000 --- a/src/core/services/overlay-shortcut-runtime-deps-service.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - OverlayShortcutLifecycleDeps, -} from "./overlay-shortcut-lifecycle-service"; -import { - OverlayShortcutRuntimeDeps, -} from "./overlay-shortcut-runtime-service"; - -export interface OverlayShortcutRuntimeDepsOptions { - showMpvOsd: (text: string) => void; - openRuntimeOptions: () => void; - openJimaku: () => void; - markAudioCard: () => Promise; - copySubtitleMultiple: (timeoutMs: number) => void; - copySubtitle: () => void; - toggleSecondarySub: () => void; - updateLastCardFromClipboard: () => Promise; - triggerFieldGrouping: () => Promise; - triggerSubsync: () => Promise; - mineSentence: () => Promise; - mineSentenceMultiple: (timeoutMs: number) => void; -} - -export interface OverlayShortcutLifecycleDepsOptions { - getConfiguredShortcuts: OverlayShortcutLifecycleDeps["getConfiguredShortcuts"]; - getOverlayHandlers: OverlayShortcutLifecycleDeps["getOverlayHandlers"]; - cancelPendingMultiCopy: () => void; - cancelPendingMineSentenceMultiple: () => void; -} - -export function createOverlayShortcutRuntimeDepsService( - options: OverlayShortcutRuntimeDepsOptions, -): OverlayShortcutRuntimeDeps { - return { - showMpvOsd: options.showMpvOsd, - openRuntimeOptions: options.openRuntimeOptions, - openJimaku: options.openJimaku, - markAudioCard: options.markAudioCard, - copySubtitleMultiple: options.copySubtitleMultiple, - copySubtitle: options.copySubtitle, - toggleSecondarySub: options.toggleSecondarySub, - updateLastCardFromClipboard: options.updateLastCardFromClipboard, - triggerFieldGrouping: options.triggerFieldGrouping, - triggerSubsync: options.triggerSubsync, - mineSentence: options.mineSentence, - mineSentenceMultiple: options.mineSentenceMultiple, - }; -} - -export function createOverlayShortcutLifecycleDepsRuntimeService( - options: OverlayShortcutLifecycleDepsOptions, -): OverlayShortcutLifecycleDeps { - return { - getConfiguredShortcuts: options.getConfiguredShortcuts, - getOverlayHandlers: options.getOverlayHandlers, - cancelPendingMultiCopy: options.cancelPendingMultiCopy, - cancelPendingMineSentenceMultiple: - options.cancelPendingMineSentenceMultiple, - }; -} diff --git a/src/core/services/overlay-visibility-facade-deps-runtime-service.test.ts b/src/core/services/overlay-visibility-facade-deps-runtime-service.test.ts deleted file mode 100644 index 78a1d99..0000000 --- a/src/core/services/overlay-visibility-facade-deps-runtime-service.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { createOverlayVisibilityFacadeDepsRuntimeService } from "./overlay-visibility-facade-deps-runtime-service"; - -test("createOverlayVisibilityFacadeDepsRuntimeService returns working deps object", () => { - let visible = false; - let invisible = true; - let mpvSubVisible: boolean | null = null; - let syncCalls = 0; - - const deps = createOverlayVisibilityFacadeDepsRuntimeService({ - getVisibleOverlayVisible: () => visible, - getInvisibleOverlayVisible: () => invisible, - setVisibleOverlayVisibleState: (nextVisible) => { - visible = nextVisible; - }, - setInvisibleOverlayVisibleState: (nextVisible) => { - invisible = nextVisible; - }, - updateVisibleOverlayVisibility: () => {}, - updateInvisibleOverlayVisibility: () => {}, - syncInvisibleOverlayMousePassthrough: () => { - syncCalls += 1; - }, - shouldBindVisibleOverlayToMpvSubVisibility: () => true, - isMpvConnected: () => true, - setMpvSubVisibility: (nextVisible) => { - mpvSubVisible = nextVisible; - }, - }); - - assert.equal(deps.getVisibleOverlayVisible(), false); - assert.equal(deps.getInvisibleOverlayVisible(), true); - - deps.setVisibleOverlayVisibleState(true); - deps.setInvisibleOverlayVisibleState(false); - deps.syncInvisibleOverlayMousePassthrough(); - deps.setMpvSubVisibility(false); - - assert.equal(visible, true); - assert.equal(invisible, false); - assert.equal(syncCalls, 1); - assert.equal(mpvSubVisible, false); - assert.equal(deps.shouldBindVisibleOverlayToMpvSubVisibility(), true); - assert.equal(deps.isMpvConnected(), true); -}); diff --git a/src/core/services/overlay-visibility-facade-deps-runtime-service.ts b/src/core/services/overlay-visibility-facade-deps-runtime-service.ts deleted file mode 100644 index e138cfe..0000000 --- a/src/core/services/overlay-visibility-facade-deps-runtime-service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - OverlayVisibilityFacadeDeps, -} from "./overlay-visibility-facade-service"; - -export interface OverlayVisibilityFacadeDepsRuntimeOptions { - getVisibleOverlayVisible: () => boolean; - getInvisibleOverlayVisible: () => boolean; - setVisibleOverlayVisibleState: (nextVisible: boolean) => void; - setInvisibleOverlayVisibleState: (nextVisible: boolean) => void; - updateVisibleOverlayVisibility: () => void; - updateInvisibleOverlayVisibility: () => void; - syncInvisibleOverlayMousePassthrough: () => void; - shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; - isMpvConnected: () => boolean; - setMpvSubVisibility: (mpvSubVisible: boolean) => void; -} - -export function createOverlayVisibilityFacadeDepsRuntimeService( - options: OverlayVisibilityFacadeDepsRuntimeOptions, -): OverlayVisibilityFacadeDeps { - return { - getVisibleOverlayVisible: options.getVisibleOverlayVisible, - getInvisibleOverlayVisible: options.getInvisibleOverlayVisible, - setVisibleOverlayVisibleState: options.setVisibleOverlayVisibleState, - setInvisibleOverlayVisibleState: options.setInvisibleOverlayVisibleState, - updateVisibleOverlayVisibility: options.updateVisibleOverlayVisibility, - updateInvisibleOverlayVisibility: options.updateInvisibleOverlayVisibility, - syncInvisibleOverlayMousePassthrough: - options.syncInvisibleOverlayMousePassthrough, - shouldBindVisibleOverlayToMpvSubVisibility: - options.shouldBindVisibleOverlayToMpvSubVisibility, - isMpvConnected: options.isMpvConnected, - setMpvSubVisibility: options.setMpvSubVisibility, - }; -} diff --git a/src/core/services/shortcut-ui-runtime-deps-service.test.ts b/src/core/services/shortcut-ui-deps-runtime-service.test.ts similarity index 54% rename from src/core/services/shortcut-ui-runtime-deps-service.test.ts rename to src/core/services/shortcut-ui-deps-runtime-service.test.ts index 0f5e592..1e68496 100644 --- a/src/core/services/shortcut-ui-runtime-deps-service.test.ts +++ b/src/core/services/shortcut-ui-deps-runtime-service.test.ts @@ -1,35 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import { - createGlobalShortcutRegistrationDepsRuntimeService, - createSecondarySubtitleCycleDepsRuntimeService, - createYomitanSettingsWindowDepsRuntimeService, runOverlayShortcutLocalFallbackRuntimeService, -} from "./shortcut-ui-runtime-deps-service"; +} from "./shortcut-ui-deps-runtime-service"; function makeOptions() { return { - yomitanExt: null, - getYomitanSettingsWindow: () => null, - setYomitanSettingsWindow: () => {}, - - shortcuts: { - toggleVisibleOverlayGlobal: "Ctrl+Shift+O", - toggleInvisibleOverlayGlobal: "Ctrl+Alt+O", - }, - onToggleVisibleOverlay: () => {}, - onToggleInvisibleOverlay: () => {}, - onOpenYomitanSettings: () => {}, - isDev: false, - getMainWindow: () => null, - - getSecondarySubMode: () => "hover" as const, - setSecondarySubMode: () => {}, - getLastSecondarySubToggleAtMs: () => 0, - setLastSecondarySubToggleAtMs: () => {}, - broadcastSecondarySubMode: () => {}, - showMpvOsd: () => {}, - getConfiguredShortcuts: () => ({ toggleVisibleOverlayGlobal: null, toggleInvisibleOverlayGlobal: null, @@ -63,17 +39,6 @@ function makeOptions() { }; } -test("shortcut ui deps builders return expected adapters", () => { - const options = makeOptions(); - const yomitan = createYomitanSettingsWindowDepsRuntimeService(options); - const globalShortcuts = createGlobalShortcutRegistrationDepsRuntimeService(options); - const secondary = createSecondarySubtitleCycleDepsRuntimeService(options); - - assert.equal(yomitan.yomitanExt, null); - assert.equal(typeof globalShortcuts.onOpenYomitanSettings, "function"); - assert.equal(secondary.getSecondarySubMode(), "hover"); -}); - test("runOverlayShortcutLocalFallbackRuntimeService delegates and returns boolean", () => { const options = { ...makeOptions(), diff --git a/src/core/services/shortcut-ui-deps-runtime-service.ts b/src/core/services/shortcut-ui-deps-runtime-service.ts new file mode 100644 index 0000000..74d2742 --- /dev/null +++ b/src/core/services/shortcut-ui-deps-runtime-service.ts @@ -0,0 +1,24 @@ +import { ConfiguredShortcuts } from "../utils/shortcut-config"; +import { OverlayShortcutFallbackHandlers, runOverlayShortcutLocalFallback } from "./overlay-shortcut-fallback-runner"; + +export interface ShortcutUiRuntimeDepsOptions { + getConfiguredShortcuts: () => ConfiguredShortcuts; + getOverlayShortcutFallbackHandlers: () => OverlayShortcutFallbackHandlers; + shortcutMatcher: ( + input: Electron.Input, + accelerator: string, + allowWhenRegistered?: boolean, + ) => boolean; +} + +export function runOverlayShortcutLocalFallbackRuntimeService( + input: Electron.Input, + options: ShortcutUiRuntimeDepsOptions, +): boolean { + return runOverlayShortcutLocalFallback( + input, + options.getConfiguredShortcuts(), + options.shortcutMatcher, + options.getOverlayShortcutFallbackHandlers(), + ); +} diff --git a/src/core/services/shortcut-ui-runtime-deps-service.ts b/src/core/services/shortcut-ui-runtime-deps-service.ts deleted file mode 100644 index ae77c40..0000000 --- a/src/core/services/shortcut-ui-runtime-deps-service.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Extension } from "electron"; -import { SecondarySubMode } from "../../types"; -import { ConfiguredShortcuts } from "../utils/shortcut-config"; -import { CycleSecondarySubModeDeps } from "./secondary-subtitle-service"; -import { OverlayShortcutFallbackHandlers, runOverlayShortcutLocalFallback } from "./overlay-shortcut-fallback-runner"; -import { OpenYomitanSettingsWindowOptions } from "./yomitan-settings-service"; -import { RegisterGlobalShortcutsServiceOptions } from "./shortcut-service"; - -export interface ShortcutUiRuntimeDepsOptions { - yomitanExt: Extension | null; - getYomitanSettingsWindow: OpenYomitanSettingsWindowOptions["getExistingWindow"]; - setYomitanSettingsWindow: OpenYomitanSettingsWindowOptions["setWindow"]; - - shortcuts: RegisterGlobalShortcutsServiceOptions["shortcuts"]; - onToggleVisibleOverlay: () => void; - onToggleInvisibleOverlay: () => void; - onOpenYomitanSettings: () => void; - isDev: boolean; - getMainWindow: RegisterGlobalShortcutsServiceOptions["getMainWindow"]; - - getSecondarySubMode: () => SecondarySubMode; - setSecondarySubMode: (mode: SecondarySubMode) => void; - getLastSecondarySubToggleAtMs: () => number; - setLastSecondarySubToggleAtMs: (timestampMs: number) => void; - broadcastSecondarySubMode: (mode: SecondarySubMode) => void; - showMpvOsd: (text: string) => void; - - getConfiguredShortcuts: () => ConfiguredShortcuts; - getOverlayShortcutFallbackHandlers: () => OverlayShortcutFallbackHandlers; - shortcutMatcher: ( - input: Electron.Input, - accelerator: string, - allowWhenRegistered?: boolean, - ) => boolean; -} - -export function createYomitanSettingsWindowDepsRuntimeService( - options: ShortcutUiRuntimeDepsOptions, -): OpenYomitanSettingsWindowOptions { - return { - yomitanExt: options.yomitanExt, - getExistingWindow: options.getYomitanSettingsWindow, - setWindow: options.setYomitanSettingsWindow, - }; -} - -export function createGlobalShortcutRegistrationDepsRuntimeService( - options: ShortcutUiRuntimeDepsOptions, -): RegisterGlobalShortcutsServiceOptions { - return { - shortcuts: options.shortcuts, - onToggleVisibleOverlay: options.onToggleVisibleOverlay, - onToggleInvisibleOverlay: options.onToggleInvisibleOverlay, - onOpenYomitanSettings: options.onOpenYomitanSettings, - isDev: options.isDev, - getMainWindow: options.getMainWindow, - }; -} - -export function createSecondarySubtitleCycleDepsRuntimeService( - options: ShortcutUiRuntimeDepsOptions, -): CycleSecondarySubModeDeps { - return { - getSecondarySubMode: options.getSecondarySubMode, - setSecondarySubMode: options.setSecondarySubMode, - getLastSecondarySubToggleAtMs: options.getLastSecondarySubToggleAtMs, - setLastSecondarySubToggleAtMs: options.setLastSecondarySubToggleAtMs, - broadcastSecondarySubMode: options.broadcastSecondarySubMode, - showMpvOsd: options.showMpvOsd, - }; -} - -export function runOverlayShortcutLocalFallbackRuntimeService( - input: Electron.Input, - options: ShortcutUiRuntimeDepsOptions, -): boolean { - return runOverlayShortcutLocalFallback( - input, - options.getConfiguredShortcuts(), - options.shortcutMatcher, - options.getOverlayShortcutFallbackHandlers(), - ); -} diff --git a/src/core/services/startup-lifecycle-hooks-runtime-service.ts b/src/core/services/startup-lifecycle-hooks-runtime-service.ts index 75e4067..3a38b41 100644 --- a/src/core/services/startup-lifecycle-hooks-runtime-service.ts +++ b/src/core/services/startup-lifecycle-hooks-runtime-service.ts @@ -7,10 +7,6 @@ import { AppShutdownRuntimeDeps, runAppShutdownRuntimeService, } from "./app-shutdown-runtime-service"; -import { - createStartupAppReadyDepsRuntimeService, - createStartupAppShutdownDepsRuntimeService, -} from "./startup-lifecycle-runtime-deps-service"; type StartupLifecycleHookDeps = Pick< AppLifecycleDepsRuntimeOptions, @@ -29,14 +25,10 @@ export function createStartupLifecycleHooksRuntimeService( ): StartupLifecycleHookDeps { return { onReady: async () => { - await runAppReadyRuntimeService( - createStartupAppReadyDepsRuntimeService(options.appReadyDeps), - ); + await runAppReadyRuntimeService(options.appReadyDeps); }, onWillQuitCleanup: () => { - runAppShutdownRuntimeService( - createStartupAppShutdownDepsRuntimeService(options.appShutdownDeps), - ); + runAppShutdownRuntimeService(options.appShutdownDeps); }, shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate, restoreWindowsOnActivate: options.restoreWindowsOnActivate, diff --git a/src/core/services/startup-lifecycle-runtime-deps-service.test.ts b/src/core/services/startup-lifecycle-runtime-deps-service.test.ts deleted file mode 100644 index bd7ce7d..0000000 --- a/src/core/services/startup-lifecycle-runtime-deps-service.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - createStartupAppReadyDepsRuntimeService, - createStartupAppShutdownDepsRuntimeService, -} from "./startup-lifecycle-runtime-deps-service"; - -test("createStartupAppReadyDepsRuntimeService preserves runtime deps behavior", async () => { - const calls: string[] = []; - const deps = createStartupAppReadyDepsRuntimeService({ - loadSubtitlePosition: () => calls.push("loadSubtitlePosition"), - resolveKeybindings: () => calls.push("resolveKeybindings"), - createMpvClient: () => calls.push("createMpvClient"), - reloadConfig: () => calls.push("reloadConfig"), - getResolvedConfig: () => ({ - secondarySub: { defaultMode: "hover" }, - websocket: { enabled: "auto", port: 1234 }, - }), - getConfigWarnings: () => [], - logConfigWarning: () => {}, - initRuntimeOptionsManager: () => calls.push("initRuntimeOptionsManager"), - setSecondarySubMode: () => calls.push("setSecondarySubMode"), - defaultSecondarySubMode: "hover", - defaultWebsocketPort: 8765, - hasMpvWebsocketPlugin: () => true, - startSubtitleWebsocket: () => calls.push("startSubtitleWebsocket"), - log: () => calls.push("log"), - createMecabTokenizerAndCheck: async () => { - calls.push("createMecab"); - }, - createSubtitleTimingTracker: () => calls.push("createSubtitleTimingTracker"), - loadYomitanExtension: async () => { - calls.push("loadYomitan"); - }, - texthookerOnlyMode: false, - shouldAutoInitializeOverlayRuntimeFromConfig: () => false, - initializeOverlayRuntime: () => calls.push("initOverlayRuntime"), - handleInitialArgs: () => calls.push("handleInitialArgs"), - }); - - deps.loadSubtitlePosition(); - await deps.createMecabTokenizerAndCheck(); - deps.handleInitialArgs(); - - assert.equal(deps.defaultWebsocketPort, 8765); - assert.equal(deps.defaultSecondarySubMode, "hover"); - assert.deepEqual(calls, ["loadSubtitlePosition", "createMecab", "handleInitialArgs"]); -}); - -test("createStartupAppShutdownDepsRuntimeService preserves shutdown handlers", () => { - const calls: string[] = []; - const deps = createStartupAppShutdownDepsRuntimeService({ - unregisterAllGlobalShortcuts: () => calls.push("unregisterAllGlobalShortcuts"), - stopSubtitleWebsocket: () => calls.push("stopSubtitleWebsocket"), - stopTexthookerService: () => calls.push("stopTexthookerService"), - destroyYomitanParserWindow: () => calls.push("destroyYomitanParserWindow"), - clearYomitanParserPromises: () => calls.push("clearYomitanParserPromises"), - stopWindowTracker: () => calls.push("stopWindowTracker"), - destroyMpvSocket: () => calls.push("destroyMpvSocket"), - clearReconnectTimer: () => calls.push("clearReconnectTimer"), - destroySubtitleTimingTracker: () => calls.push("destroySubtitleTimingTracker"), - destroyAnkiIntegration: () => calls.push("destroyAnkiIntegration"), - }); - - deps.stopSubtitleWebsocket(); - deps.clearReconnectTimer(); - deps.destroyAnkiIntegration(); - - assert.deepEqual(calls, [ - "stopSubtitleWebsocket", - "clearReconnectTimer", - "destroyAnkiIntegration", - ]); -}); diff --git a/src/core/services/startup-lifecycle-runtime-deps-service.ts b/src/core/services/startup-lifecycle-runtime-deps-service.ts deleted file mode 100644 index 3da81c3..0000000 --- a/src/core/services/startup-lifecycle-runtime-deps-service.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - AppReadyRuntimeDeps, -} from "./app-ready-runtime-service"; -import { - AppShutdownRuntimeDeps, -} from "./app-shutdown-runtime-service"; - -export type StartupAppReadyDepsRuntimeOptions = AppReadyRuntimeDeps; -export type StartupAppShutdownDepsRuntimeOptions = AppShutdownRuntimeDeps; - -export function createStartupAppReadyDepsRuntimeService( - options: StartupAppReadyDepsRuntimeOptions, -): AppReadyRuntimeDeps { - return { - loadSubtitlePosition: options.loadSubtitlePosition, - resolveKeybindings: options.resolveKeybindings, - createMpvClient: options.createMpvClient, - reloadConfig: options.reloadConfig, - getResolvedConfig: options.getResolvedConfig, - getConfigWarnings: options.getConfigWarnings, - logConfigWarning: options.logConfigWarning, - initRuntimeOptionsManager: options.initRuntimeOptionsManager, - setSecondarySubMode: options.setSecondarySubMode, - defaultSecondarySubMode: options.defaultSecondarySubMode, - defaultWebsocketPort: options.defaultWebsocketPort, - hasMpvWebsocketPlugin: options.hasMpvWebsocketPlugin, - startSubtitleWebsocket: options.startSubtitleWebsocket, - log: options.log, - createMecabTokenizerAndCheck: options.createMecabTokenizerAndCheck, - createSubtitleTimingTracker: options.createSubtitleTimingTracker, - loadYomitanExtension: options.loadYomitanExtension, - texthookerOnlyMode: options.texthookerOnlyMode, - shouldAutoInitializeOverlayRuntimeFromConfig: - options.shouldAutoInitializeOverlayRuntimeFromConfig, - initializeOverlayRuntime: options.initializeOverlayRuntime, - handleInitialArgs: options.handleInitialArgs, - }; -} - -export function createStartupAppShutdownDepsRuntimeService( - options: StartupAppShutdownDepsRuntimeOptions, -): AppShutdownRuntimeDeps { - return { - unregisterAllGlobalShortcuts: options.unregisterAllGlobalShortcuts, - stopSubtitleWebsocket: options.stopSubtitleWebsocket, - stopTexthookerService: options.stopTexthookerService, - destroyYomitanParserWindow: options.destroyYomitanParserWindow, - clearYomitanParserPromises: options.clearYomitanParserPromises, - stopWindowTracker: options.stopWindowTracker, - destroyMpvSocket: options.destroyMpvSocket, - clearReconnectTimer: options.clearReconnectTimer, - destroySubtitleTimingTracker: options.destroySubtitleTimingTracker, - destroyAnkiIntegration: options.destroyAnkiIntegration, - }; -} diff --git a/src/main.ts b/src/main.ts index bfbe735..f47d932 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,7 +22,6 @@ import { clipboard, shell, protocol, - screen, Extension, } from "electron"; @@ -40,18 +39,12 @@ protocol.registerSchemesAsPrivileged([ ]); import * as path from "path"; -import * as http from "http"; -import * as https from "https"; import * as os from "os"; import * as fs from "fs"; -import * as crypto from "crypto"; import { MecabTokenizer } from "./mecab-tokenizer"; import { BaseWindowTracker } from "./window-trackers"; import type { JimakuApiResponse, - JimakuDownloadResult, - JimakuMediaInfo, - JimakuConfig, JimakuLanguagePreference, SubtitleData, SubtitlePosition, @@ -78,16 +71,12 @@ import { getSubsyncConfig, } from "./subsync/utils"; import { - hasExplicitCommand, parseArgs, shouldStartApp, } from "./cli/args"; import type { CliArgs, CliCommandSource } from "./cli/args"; import { printHelp } from "./cli/help"; import { - asBoolean, - asFiniteNumber, - asString, enforceUnsupportedWaylandMode, forceX11Backend, generateDefaultConfigFile, @@ -104,48 +93,33 @@ import { broadcastRuntimeOptionsChangedRuntimeService, broadcastToOverlayWindowsRuntimeService, copyCurrentSubtitleService, - createAnkiJimakuIpcDepsRuntimeService, createAppLifecycleDepsRuntimeService, createAppLoggingRuntimeService, createCliCommandDepsRuntimeService, - createCopyCurrentSubtitleDepsRuntimeService, + createOverlayManagerService, createFieldGroupingOverlayRuntimeService, - createGlobalShortcutRegistrationDepsRuntimeService, - createHandleMineSentenceDigitDepsRuntimeService, - createHandleMultiCopyDigitDepsRuntimeService, createInitializeOverlayRuntimeDepsService, createInvisibleOverlayVisibilityDepsRuntimeService, createIpcDepsRuntimeService, - createMarkLastCardAsAudioCardDepsRuntimeService, createMecabTokenizerAndCheckRuntimeService, - createMineSentenceCardDepsRuntimeService, createMpvCommandIpcDepsRuntimeService, - createMpvIpcClientDepsRuntimeService, createNumericShortcutRuntimeService, - createOverlayShortcutLifecycleDepsRuntimeService, - createOverlayShortcutRuntimeDepsService, createOverlayShortcutRuntimeHandlers, - createOverlayVisibilityFacadeDepsRuntimeService, createOverlayWindowRuntimeDepsService, createOverlayWindowService, createRuntimeOptionsIpcDepsRuntimeService, createRuntimeOptionsManagerRuntimeService, - createSecondarySubtitleCycleDepsRuntimeService, createStartupLifecycleHooksRuntimeService, createSubsyncRuntimeDepsService, createSubtitleTimingTrackerRuntimeService, createTokenizerDepsRuntimeService, - createTriggerFieldGroupingDepsRuntimeService, - createUpdateLastCardFromClipboardDepsRuntimeService, createVisibleOverlayVisibilityDepsRuntimeService, - createYomitanSettingsWindowDepsRuntimeService, cycleSecondarySubModeService, enforceOverlayLayerOrderService, ensureOverlayWindowLevelService, getInitialInvisibleOverlayVisibilityService, getJimakuLanguagePreferenceService, getJimakuMaxEntryResultsService, - getOverlayWindowsRuntimeService, handleCliCommandService, handleMineSentenceDigitService, handleMpvCommandFromIpcService, @@ -154,7 +128,6 @@ import { hasMpvWebsocketPlugin, initializeOverlayRuntimeService, isAutoUpdateEnabledRuntimeService, - isGlobalShortcutRegisteredSafe, jimakuFetchJsonService, loadSubtitlePositionService, loadYomitanExtensionService, @@ -211,7 +184,41 @@ if (process.platform === "linux") { } const DEFAULT_TEXTHOOKER_PORT = 5174; -const CONFIG_DIR = path.join(os.homedir(), ".config", "SubMiner"); +function resolveConfigDir(): string { + const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); + const baseDirs = Array.from( + new Set([ + xdgConfigHome || path.join(os.homedir(), ".config"), + path.join(os.homedir(), ".config"), + ]), + ); + const appNames = ["SubMiner", "subminer"]; + + for (const baseDir of baseDirs) { + for (const appName of appNames) { + const dir = path.join(baseDir, appName); + if ( + fs.existsSync(path.join(dir, "config.jsonc")) || + fs.existsSync(path.join(dir, "config.json")) + ) { + return dir; + } + } + } + + for (const baseDir of baseDirs) { + for (const appName of appNames) { + const dir = path.join(baseDir, appName); + if (fs.existsSync(dir)) { + return dir; + } + } + } + + return path.join(baseDirs[0], "SubMiner"); +} + +const CONFIG_DIR = resolveConfigDir(); const USER_DATA_PATH = CONFIG_DIR; const configService = new ConfigService(CONFIG_DIR); const isDev = @@ -239,8 +246,6 @@ process.on("SIGTERM", () => { app.quit(); }); -let mainWindow: BrowserWindow | null = null; -let invisibleWindow: BrowserWindow | null = null; let yomitanExt: Extension | null = null; let yomitanSettingsWindow: BrowserWindow | null = null; let yomitanParserWindow: BrowserWindow | null = null; @@ -250,8 +255,6 @@ let mpvClient: MpvIpcClient | null = null; let reconnectTimer: ReturnType | null = null; let currentSubText = ""; let currentSubAssText = ""; -let visibleOverlayVisible = false; -let invisibleOverlayVisible = false; let windowTracker: BaseWindowTracker | null = null; let subtitlePosition: SubtitlePosition | null = null; let currentMediaPath: string | null = null; @@ -292,12 +295,13 @@ let fieldGroupingResolver: ((choice: KikuFieldGroupingChoice) => void) | null = let runtimeOptionsManager: RuntimeOptionsManager | null = null; let trackerNotReadyWarningShown = false; let overlayDebugVisualizationEnabled = false; +const overlayManager = createOverlayManagerService(); type OverlayHostedModal = "runtime-options" | "subsync"; const restoreVisibleOverlayOnModalClose = new Set(); const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntimeService({ - getMainWindow: () => mainWindow, - getVisibleOverlayVisible: () => visibleOverlayVisible, - getInvisibleOverlayVisible: () => invisibleOverlayVisible, + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible), getResolver: () => fieldGroupingResolver, @@ -315,7 +319,7 @@ const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, "subtitle-positions"); function getRuntimeOptionsState(): RuntimeOptionState[] { if (!runtimeOptionsManager) return []; return runtimeOptionsManager.listOptions(); } function getOverlayWindows(): BrowserWindow[] { - return getOverlayWindowsRuntimeService({ mainWindow, invisibleWindow }); + return overlayManager.getOverlayWindows(); } function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { @@ -492,13 +496,14 @@ const startupState = runStartupBootstrapRuntimeService({ createMpvClient: () => { mpvClient = new MpvIpcClient( mpvSocketPath, - createMpvIpcClientDepsRuntimeService({ + { getResolvedConfig: () => getResolvedConfig(), autoStartOverlay, setOverlayVisible: (visible) => setOverlayVisible(visible), shouldBindVisibleOverlayToMpvSubVisibility: () => shouldBindVisibleOverlayToMpvSubVisibility(), - isVisibleOverlayVisible: () => visibleOverlayVisible, + isVisibleOverlayVisible: () => + overlayManager.getVisibleOverlayVisible(), getReconnectTimer: () => reconnectTimer, setReconnectTimer: (timer) => { reconnectTimer = timer; @@ -532,11 +537,12 @@ const startupState = runStartupBootstrapRuntimeService({ showMpvOsd: (text) => { showMpvOsd(text); }, - }), + }, ); }, reloadConfig: () => { configService.reloadConfig(); + appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`); }, getResolvedConfig: () => getResolvedConfig(), getConfigWarnings: () => configService.getWarnings(), @@ -705,7 +711,7 @@ function handleCliCommand( }, app: { stop: () => app.quit(), - hasMainWindow: () => Boolean(mainWindow), + hasMainWindow: () => Boolean(overlayManager.getMainWindow()), }, getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, schedule: (fn, delayMs) => setTimeout(fn, delayMs), @@ -773,10 +779,10 @@ function ensureOverlayWindowLevel(window: BrowserWindow): void { function enforceOverlayLayerOrder(): void { enforceOverlayLayerOrderService({ - visibleOverlayVisible, - invisibleOverlayVisible, - mainWindow, - invisibleWindow, + visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(), + invisibleOverlayVisible: overlayManager.getInvisibleOverlayVisible(), + mainWindow: overlayManager.getMainWindow(), + invisibleWindow: overlayManager.getInvisibleWindow(), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), }); } @@ -810,23 +816,31 @@ function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow { onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), - getVisibleOverlayVisible: () => visibleOverlayVisible, - getInvisibleOverlayVisible: () => invisibleOverlayVisible, + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), tryHandleOverlayShortcutLocalFallback: (input) => tryHandleOverlayShortcutLocalFallback(input), onWindowClosed: (windowKind) => { if (windowKind === "visible") { - mainWindow = null; + overlayManager.setMainWindow(null); } else { - invisibleWindow = null; + overlayManager.setInvisibleWindow(null); } }, }), ); } -function createMainWindow(): BrowserWindow { mainWindow = createOverlayWindow("visible"); return mainWindow; } -function createInvisibleWindow(): BrowserWindow { invisibleWindow = createOverlayWindow("invisible"); return invisibleWindow; } +function createMainWindow(): BrowserWindow { + const window = createOverlayWindow("visible"); + overlayManager.setMainWindow(window); + return window; +} +function createInvisibleWindow(): BrowserWindow { + const window = createOverlayWindow("invisible"); + overlayManager.setInvisibleWindow(window); + return window; +} function initializeOverlayRuntime(): void { if (overlayRuntimeInitialized) { @@ -849,8 +863,9 @@ function initializeOverlayRuntime(): void { updateOverlayBounds: (geometry) => { updateOverlayBounds(geometry); }, - isVisibleOverlayVisible: () => visibleOverlayVisible, - isInvisibleOverlayVisible: () => invisibleOverlayVisible, + isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + isInvisibleOverlayVisible: () => + overlayManager.getInvisibleOverlayVisible(), updateVisibleOverlayVisibility: () => { updateVisibleOverlayVisibility(); }, @@ -875,35 +890,12 @@ function initializeOverlayRuntime(): void { createFieldGroupingCallback: () => createFieldGroupingCallback(), }), ); - invisibleOverlayVisible = result.invisibleOverlayVisible; + overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible); overlayRuntimeInitialized = true; } function getShortcutUiRuntimeDeps() { return { - yomitanExt, - getYomitanSettingsWindow: () => yomitanSettingsWindow, - setYomitanSettingsWindow: (window: BrowserWindow | null) => { - yomitanSettingsWindow = window; - }, - shortcuts: getConfiguredShortcuts(), - onToggleVisibleOverlay: () => toggleVisibleOverlay(), - onToggleInvisibleOverlay: () => toggleInvisibleOverlay(), - onOpenYomitanSettings: () => openYomitanSettings(), - isDev, - getMainWindow: () => mainWindow, - getSecondarySubMode: () => secondarySubMode, - setSecondarySubMode: (mode: SecondarySubMode) => { - secondarySubMode = mode; - }, - getLastSecondarySubToggleAtMs: () => lastSecondarySubToggleAtMs, - setLastSecondarySubToggleAtMs: (timestampMs: number) => { - lastSecondarySubToggleAtMs = timestampMs; - }, - broadcastSecondarySubMode: (mode: SecondarySubMode) => { - broadcastToOverlayWindows("secondary-subtitle:mode", mode); - }, - showMpvOsd: (text: string) => showMpvOsd(text), getConfiguredShortcuts: () => getConfiguredShortcuts(), getOverlayShortcutFallbackHandlers: () => getOverlayShortcutRuntimeHandlers().fallbackHandlers, @@ -913,12 +905,25 @@ function getShortcutUiRuntimeDeps() { function openYomitanSettings(): void { openYomitanSettingsWindow( - createYomitanSettingsWindowDepsRuntimeService(getShortcutUiRuntimeDeps()), + { + yomitanExt, + getExistingWindow: () => yomitanSettingsWindow, + setWindow: (window: BrowserWindow | null) => { + yomitanSettingsWindow = window; + }, + }, ); } function registerGlobalShortcuts(): void { registerGlobalShortcutsService( - createGlobalShortcutRegistrationDepsRuntimeService(getShortcutUiRuntimeDeps()), + { + shortcuts: getConfiguredShortcuts(), + onToggleVisibleOverlay: () => toggleVisibleOverlay(), + onToggleInvisibleOverlay: () => toggleInvisibleOverlay(), + onOpenYomitanSettings: () => openYomitanSettings(), + isDev, + getMainWindow: () => overlayManager.getMainWindow(), + }, ); } @@ -926,7 +931,7 @@ function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolve function getOverlayShortcutRuntimeHandlers() { return createOverlayShortcutRuntimeHandlers( - createOverlayShortcutRuntimeDepsService({ + { showMpvOsd: (text) => showMpvOsd(text), openRuntimeOptions: () => { openRuntimeOptionsPalette(); @@ -949,7 +954,7 @@ function getOverlayShortcutRuntimeHandlers() { mineSentenceMultiple: (timeoutMs) => { startPendingMineSentenceMultiple(timeoutMs); }, - }), + }, ); } @@ -962,7 +967,20 @@ function tryHandleOverlayShortcutLocalFallback(input: Electron.Input): boolean { function cycleSecondarySubMode(): void { cycleSecondarySubModeService( - createSecondarySubtitleCycleDepsRuntimeService(getShortcutUiRuntimeDeps()), + { + getSecondarySubMode: () => secondarySubMode, + setSecondarySubMode: (mode: SecondarySubMode) => { + secondarySubMode = mode; + }, + getLastSecondarySubToggleAtMs: () => lastSecondarySubToggleAtMs, + setLastSecondarySubToggleAtMs: (timestampMs: number) => { + lastSecondarySubToggleAtMs = timestampMs; + }, + broadcastSecondarySubMode: (mode: SecondarySubMode) => { + broadcastToOverlayWindows("secondary-subtitle:mode", mode); + }, + showMpvOsd: (text: string) => showMpvOsd(text), + }, ); } @@ -984,27 +1002,26 @@ const numericShortcutRuntime = createNumericShortcutRuntimeService({ }); const multiCopySession = numericShortcutRuntime.createSession(); const mineSentenceSession = numericShortcutRuntime.createSession(); -const overlayVisibilityFacadeDeps = - createOverlayVisibilityFacadeDepsRuntimeService({ - getVisibleOverlayVisible: () => visibleOverlayVisible, - getInvisibleOverlayVisible: () => invisibleOverlayVisible, - setVisibleOverlayVisibleState: (nextVisible: boolean) => { - visibleOverlayVisible = nextVisible; - }, - setInvisibleOverlayVisibleState: (nextVisible: boolean) => { - invisibleOverlayVisible = nextVisible; - }, - updateVisibleOverlayVisibility: () => updateVisibleOverlayVisibility(), - updateInvisibleOverlayVisibility: () => updateInvisibleOverlayVisibility(), - syncInvisibleOverlayMousePassthrough: () => - syncInvisibleOverlayMousePassthrough(), - shouldBindVisibleOverlayToMpvSubVisibility: () => - shouldBindVisibleOverlayToMpvSubVisibility(), - isMpvConnected: () => Boolean(mpvClient && mpvClient.connected), - setMpvSubVisibility: (mpvSubVisible: boolean) => { - setMpvSubVisibilityRuntimeService(mpvClient, mpvSubVisible); - }, - }); +const overlayVisibilityFacadeDeps = { + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + setVisibleOverlayVisibleState: (nextVisible: boolean) => { + overlayManager.setVisibleOverlayVisible(nextVisible); + }, + setInvisibleOverlayVisibleState: (nextVisible: boolean) => { + overlayManager.setInvisibleOverlayVisible(nextVisible); + }, + updateVisibleOverlayVisibility: () => updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => updateInvisibleOverlayVisibility(), + syncInvisibleOverlayMousePassthrough: () => + syncInvisibleOverlayMousePassthrough(), + shouldBindVisibleOverlayToMpvSubVisibility: () => + shouldBindVisibleOverlayToMpvSubVisibility(), + isMpvConnected: () => Boolean(mpvClient && mpvClient.connected), + setMpvSubVisibility: (mpvSubVisible: boolean) => { + setMpvSubVisibilityRuntimeService(mpvClient, mpvSubVisible); + }, +}; function getSubsyncRuntimeDeps() { return createSubsyncRuntimeDepsService({ @@ -1043,59 +1060,59 @@ function startPendingMultiCopy(timeoutMs: number): void { function handleMultiCopyDigit(count: number): void { handleMultiCopyDigitService( count, - createHandleMultiCopyDigitDepsRuntimeService({ + { subtitleTimingTracker, writeClipboardText: (text) => clipboard.writeText(text), showMpvOsd: (text) => showMpvOsd(text), - }), + }, ); } function copyCurrentSubtitle(): void { copyCurrentSubtitleService( - createCopyCurrentSubtitleDepsRuntimeService({ + { subtitleTimingTracker, writeClipboardText: (text) => clipboard.writeText(text), showMpvOsd: (text) => showMpvOsd(text), - }), + }, ); } async function updateLastCardFromClipboard(): Promise { await updateLastCardFromClipboardService( - createUpdateLastCardFromClipboardDepsRuntimeService({ + { ankiIntegration, readClipboardText: () => clipboard.readText(), showMpvOsd: (text) => showMpvOsd(text), - }), + }, ); } async function triggerFieldGrouping(): Promise { await triggerFieldGroupingService( - createTriggerFieldGroupingDepsRuntimeService({ + { ankiIntegration, showMpvOsd: (text) => showMpvOsd(text), - }), + }, ); } async function markLastCardAsAudioCard(): Promise { await markLastCardAsAudioCardService( - createMarkLastCardAsAudioCardDepsRuntimeService({ + { ankiIntegration, showMpvOsd: (text) => showMpvOsd(text), - }), + }, ); } async function mineSentenceCard(): Promise { await mineSentenceCardService( - createMineSentenceCardDepsRuntimeService({ + { ankiIntegration, mpvClient, showMpvOsd: (text) => showMpvOsd(text), - }), + }, ); } @@ -1118,7 +1135,7 @@ function startPendingMineSentenceMultiple(timeoutMs: number): void { function handleMineSentenceDigit(count: number): void { handleMineSentenceDigitService( count, - createHandleMineSentenceDigitDepsRuntimeService({ + { subtitleTimingTracker, ankiIntegration, getCurrentSecondarySubText: () => @@ -1127,7 +1144,7 @@ function handleMineSentenceDigit(count: number): void { logError: (message, err) => { console.error(message, err); }, - }), + }, ); } @@ -1139,12 +1156,12 @@ function registerOverlayShortcuts(): void { } function getOverlayShortcutLifecycleDeps() { - return createOverlayShortcutLifecycleDepsRuntimeService({ + return { getConfiguredShortcuts: () => getConfiguredShortcuts(), getOverlayHandlers: () => getOverlayShortcutRuntimeHandlers().overlayHandlers, cancelPendingMultiCopy: () => cancelPendingMultiCopy(), cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultiple(), - }); + }; } function unregisterOverlayShortcuts(): void { @@ -1173,8 +1190,8 @@ function refreshOverlayShortcuts(): void { function updateVisibleOverlayVisibility(): void { updateVisibleOverlayVisibilityService( createVisibleOverlayVisibilityDepsRuntimeService({ - getVisibleOverlayVisible: () => visibleOverlayVisible, - getMainWindow: () => mainWindow, + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), getWindowTracker: () => windowTracker, getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown) => { @@ -1203,9 +1220,10 @@ function updateVisibleOverlayVisibility(): void { function updateInvisibleOverlayVisibility(): void { updateInvisibleOverlayVisibilityService( createInvisibleOverlayVisibilityDepsRuntimeService({ - getInvisibleWindow: () => invisibleWindow, - getVisibleOverlayVisible: () => visibleOverlayVisible, - getInvisibleOverlayVisible: () => invisibleOverlayVisible, + getInvisibleWindow: () => overlayManager.getInvisibleWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisible: () => + overlayManager.getInvisibleOverlayVisible(), getWindowTracker: () => windowTracker, updateOverlayBounds: (geometry) => updateOverlayBounds(geometry), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), @@ -1217,13 +1235,17 @@ function updateInvisibleOverlayVisibility(): void { function syncInvisibleOverlayMousePassthrough(): void { syncInvisibleOverlayMousePassthroughService({ - hasInvisibleWindow: () => Boolean(invisibleWindow && !invisibleWindow.isDestroyed()), + hasInvisibleWindow: () => { + const invisibleWindow = overlayManager.getInvisibleWindow(); + return Boolean(invisibleWindow && !invisibleWindow.isDestroyed()); + }, setIgnoreMouseEvents: (ignore, extra) => { + const invisibleWindow = overlayManager.getInvisibleWindow(); if (!invisibleWindow || invisibleWindow.isDestroyed()) return; invisibleWindow.setIgnoreMouseEvents(ignore, extra); }, - visibleOverlayVisible, - invisibleOverlayVisible, + visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(), + invisibleOverlayVisible: overlayManager.getInvisibleOverlayVisible(), }); } @@ -1285,10 +1307,11 @@ const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDepsRuntimeService({ registerIpcHandlersService( createIpcDepsRuntimeService({ - getInvisibleWindow: () => invisibleWindow, - getMainWindow: () => mainWindow, - getVisibleOverlayVisibility: () => visibleOverlayVisible, - getInvisibleOverlayVisibility: () => invisibleOverlayVisible, + getInvisibleWindow: () => overlayManager.getInvisibleWindow(), + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), + getInvisibleOverlayVisibility: () => + overlayManager.getInvisibleOverlayVisible(), onOverlayModalClosed: (modal) => handleOverlayModalClosed(modal as OverlayHostedModal), openYomitanSettings: () => openYomitanSettings(), @@ -1316,7 +1339,7 @@ registerIpcHandlersService( ); registerAnkiJimakuIpcRuntimeService( - createAnkiJimakuIpcDepsRuntimeService({ + { patchAnkiConnectEnabled: (enabled) => { configService.patchRawConfig({ ankiConnect: { enabled } }); }, @@ -1344,5 +1367,5 @@ registerAnkiJimakuIpcRuntimeService( isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), downloadToFile: (url, destPath, headers) => downloadToFile(url, destPath, headers), - }), + }, );