mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 16:19:25 -07:00
Compare commits
5 Commits
aa0385904e
...
refactor-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
8520a4d068
|
|||
|
564a295e5f
|
|||
|
006ff22d42
|
|||
|
ec64eebb80
|
|||
|
983f3b38ee
|
@@ -4,8 +4,6 @@
|
||||
|
||||
### Fixed
|
||||
- AniList: Stopped post-watch tracking from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
|
||||
- Playback: Fixed managed local playback so duplicate startup-ready retries no longer unpause media after a later manual pause on the same file.
|
||||
- Playback: Fixed managed local subtitle auto-selection so local files reuse configured primary/secondary subtitle language priorities instead of staying on mpv's initial `sid=auto` guess.
|
||||
|
||||
## v0.10.0 (2026-03-29)
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@ Look up words with Yomitan, export to Anki in one key, track your immersion —
|
||||
|
||||
SubMiner runs as an invisible Electron overlay on top of mpv. Subtitles render as an interactive layer. Move your cursor over any word and trigger a [Yomitan](https://github.com/yomidevs/yomitan) lookup. Press one key to snapshot the sentence, audio, and screenshot into Anki via AnkiConnect.
|
||||
|
||||
First-run setup requires the mpv plugin before it can finish. On Windows, the optional `SubMiner mpv` shortcut created during setup is the recommended playback entry point because it launches `mpv` with SubMiner's defaults directly, so you do not need an `mpv.conf` profile just to use it.
|
||||
|
||||
## Features
|
||||
|
||||
### Dictionary Lookups
|
||||
@@ -69,8 +67,6 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
|
||||
|
||||
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
||||
|
||||
Managed local playback now reapplies your configured subtitle language priorities after mpv loads track metadata, so mixed subtitle sets can settle onto the expected primary and secondary tracks instead of staying on mpv's initial `sid=auto` guess.
|
||||
|
||||
<br>
|
||||
|
||||
### Integrations
|
||||
@@ -78,7 +74,7 @@ Managed local playback now reapplies your configured subtitle language prioritie
|
||||
<table>
|
||||
<tr>
|
||||
<td><b>YouTube</b></td>
|
||||
<td>Auto-loaded yt-dlp subtitle tracks at startup with config-driven primary/secondary language priorities and a manual overlay picker on demand (<code>Ctrl+Alt+C</code>)</td>
|
||||
<td>Auto-loaded yt-dlp subtitle tracks at startup with a manual overlay picker on demand (<code>Ctrl+Alt+C</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>AniList</b></td>
|
||||
@@ -228,7 +224,7 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
|
||||
|
||||
### 2. First Launch
|
||||
|
||||
Run the app. On first launch SubMiner starts in the system tray, creates a default config, and opens a setup popup to finish config, install the mpv plugin, and configure Yomitan dictionaries.
|
||||
Run the app. On first launch SubMiner starts in the system tray, creates a default config, and opens a setup popup to install the mpv plugin and configure Yomitan dictionaries.
|
||||
|
||||
### 3. Mine
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
id: TASK-272
|
||||
title: 'Assess and address PR #40 CodeRabbit review follow-ups'
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-03 07:52'
|
||||
updated_date: '2026-04-03 08:04'
|
||||
labels:
|
||||
- coderabbit
|
||||
- review
|
||||
- launcher
|
||||
milestone: 'PR #40'
|
||||
dependencies: []
|
||||
references:
|
||||
- 'https://github.com/ksyasuda/SubMiner/pull/40'
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Implement the valid CodeRabbit findings on PR #40 and keep the Windows mpv shortcut / first-run setup flow consistent end to end.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Windows binary resolution does not return install directories as executable candidates
|
||||
- [ ] #2 Launch-mpv arg parsing preserves space-separated mpv option values and target separation
|
||||
- [ ] #3 Windows mpv launch args keep the final input-ipc-server and script-opts socket path in sync when custom values are supplied
|
||||
- [ ] #4 First-run setup navigation swallows stale or invalid custom-scheme actions without navigating away
|
||||
- [ ] #5 Setup messaging and footer copy reflect configReady, plugin, and dictionary gates consistently
|
||||
- [ ] #6 Regression tests cover the fixed behaviors
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Addressed CodeRabbit follow-ups for PR #40. Hardened launcher binary discovery on Windows and PATH resolution, fixed launch-mpv argument parsing for value-bearing flags, synced custom Windows mpv IPC socket values into script opts, and tightened first-run setup messaging/navigation to handle stale actions and blocker copy. Verified with `bun test src/main-entry-runtime.test.ts src/main/runtime/windows-mpv-launch.test.ts src/main/runtime/first-run-setup-window.test.ts launcher/mpv.test.ts`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: TASK-238.8
|
||||
title: Refactor src/main.ts composition root into domain runtimes
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-03-31 06:28'
|
||||
updated_date: '2026-04-01 07:07'
|
||||
labels:
|
||||
- tech-debt
|
||||
- runtime
|
||||
- maintainability
|
||||
- composition-root
|
||||
dependencies: []
|
||||
references:
|
||||
- src/main.ts
|
||||
- src/main/boot/services
|
||||
- src/main/runtime/composers
|
||||
- docs/architecture/README.md
|
||||
parent_task_id: TASK-238
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Refactor `src/main.ts` so it becomes a thin composition root and the domain-specific runtime wiring moves into short wrapper modules under `src/main/`. Preserve all current behavior, IPC contracts, and config/schema semantics while reducing the entrypoint to boot services, grouped runtime instantiation, startup execution, and process-level quit handling.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 `src/main.ts` is bootstrap/composition only: platform preflight, boot services, runtime creation, startup execution, and top-level quit/signal handling.
|
||||
- [ ] #2 `src/main.ts` no longer imports `src/main/runtime/*-main-deps.ts` directly.
|
||||
- [ ] #3 `src/main.ts` has no local names like `build*MainDepsHandler`, `*MainDeps`, or trivial `*Handler` pass-through wrappers.
|
||||
- [ ] #4 New wrapper files stay under ~500 LOC each; if one exceeds that, split before merge.
|
||||
- [ ] #5 Cross-domain coordination stays in `main.ts`; wrapper modules stay acyclic and communicate via injected callbacks.
|
||||
- [ ] #6 No user-facing behavior, config fields, or IPC channel names change.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
CI follow-up: typecheck failed after the runtime split because playlist-browser IPC deps were not threaded through the new bootstrap/composer surfaces. Wiring the missing open action and registration deps now.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
id: TASK-262
|
||||
title: Create overlay UI bootstrap input helper
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-03-31 17:04'
|
||||
labels:
|
||||
- refactor
|
||||
- main
|
||||
- overlay-ui
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add a coarse input-builder/helper module to reduce the large createOverlayUiRuntime(...) callsite in src/main.ts without changing runtime behavior. Do not edit src/main.ts in this task.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 New helper module(s) live under src/main/
|
||||
- [ ] #2 Helper accepts grouped overlay UI/domain inputs instead of giant inline literals
|
||||
- [ ] #3 Helper keeps files under 500 LOC
|
||||
- [ ] #4 Optional focused tests added if useful
|
||||
- [ ] #5 No runtime behavior changes
|
||||
<!-- AC:END -->
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
id: TASK-263
|
||||
title: Create coarse startup bootstrap wrapper
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-03-31 17:21'
|
||||
labels:
|
||||
- refactor
|
||||
- main
|
||||
- startup
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Move the large createMainStartupRuntime construction and its self-reference handling out of src/main.ts into a coarse startup bootstrap wrapper. Keep behavior identical and shrink the startup section materially.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 New wrapper module(s) live under src/main/
|
||||
- [ ] #2 Wrapper owns createMainStartupRuntime construction and self-reference handling
|
||||
- [ ] #3 src/main.ts startup section becomes materially smaller
|
||||
- [ ] #4 Files stay under 500 LOC
|
||||
- [ ] #5 Focused tests cover the wrapper if useful
|
||||
- [ ] #6 No runtime behavior changes
|
||||
<!-- AC:END -->
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
id: TASK-269
|
||||
title: 'Assess and address PR #39 latest CodeRabbit review round'
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-04-01 07:22'
|
||||
updated_date: '2026-04-01 07:55'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
- src/main
|
||||
- docs/architecture/README.md
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Assess unresolved CodeRabbit review threads on PR #39, fix valid findings in the current branch, and document any non-actionable findings so the review state is clear for follow-up.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 All unresolved CodeRabbit review threads on PR #39 are assessed and classified as fix or non-actionable with rationale
|
||||
- [x] #2 Valid findings are addressed in code without regressing current runtime behavior
|
||||
- [x] #3 Regression tests or targeted coverage are added when the reviewed behavior can be exercised locally
|
||||
- [x] #4 Relevant verification commands are run and results recorded in the task summary
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Confirm each unresolved CodeRabbit thread against current branch state and mark already-addressed items as non-actionable without code churn.
|
||||
2. Fix validated runtime issues in focused patches: AniList setup window stale close handler, headless known-word refresh effective config usage, main startup websocket probe wiring, startup warmup delegation, mpv media-path duplicate side effects, overlay visibility readiness guard, Discord presence service cleanup, and stats daemon self-stop handling.
|
||||
3. Tighten headless startup runtime typing so custom bootstrap deps require an explicit factory instead of unsafe casting.
|
||||
4. Add or extend targeted tests for behaviors that can be exercised locally, especially overlay visibility, Discord lifecycle cleanup, stats self-stop, and headless startup typing/runtime coverage.
|
||||
5. Run targeted tests first, then the relevant broader verification lane, and capture which CodeRabbit items were fixed versus assessed as already addressed.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Reassessed current branch after reset concern: most runtime/test fixes were still present; only live worktree delta was IPC playlist-browser cleanup plus the untracked backlog task record.
|
||||
|
||||
Validated and fixed CodeRabbit findings for stale AniList setup window clearing, effective headless Anki config usage, CLI websocket probe wiring, duplicate mpv media-path side effects, overlay visibility readiness, Discord presence service cleanup, stats self-stop, unsafe headless startup custom deps typing, and playlist-browser IPC wiring.
|
||||
|
||||
Assessed main-startup-runtime-bootstrap warmup comment as non-actionable at this layer: the guarded startBackgroundWarmupsIfAllowed wrapper exists in main-startup-bootstrap, while this lower bootstrap still needs to expose the raw mpv warmup command.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Assessed the unresolved CodeRabbit round on PR #39 and applied the confirmed fixes across the main-process runtime split. Updated AniList setup window teardown to avoid clearing newer windows, made headless known-word refresh use one effective Anki config end-to-end, wired the real mpv websocket probe into CLI startup, removed duplicate mpv media-path side effects, guarded overlay visibility changes behind overlay readiness, stopped Discord presence services before disable/replace, and prevented stats daemon stop from SIGTERMing the current Electron process. Also tightened headless startup typing so custom bootstrap deps require an explicit factory, normalized stats coordinator note-id typing, and completed playlist-browser IPC wiring/types so typecheck stays green.
|
||||
|
||||
Added targeted regression coverage for overlay visibility initialization, Discord lifecycle cleanup, stats self-stop, and headless startup custom-deps typing expectations. Verification run from the current tree: `bun run typecheck`, targeted `bun test` for overlay/discord/stats/headless startup files, and full `bun run test:fast` all passed.
|
||||
|
||||
Assessment outcome for remaining review noise: the playlist-browser open action was already wired in IPC bootstrap, and the warmup-delegation comment on `main-startup-runtime-bootstrap.ts` was not applied because the guarded wrapper lives one layer higher in `main-startup-bootstrap.ts`; this lower bootstrap still needs to surface the raw mpv warmup command consumed by that wrapper.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
id: TASK-270
|
||||
title: Make Windows mpv shortcut self-contained
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-02 07:13'
|
||||
updated_date: '2026-04-02 07:19'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Remove the Windows mpv shortcut's dependency on a pre-existing mpv profile so the installer-created `SubMiner mpv` flow works out of the box without requiring the user to edit `mpv.conf`. Keep docs aligned with the new behavior and preserve the optional profile guidance for manual mpv usage.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 `SubMiner.exe --launch-mpv` launches mpv with SubMiner's required default args without requiring an mpv profile named `subminer`.
|
||||
- [x] #2 Windows shortcut/help/docs no longer describe `--launch-mpv` as depending on the SubMiner mpv profile.
|
||||
- [x] #3 Automated tests cover the Windows launch args behavior and pass after the change.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Updated the Windows `--launch-mpv` path to pass SubMiner's default mpv args directly instead of requiring `--profile=subminer`. Adjusted Windows shortcut/help text to describe the self-contained defaults-based launch, and updated Windows docs to state that `mpv.conf` is not required for the shortcut path while preserving the optional profile guidance for manual mpv launches.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
id: TASK-271
|
||||
title: Fix local playback subtitle auto-selection and startup pause release
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-04-03 07:47'
|
||||
updated_date: '2026-04-03 08:03'
|
||||
labels:
|
||||
- bug
|
||||
- playback
|
||||
- subtitles
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Investigate local-video startup on desktop playback where managed subtitle defaults can bind the wrong primary subtitle track and startup readiness retries can force playback to resume after the user manually pauses. Scope includes fixing the pause/unpause loop and making local subtitle auto-selection prefer the intended primary/secondary tracks for sentence mining sessions.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Desktop local playback no longer forces `pause=no` after the user manually pauses during or after startup readiness handling.
|
||||
- [x] #2 Managed local subtitle startup selects the expected primary track before secondary track selection for mixed-language subtitle sets like Japanese primary plus English secondary.
|
||||
- [x] #3 Regression tests cover the pause-release bug and the local subtitle auto-selection behavior.
|
||||
- [x] #4 Internal docs are updated if runtime behavior or operator expectations change.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add regression coverage for startup autoplay release so duplicate ready handling cannot unpause playback after the user manually pauses the same media.
|
||||
2. Add regression coverage for managed local subtitle startup selection using configured primary/secondary subtitle language preferences, with JA/EN remaining the default fallback behavior.
|
||||
3. Extract or add reusable subtitle track ranking logic that prefers configured primary and secondary subtitle languages, with stable scoring for external tracks and non-SDH labels.
|
||||
4. Update local playback startup/runtime wiring so managed subtitle defaults use explicit ranked selection instead of raw sid=auto / secondary-sid=auto while preserving config-driven language preference ordering.
|
||||
5. Run focused subtitle/playback tests, then update task notes/final summary with any behavior or docs impact.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented config-aware managed local subtitle selection runtime for local media path changes and playlist-browser local playback rearm, using `youtube.primarySubLanguages` for primary preference and `secondarySub.secondarySubLanguages` for secondary preference with JA/EN fallback defaults.
|
||||
|
||||
Updated autoplay ready gate to ignore duplicate readiness signals for the same media so later manual pauses are not overridden by repeated tokenization-ready events.
|
||||
|
||||
Updated config/template wording to document that the existing subtitle language preferences now drive managed subtitle auto-selection beyond YouTube-only flows.
|
||||
|
||||
Verification: `bun test src/config/config.test.ts src/main/runtime/autoplay-ready-gate.test.ts src/main/runtime/local-subtitle-selection.test.ts src/main/runtime/playlist-browser-runtime.test.ts`; `bun run typecheck`.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Stopped startup readiness retries from re-unpausing the same media after a later manual pause, and added config-aware managed local subtitle selection so local playback prefers the configured primary/secondary subtitle languages instead of relying on raw mpv `sid=auto` behavior. Added regression coverage for autoplay-ready gating, local subtitle selection, playlist-browser local playback rearm, and config template wording updates.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
6
changes/260-main-runtime-refactor.md
Normal file
6
changes/260-main-runtime-refactor.md
Normal file
@@ -0,0 +1,6 @@
|
||||
type: internal
|
||||
area: main
|
||||
|
||||
- Split `src/main.ts` into domain runtime wrappers and startup sequencing helpers.
|
||||
- Removed the last direct `src/main/runtime/*-main-deps.ts` imports from `src/main.ts`.
|
||||
- Kept startup behavior and IPC contracts stable while reducing composition-root size.
|
||||
@@ -1,6 +0,0 @@
|
||||
type: fixed
|
||||
area: launcher
|
||||
|
||||
- Fixed the Windows `SubMiner mpv` shortcut and `SubMiner.exe --launch-mpv` flow to launch mpv with SubMiner's required default args directly instead of requiring an `mpv.conf` profile named `subminer`.
|
||||
- Clarified the Windows install and usage docs so the shortcut path is documented as self-contained, while the optional `subminer` mpv profile remains available for manual mpv launches.
|
||||
- Hardened the first-run setup blocker copy and stale custom-scheme handling so setup messages stay aligned with config, plugin, and dictionary readiness.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: launcher
|
||||
|
||||
- Fixed the Windows `SubMiner mpv` shortcut idle launch so loading a video after opening the shortcut keeps mpv in the expected SubMiner-managed session, auto-starts the overlay, and re-arms subtitle auto-selection for the newly opened file.
|
||||
- Removed the redundant `.` subtitle search path from the Windows shortcut launch args and deduped repeated subtitle source tracks in the manual sync picker so duplicate external subtitle entries no longer appear from the shortcut path.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: changed
|
||||
area: setup
|
||||
|
||||
- Made mpv plugin installation mandatory in the first-run setup flow, removed the skip path, and kept Finish disabled until the plugin is installed.
|
||||
- Clarified that the mpv plugin requirement applies to setup on every platform, while the optional `SubMiner mpv` shortcut remains the recommended Windows playback entry point.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: playback
|
||||
|
||||
- Fixed managed local playback so duplicate startup-ready retries no longer unpause media after a later manual pause on the same file.
|
||||
- Fixed managed local subtitle auto-selection so local files reuse configured primary and secondary subtitle language priorities instead of staying on mpv's initial `sid=auto` guess.
|
||||
@@ -187,7 +187,7 @@
|
||||
// ==========================================
|
||||
// Secondary Subtitles
|
||||
// Dual subtitle track options.
|
||||
// Used by managed subtitle loading as secondary language preferences for local and YouTube playback.
|
||||
// Used by the YouTube subtitle loading flow as secondary language preferences.
|
||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||
// ==========================================
|
||||
"secondarySub": {
|
||||
@@ -415,14 +415,14 @@
|
||||
|
||||
// ==========================================
|
||||
// YouTube Playback Settings
|
||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
// Defaults for SubMiner YouTube subtitle loading and languages.
|
||||
// ==========================================
|
||||
"youtube": {
|
||||
"primarySubLanguages": [
|
||||
"ja",
|
||||
"jpn"
|
||||
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
] // Comma-separated primary subtitle language priority for YouTube auto-loading.
|
||||
}, // Defaults for SubMiner YouTube subtitle loading and languages.
|
||||
|
||||
// ==========================================
|
||||
// Anilist
|
||||
|
||||
@@ -448,8 +448,6 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||
|
||||
`secondarySub.secondarySubLanguages` also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
||||
|
||||
**Display modes:**
|
||||
|
||||
- **hidden** — Secondary subtitles not shown
|
||||
@@ -1344,7 +1342,7 @@ Usage notes:
|
||||
|
||||
### YouTube Playback Settings
|
||||
|
||||
Set defaults used by managed subtitle auto-selection and the `subminer` launcher YouTube flow:
|
||||
Set defaults used by the `subminer` launcher for YouTube subtitle loading:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1356,7 +1354,7 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
|
||||
|
||||
| Option | Values | Description |
|
||||
| --------------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
||||
| `primarySubLanguages` | string[] | Primary subtitle language priority for YouTube auto-loading (default `["ja", "jpn"]`) |
|
||||
|
||||
Current launcher behavior:
|
||||
|
||||
@@ -1372,7 +1370,6 @@ Language targets are derived from subtitle config:
|
||||
|
||||
- primary track: `youtube.primarySubLanguages` (falls back to `["ja","jpn"]`)
|
||||
- secondary track: `secondarySub.secondarySubLanguages` (falls back to English when empty)
|
||||
- Local playback uses the same priorities after mpv reports subtitle track metadata, so sidecar/internal mixed sets can override an incorrect initial `sid=auto` pick.
|
||||
- Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed.
|
||||
|
||||
Precedence for launcher defaults is: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||
|
||||
@@ -171,8 +171,7 @@ Install `mpv` separately and ensure `mpv.exe` is on `PATH`. `ffmpeg` is still re
|
||||
|
||||
### Windows Usage Notes
|
||||
|
||||
- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, require mpv plugin installation, and open bundled Yomitan settings. The optional `SubMiner mpv` Start Menu/Desktop shortcut can also be created during setup, and on Windows it is the recommended way to launch mpv playback with SubMiner defaults.
|
||||
- `SubMiner.exe --launch-mpv` and the optional `SubMiner mpv` shortcut pass SubMiner's default mpv socket/subtitle args directly and do not require an `mpv.conf` profile named `subminer`.
|
||||
- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, offer mpv plugin installation, open bundled Yomitan settings, and optionally create `SubMiner mpv` Start Menu/Desktop shortcuts.
|
||||
- First-run mpv plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is installed in a non-standard location.
|
||||
- Windows plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket`; do not keep `/tmp/subminer-socket` on Windows.
|
||||
- Native window tracking is built in on Windows; no `xdotool`, `xwininfo`, or compositor-specific helper is required.
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
// ==========================================
|
||||
// Secondary Subtitles
|
||||
// Dual subtitle track options.
|
||||
// Used by managed subtitle loading as secondary language preferences for local and YouTube playback.
|
||||
// Used by the YouTube subtitle loading flow as secondary language preferences.
|
||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||
// ==========================================
|
||||
"secondarySub": {
|
||||
@@ -415,14 +415,14 @@
|
||||
|
||||
// ==========================================
|
||||
// YouTube Playback Settings
|
||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
// Defaults for SubMiner YouTube subtitle loading and languages.
|
||||
// ==========================================
|
||||
"youtube": {
|
||||
"primarySubLanguages": [
|
||||
"ja",
|
||||
"jpn"
|
||||
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
] // Comma-separated primary subtitle language priority for YouTube auto-loading.
|
||||
}, // Defaults for SubMiner YouTube subtitle loading and languages.
|
||||
|
||||
// ==========================================
|
||||
// Anilist
|
||||
|
||||
@@ -117,15 +117,12 @@ SubMiner.AppImage --help # Show all options
|
||||
|
||||
### Windows mpv Shortcut
|
||||
|
||||
First-run setup creates the config file, then requires the mpv plugin and Yomitan dictionaries before it can finish.
|
||||
|
||||
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
|
||||
After setup completes, the shortcut is the normal Windows playback entry point.
|
||||
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. It runs `SubMiner.exe --launch-mpv`, which starts `mpv.exe` with SubMiner's `subminer` profile.
|
||||
|
||||
You can use it three ways:
|
||||
|
||||
- Double-click `SubMiner mpv` to open `mpv` with SubMiner's default socket/subtitle args.
|
||||
- Drag a video file onto `SubMiner mpv` to launch that file with the same defaults.
|
||||
- Double-click `SubMiner mpv` to open `mpv` with the SubMiner profile.
|
||||
- Drag a video file onto `SubMiner mpv` to launch that file with the same profile.
|
||||
- Run it directly from Command Prompt or PowerShell with `--launch-mpv`.
|
||||
|
||||
```powershell
|
||||
@@ -133,7 +130,7 @@ You can use it three ways:
|
||||
& "C:\Program Files\SubMiner\SubMiner.exe" --launch-mpv "C:\Videos\episode 01.mkv"
|
||||
```
|
||||
|
||||
This flow requires `mpv.exe` to be on `PATH`. If it is installed elsewhere, set `SUBMINER_MPV_PATH` to the full `mpv.exe` path before launching. On Windows, `--launch-mpv` does not require an `mpv.conf` profile named `subminer`.
|
||||
This flow requires `mpv.exe` to be on `PATH`. If it is installed elsewhere, set `SUBMINER_MPV_PATH` to the full `mpv.exe` path before launching.
|
||||
|
||||
### Launcher Subcommands
|
||||
|
||||
@@ -160,12 +157,12 @@ SubMiner.AppImage --setup
|
||||
Setup flow:
|
||||
|
||||
- config file: create the default config directory and prefer `config.jsonc`
|
||||
- plugin status: install the bundled mpv plugin before finishing setup
|
||||
- plugin status: install or skip the bundled mpv plugin
|
||||
- Yomitan shortcut: open bundled Yomitan settings directly from the setup window
|
||||
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
|
||||
- dictionary check: ensure at least one bundled Yomitan dictionary is available
|
||||
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
|
||||
- refresh: re-check plugin + dictionary state without restarting
|
||||
- `Finish setup` stays disabled until the config, plugin, and dictionary gates are satisfied
|
||||
- `Finish setup` stays disabled until dictionary availability is detected
|
||||
- finish action writes setup completion state and suppresses future auto-open prompts
|
||||
|
||||
AniList character dictionary auto-sync (optional):
|
||||
@@ -192,7 +189,7 @@ Top-level launcher flags like `--jellyfin-*` are intentionally rejected.
|
||||
|
||||
You can append additional MPV arguments with launcher `-a/--args`, for example `--args "--ao=alsa --volume=80"`.
|
||||
|
||||
You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. The Windows `SubMiner.exe --launch-mpv` shortcut path uses equivalent args directly, but skips the extra current-directory subtitle scan to avoid duplicate sidecar detection when you drag a video onto the shortcut; the optional profile remains useful for manual mpv launches and the `subminer` wrapper defaults to `--profile=subminer` (or override with `subminer -p <profile> ...`):
|
||||
You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. `subminer` launches with `--profile=subminer` by default (or override with `subminer -p <profile> ...`):
|
||||
|
||||
```ini
|
||||
[subminer]
|
||||
@@ -213,6 +210,10 @@ secondary-sid=auto
|
||||
secondary-sub-visibility=no
|
||||
```
|
||||
|
||||
::: warning
|
||||
`secondary-slang` is not a valid mpv option. Use `slang` with `sid=auto` / `secondary-sid=auto` to set subtitle language preferences.
|
||||
:::
|
||||
|
||||
### Yomitan setup
|
||||
|
||||
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
|
||||
@@ -237,8 +238,6 @@ Notes:
|
||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset).
|
||||
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`.
|
||||
|
||||
For local video files, SubMiner now uses those same config-driven language priorities after mpv finishes reporting subtitle tracks. That means mixed internal/external subtitle sets can correct an initial `sid=auto` guess and settle onto the expected primary and secondary tracks without manual cycling.
|
||||
|
||||
## Controller Support
|
||||
|
||||
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
||||
@@ -292,7 +291,9 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
|
||||
| `Alt+Shift+O` | Toggle visible overlay |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings |
|
||||
|
||||
::: tip
|
||||
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
|
||||
:::
|
||||
|
||||
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Architecture Map
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-26
|
||||
Last verified: 2026-03-31
|
||||
Owner: Kyle Yasuda
|
||||
Read when: runtime ownership, composition boundaries, or layering questions
|
||||
|
||||
@@ -24,6 +24,27 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
|
||||
## Current Shape
|
||||
|
||||
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
||||
- `src/main/*.ts` wrapper runtimes sit between `src/main.ts` and `src/main/runtime/**`
|
||||
so the composition root stays thin while exported `createBuild*MainDepsHandler`
|
||||
APIs remain internal plumbing. Key domain runtimes:
|
||||
- `anilist-runtime` – AniList token management, media tracking, retry queue
|
||||
- `cli-startup-runtime` – CLI command dispatch and initial-args handling
|
||||
- `discord-presence-lifecycle-runtime` – Discord Rich Presence lifecycle
|
||||
- `first-run-runtime` – first-run setup wizard
|
||||
- `ipc-runtime` – IPC handler registration and composition
|
||||
- `jellyfin-runtime` – Jellyfin session, playback, mpv orchestration
|
||||
- `main-startup-runtime` – top-level startup orchestration (app-ready → CLI → headless)
|
||||
- `main-startup-bootstrap` – wiring helper that builds startup runtime inputs
|
||||
- `mining-runtime` – Anki card mining actions
|
||||
- `mpv-runtime` – mpv client lifecycle
|
||||
- `overlay-ui-runtime` – overlay window management, visibility, tray
|
||||
- `overlay-geometry-runtime` – overlay bounds resolution
|
||||
- `shortcuts-runtime` – global shortcut registration
|
||||
- `startup-sequence-runtime` – headless known-word refresh and deferred startup sequencing
|
||||
- `stats-runtime` – immersion tracker, stats server, stats CLI
|
||||
- `subtitle-runtime` – subtitle prefetch, tokenization, caching
|
||||
- `youtube-runtime` – YouTube playback flow
|
||||
- `yomitan-runtime` – Yomitan extension loading and settings
|
||||
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
||||
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
|
||||
@@ -62,7 +62,6 @@ test('createDefaultArgs normalizes configured language codes and env thread over
|
||||
assert.deepEqual(parsed.youtubeAudioLangs, ['ja', 'jpn', 'en', 'eng']);
|
||||
assert.equal(parsed.whisperThreads, 7);
|
||||
assert.equal(parsed.youtubeWhisperSourceLanguage, 'ja');
|
||||
assert.equal(parsed.profile, '');
|
||||
} finally {
|
||||
if (originalThreads === undefined) {
|
||||
delete process.env.SUBMINER_WHISPER_THREADS;
|
||||
|
||||
@@ -97,7 +97,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
||||
backend: 'auto',
|
||||
directory: '.',
|
||||
recursive: false,
|
||||
profile: '',
|
||||
profile: 'subminer',
|
||||
startOverlay: false,
|
||||
whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '',
|
||||
whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || '',
|
||||
|
||||
@@ -427,19 +427,6 @@ function withFindAppBinaryEnvSandbox(run: () => void): void {
|
||||
}
|
||||
}
|
||||
|
||||
function withFindAppBinaryPlatformSandbox(
|
||||
platform: NodeJS.Platform,
|
||||
run: (pathModule: typeof path) => void,
|
||||
): void {
|
||||
const originalPlatform = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
|
||||
withFindAppBinaryEnvSandbox(() => run(platform === 'win32' ? (path.win32 as typeof path) : path));
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
}
|
||||
|
||||
function withAccessSyncStub(
|
||||
isExecutablePath: (filePath: string) => boolean,
|
||||
run: () => void,
|
||||
@@ -460,7 +447,7 @@ function withAccessSyncStub(
|
||||
}
|
||||
}
|
||||
|
||||
test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', { concurrency: false }, () => {
|
||||
test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
const originalHomedir = os.homedir;
|
||||
try {
|
||||
@@ -468,8 +455,8 @@ test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', { c
|
||||
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
|
||||
makeExecutable(appImage);
|
||||
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
const result = findAppBinary('/some/other/path/subminer', pathModule);
|
||||
withFindAppBinaryEnvSandbox(() => {
|
||||
const result = findAppBinary('/some/other/path/subminer');
|
||||
assert.equal(result, appImage);
|
||||
});
|
||||
} finally {
|
||||
@@ -478,16 +465,16 @@ test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', { c
|
||||
}
|
||||
});
|
||||
|
||||
test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', { concurrency: false }, () => {
|
||||
test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
const originalHomedir = os.homedir;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
withFindAppBinaryEnvSandbox(() => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
|
||||
() => {
|
||||
const result = findAppBinary('/some/other/path/subminer', pathModule);
|
||||
const result = findAppBinary('/some/other/path/subminer');
|
||||
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
|
||||
},
|
||||
);
|
||||
@@ -498,7 +485,7 @@ test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin c
|
||||
}
|
||||
});
|
||||
|
||||
test('findAppBinary finds subminer on PATH when AppImage candidates do not exist', { concurrency: false }, () => {
|
||||
test('findAppBinary finds subminer on PATH when AppImage candidates do not exist', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalPath = process.env.PATH;
|
||||
@@ -510,12 +497,12 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist
|
||||
makeExecutable(wrapperPath);
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
withFindAppBinaryEnvSandbox(() => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === wrapperPath,
|
||||
() => {
|
||||
// selfPath must differ from wrapperPath so the self-check does not exclude it
|
||||
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'), pathModule);
|
||||
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'));
|
||||
assert.equal(result, wrapperPath);
|
||||
},
|
||||
);
|
||||
@@ -526,98 +513,3 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findAppBinary resolves Windows install paths when present', { concurrency: false }, () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalLocalAppData = process.env.LOCALAPPDATA;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
process.env.LOCALAPPDATA = path.win32.join(baseDir, 'AppData', 'Local');
|
||||
const appExe = path.win32.join(
|
||||
baseDir,
|
||||
'AppData',
|
||||
'Local',
|
||||
'Programs',
|
||||
'SubMiner',
|
||||
'SubMiner.exe',
|
||||
);
|
||||
|
||||
withFindAppBinaryPlatformSandbox('win32', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === appExe,
|
||||
() => {
|
||||
const result = findAppBinary(pathModule.join(baseDir, 'launcher', 'SubMiner.exe'), pathModule);
|
||||
assert.equal(result, appExe);
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
if (originalLocalAppData === undefined) {
|
||||
delete process.env.LOCALAPPDATA;
|
||||
} else {
|
||||
process.env.LOCALAPPDATA = originalLocalAppData;
|
||||
}
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: false }, () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-path-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalPath = process.env.PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const binDir = path.win32.join(baseDir, 'bin');
|
||||
const wrapperPath = path.win32.join(binDir, 'SubMiner.exe');
|
||||
makeExecutable(wrapperPath);
|
||||
process.env.PATH = `${binDir}${path.win32.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
withFindAppBinaryPlatformSandbox('win32', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === wrapperPath,
|
||||
() => {
|
||||
const result = findAppBinary(pathModule.join(baseDir, 'launcher', 'SubMiner.exe'), pathModule);
|
||||
assert.equal(result, wrapperPath);
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
process.env.PATH = originalPath;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findAppBinary resolves a Windows install directory to SubMiner.exe', { concurrency: false }, () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-dir-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner');
|
||||
const appExe = path.win32.join(installDir, 'SubMiner.exe');
|
||||
process.env.SUBMINER_BINARY_PATH = installDir;
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
fs.writeFileSync(appExe, '#!/bin/sh\nexit 0\n');
|
||||
fs.chmodSync(appExe, 0o755);
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
const result = findAppBinary(path.win32.join(baseDir, 'launcher', 'SubMiner.exe'), path.win32);
|
||||
assert.equal(result, appExe);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
if (originalSubminerBinaryPath === undefined) {
|
||||
delete process.env.SUBMINER_BINARY_PATH;
|
||||
} else {
|
||||
process.env.SUBMINER_BINARY_PATH = originalSubminerBinaryPath;
|
||||
}
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
111
launcher/mpv.ts
111
launcher/mpv.ts
@@ -14,6 +14,7 @@ import {
|
||||
isExecutable,
|
||||
resolveBinaryPathCandidate,
|
||||
resolveCommandInvocation,
|
||||
realpathMaybe,
|
||||
isYoutubeTarget,
|
||||
uniqueNormalizedLangCodes,
|
||||
sleep,
|
||||
@@ -34,8 +35,6 @@ type SpawnTarget = {
|
||||
args: string[];
|
||||
};
|
||||
|
||||
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>;
|
||||
|
||||
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
||||
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
|
||||
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
||||
@@ -244,49 +243,18 @@ export function detectBackend(backend: Backend): Exclude<Backend, 'auto'> {
|
||||
fail('Could not detect display backend');
|
||||
}
|
||||
|
||||
function resolveAppBinaryCandidate(candidate: string, pathModule: PathModule = path): string {
|
||||
function resolveMacAppBinaryCandidate(candidate: string): string {
|
||||
const direct = resolveBinaryPathCandidate(candidate);
|
||||
if (!direct) return '';
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
if (fs.existsSync(direct) && fs.statSync(direct).isDirectory()) {
|
||||
for (const candidateBinary of ['SubMiner.exe', 'subminer.exe']) {
|
||||
const nestedCandidate = pathModule.join(direct, candidateBinary);
|
||||
if (isExecutable(nestedCandidate)) {
|
||||
return nestedCandidate;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (isExecutable(direct)) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
if (!pathModule.extname(direct)) {
|
||||
for (const extension of ['.exe', '.cmd', '.bat']) {
|
||||
const withExtension = `${direct}${extension}`;
|
||||
if (isExecutable(withExtension)) {
|
||||
return withExtension;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
if (process.platform !== 'darwin') {
|
||||
return isExecutable(direct) ? direct : '';
|
||||
}
|
||||
|
||||
if (isExecutable(direct)) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const appIndex = direct.indexOf('.app/');
|
||||
const appPath =
|
||||
direct.endsWith('.app') && direct.includes('.app')
|
||||
@@ -297,8 +265,8 @@ function resolveAppBinaryCandidate(candidate: string, pathModule: PathModule = p
|
||||
if (!appPath) return '';
|
||||
|
||||
const candidates = [
|
||||
pathModule.join(appPath, 'Contents', 'MacOS', 'SubMiner'),
|
||||
pathModule.join(appPath, 'Contents', 'MacOS', 'subminer'),
|
||||
path.join(appPath, 'Contents', 'MacOS', 'SubMiner'),
|
||||
path.join(appPath, 'Contents', 'MacOS', 'subminer'),
|
||||
];
|
||||
|
||||
for (const candidateBinary of candidates) {
|
||||
@@ -310,78 +278,41 @@ function resolveAppBinaryCandidate(candidate: string, pathModule: PathModule = p
|
||||
return '';
|
||||
}
|
||||
|
||||
function findCommandOnPath(candidates: string[], pathModule: PathModule = path): string {
|
||||
const pathDirs = getPathEnv().split(pathModule.delimiter);
|
||||
for (const candidateName of candidates) {
|
||||
for (const dir of pathDirs) {
|
||||
if (!dir) continue;
|
||||
|
||||
const directCandidate = pathModule.join(dir, candidateName);
|
||||
if (isExecutable(directCandidate)) {
|
||||
return directCandidate;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && !pathModule.extname(candidateName)) {
|
||||
for (const extension of ['.exe', '.cmd', '.bat']) {
|
||||
const extendedCandidate = pathModule.join(dir, `${candidateName}${extension}`);
|
||||
if (isExecutable(extendedCandidate)) {
|
||||
return extendedCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function findAppBinary(selfPath: string, pathModule: PathModule = path): string | null {
|
||||
export function findAppBinary(selfPath: string): string | null {
|
||||
const envPaths = [process.env.SUBMINER_APPIMAGE_PATH, process.env.SUBMINER_BINARY_PATH].filter(
|
||||
(candidate): candidate is string => Boolean(candidate),
|
||||
);
|
||||
|
||||
for (const envPath of envPaths) {
|
||||
const resolved = resolveAppBinaryCandidate(envPath, pathModule);
|
||||
const resolved = resolveMacAppBinaryCandidate(envPath);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
const candidates: string[] = [];
|
||||
if (process.platform === 'win32') {
|
||||
const localAppData =
|
||||
process.env.LOCALAPPDATA?.trim() ||
|
||||
(process.env.APPDATA?.trim() || '').replace(/[\\/]Roaming$/i, `${pathModule.sep}Local`) ||
|
||||
pathModule.join(os.homedir(), 'AppData', 'Local');
|
||||
const programFiles = process.env.ProgramFiles?.trim() || 'C:\\Program Files';
|
||||
const programFilesX86 = process.env['ProgramFiles(x86)']?.trim() || 'C:\\Program Files (x86)';
|
||||
candidates.push(pathModule.join(localAppData, 'Programs', 'SubMiner', 'SubMiner.exe'));
|
||||
candidates.push(pathModule.join(programFiles, 'SubMiner', 'SubMiner.exe'));
|
||||
candidates.push(pathModule.join(programFilesX86, 'SubMiner', 'SubMiner.exe'));
|
||||
candidates.push('C:\\SubMiner\\SubMiner.exe');
|
||||
} else if (process.platform === 'darwin') {
|
||||
if (process.platform === 'darwin') {
|
||||
candidates.push('/Applications/SubMiner.app/Contents/MacOS/SubMiner');
|
||||
candidates.push('/Applications/SubMiner.app/Contents/MacOS/subminer');
|
||||
candidates.push(pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'));
|
||||
candidates.push(pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'));
|
||||
} else {
|
||||
candidates.push(pathModule.join(os.homedir(), '.local/bin/SubMiner.AppImage'));
|
||||
candidates.push('/opt/SubMiner/SubMiner.AppImage');
|
||||
candidates.push(path.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'));
|
||||
candidates.push(path.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'));
|
||||
}
|
||||
|
||||
candidates.push(path.join(os.homedir(), '.local/bin/SubMiner.AppImage'));
|
||||
candidates.push('/opt/SubMiner/SubMiner.AppImage');
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const resolved = resolveAppBinaryCandidate(candidate, pathModule);
|
||||
if (resolved) return resolved;
|
||||
if (isExecutable(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const fromPath = findCommandOnPath(
|
||||
process.platform === 'win32' ? ['SubMiner', 'subminer'] : ['subminer'],
|
||||
pathModule,
|
||||
);
|
||||
const fromPath = getPathEnv()
|
||||
.split(path.delimiter)
|
||||
.map((dir) => path.join(dir, 'subminer'))
|
||||
.find((candidate) => isExecutable(candidate));
|
||||
|
||||
if (fromPath) {
|
||||
const resolvedSelf = pathModule.resolve(selfPath);
|
||||
const resolvedCandidate = pathModule.resolve(fromPath);
|
||||
const resolvedSelf = realpathMaybe(selfPath);
|
||||
const resolvedCandidate = realpathMaybe(fromPath);
|
||||
if (resolvedSelf !== resolvedCandidate) return fromPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
||||
"build:yomitan": "bun scripts/build-yomitan.mjs",
|
||||
"build:assets": "bun scripts/prepare-build-assets.mjs",
|
||||
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=dist/launcher/subminer",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:assets",
|
||||
|
||||
@@ -29,25 +29,13 @@ function M.create(ctx)
|
||||
return options_helper.coerce_bool(raw_auto_start, false)
|
||||
end
|
||||
|
||||
local function rearm_managed_subtitle_defaults()
|
||||
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
|
||||
return false
|
||||
end
|
||||
|
||||
mp.set_property_native("sub-auto", "fuzzy")
|
||||
mp.set_property_native("sid", "auto")
|
||||
mp.set_property_native("secondary-sid", "auto")
|
||||
return true
|
||||
end
|
||||
|
||||
local function on_file_loaded()
|
||||
aniskip.clear_aniskip_state()
|
||||
process.disarm_auto_play_ready_gate()
|
||||
local has_matching_socket = rearm_managed_subtitle_defaults()
|
||||
|
||||
local should_auto_start = resolve_auto_start_enabled()
|
||||
if should_auto_start then
|
||||
if not has_matching_socket then
|
||||
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
|
||||
subminer_log(
|
||||
"info",
|
||||
"lifecycle",
|
||||
|
||||
@@ -178,12 +178,6 @@ local function run_plugin_scenario(config)
|
||||
value = value,
|
||||
}
|
||||
end
|
||||
function mp.set_property(name, value)
|
||||
recorded.property_sets[#recorded.property_sets + 1] = {
|
||||
name = name,
|
||||
value = value,
|
||||
}
|
||||
end
|
||||
function mp.get_script_name()
|
||||
return "subminer"
|
||||
end
|
||||
@@ -537,38 +531,6 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "no",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for subtitle rearm scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
has_property_set(recorded.property_sets, "sub-auto", "fuzzy"),
|
||||
"managed file-loaded should rearm sub-auto for idle mpv sessions"
|
||||
)
|
||||
assert_true(
|
||||
has_property_set(recorded.property_sets, "sid", "auto"),
|
||||
"managed file-loaded should rearm primary subtitle selection for idle mpv sessions"
|
||||
)
|
||||
assert_true(
|
||||
has_property_set(recorded.property_sets, "secondary-sid", "auto"),
|
||||
"managed file-loaded should rearm secondary subtitle selection for idle mpv sessions"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -1075,10 +1037,6 @@ do
|
||||
start_call == nil,
|
||||
"auto-start should be skipped when mpv input-ipc-server does not match configured socket_path"
|
||||
)
|
||||
assert_true(
|
||||
not has_property_set(recorded.property_sets, "sid", "auto"),
|
||||
"subtitle rearm should not run when mpv input-ipc-server does not match configured socket_path"
|
||||
)
|
||||
assert_true(
|
||||
not has_property_set(recorded.property_sets, "pause", true),
|
||||
"pause-until-ready gate should not arm when socket_path does not match"
|
||||
|
||||
@@ -17,7 +17,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
|
||||
assert.match(output, /--help\s+Show this help/);
|
||||
assert.match(output, /default: 7777/);
|
||||
assert.match(output, /--launch-mpv.*Launch mpv with SubMiner defaults and exit/);
|
||||
assert.match(output, /--launch-mpv/);
|
||||
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
|
||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
|
||||
@@ -12,7 +12,7 @@ ${B}Usage:${R} subminer ${D}[command] [options]${R}
|
||||
${B}Session${R}
|
||||
--background Start in tray/background mode
|
||||
--start Connect to mpv and launch overlay
|
||||
--launch-mpv ${D}[targets...]${R} Launch mpv with SubMiner defaults and exit
|
||||
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
|
||||
--stop Stop the running instance
|
||||
--stats Open the stats dashboard in your browser
|
||||
--texthooker Start texthooker server only ${D}(no overlay)${R}
|
||||
|
||||
@@ -2138,7 +2138,7 @@ test('template generator includes known keys', () => {
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./,
|
||||
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for YouTube auto-loading\./,
|
||||
);
|
||||
assert.doesNotMatch(output, /"mode": "automatic"/);
|
||||
assert.doesNotMatch(output, /"fixWithAi": false/);
|
||||
|
||||
@@ -87,8 +87,7 @@ export function buildCoreConfigOptionRegistry(
|
||||
path: 'youtube.primarySubLanguages',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.youtube.primarySubLanguages.join(','),
|
||||
description:
|
||||
'Comma-separated primary subtitle language priority for managed subtitle auto-selection.',
|
||||
description: 'Comma-separated primary subtitle language priority for YouTube auto-loading.',
|
||||
},
|
||||
{
|
||||
path: 'controller.enabled',
|
||||
|
||||
@@ -74,7 +74,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
title: 'Secondary Subtitles',
|
||||
description: [
|
||||
'Dual subtitle track options.',
|
||||
'Used by managed subtitle loading as secondary language preferences for local and YouTube playback.',
|
||||
'Used by the YouTube subtitle loading flow as secondary language preferences.',
|
||||
],
|
||||
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
|
||||
key: 'secondarySub',
|
||||
@@ -131,7 +131,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
},
|
||||
{
|
||||
title: 'YouTube Playback Settings',
|
||||
description: ['Defaults for managed subtitle language preferences and YouTube subtitle loading.'],
|
||||
description: ['Defaults for SubMiner YouTube subtitle loading and languages.'],
|
||||
key: 'youtube',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -184,39 +184,6 @@ test('dispatchMpvProtocolMessage sets secondary subtitle track based on track li
|
||||
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 2] }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage prefers the already selected matching secondary track', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{
|
||||
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
data: [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
lang: 'ja',
|
||||
title: 'ja.srt',
|
||||
selected: false,
|
||||
external: true,
|
||||
'external-filename': '/tmp/dupe.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 3,
|
||||
lang: 'ja',
|
||||
title: 'ja.srt',
|
||||
selected: true,
|
||||
external: true,
|
||||
'external-filename': '/tmp/dupe.srt',
|
||||
},
|
||||
],
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
|
||||
@@ -93,97 +93,6 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
}
|
||||
|
||||
type SubtitleTrackCandidate = {
|
||||
id: number;
|
||||
lang: string;
|
||||
title: string;
|
||||
selected: boolean;
|
||||
external: boolean;
|
||||
externalFilename: string | null;
|
||||
};
|
||||
|
||||
function normalizeSubtitleTrackCandidate(track: Record<string, unknown>): SubtitleTrackCandidate | null {
|
||||
const id =
|
||||
typeof track.id === 'number'
|
||||
? track.id
|
||||
: typeof track.id === 'string'
|
||||
? Number(track.id.trim())
|
||||
: Number.NaN;
|
||||
if (!Number.isInteger(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const externalFilename =
|
||||
typeof track['external-filename'] === 'string' && track['external-filename'].trim().length > 0
|
||||
? track['external-filename'].trim()
|
||||
: typeof track.external_filename === 'string' && track.external_filename.trim().length > 0
|
||||
? track.external_filename.trim()
|
||||
: null;
|
||||
|
||||
return {
|
||||
id,
|
||||
lang: String(track.lang || '').trim().toLowerCase(),
|
||||
title: String(track.title || '').trim().toLowerCase(),
|
||||
selected: track.selected === true,
|
||||
external: track.external === true,
|
||||
externalFilename,
|
||||
};
|
||||
}
|
||||
|
||||
function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string {
|
||||
if (track.externalFilename) {
|
||||
return `external:${track.externalFilename.toLowerCase()}`;
|
||||
}
|
||||
if (track.title.length > 0) {
|
||||
return `title:${track.title}`;
|
||||
}
|
||||
return `id:${track.id}`;
|
||||
}
|
||||
|
||||
function pickSecondarySubtitleTrackId(
|
||||
tracks: Array<Record<string, unknown>>,
|
||||
preferredLanguages: string[],
|
||||
): number | null {
|
||||
const normalizedLanguages = preferredLanguages
|
||||
.map((language) => language.trim().toLowerCase())
|
||||
.filter((language) => language.length > 0);
|
||||
if (normalizedLanguages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subtitleTracks = tracks
|
||||
.filter((track) => track.type === 'sub')
|
||||
.map(normalizeSubtitleTrackCandidate)
|
||||
.filter((track): track is SubtitleTrackCandidate => track !== null);
|
||||
|
||||
const dedupedTracks = new Map<string, SubtitleTrackCandidate>();
|
||||
for (const track of subtitleTracks) {
|
||||
const identity = getSubtitleTrackIdentity(track);
|
||||
const existing = dedupedTracks.get(identity);
|
||||
if (!existing || (track.selected && !existing.selected)) {
|
||||
dedupedTracks.set(identity, track);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueTracks = [...dedupedTracks.values()];
|
||||
|
||||
for (const language of normalizedLanguages) {
|
||||
const selectedMatch = uniqueTracks.find(
|
||||
(track) => track.selected && track.lang === language,
|
||||
);
|
||||
if (selectedMatch) {
|
||||
return selectedMatch.id;
|
||||
}
|
||||
|
||||
const match = uniqueTracks.find((track) => track.lang === language);
|
||||
if (match) {
|
||||
return match.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function splitMpvMessagesFromBuffer(
|
||||
buffer: string,
|
||||
onMessage?: MpvMessageParser,
|
||||
@@ -374,11 +283,15 @@ export async function dispatchMpvProtocolMessage(
|
||||
if (Array.isArray(tracks)) {
|
||||
const config = deps.getResolvedConfig();
|
||||
const languages = config.secondarySub?.secondarySubLanguages || [];
|
||||
const secondaryTrackId = pickSecondarySubtitleTrackId(tracks, languages);
|
||||
if (secondaryTrackId !== null) {
|
||||
deps.sendCommand({
|
||||
command: ['set_property', 'secondary-sid', secondaryTrackId],
|
||||
});
|
||||
const subTracks = tracks.filter((track) => track.type === 'sub');
|
||||
for (const language of languages) {
|
||||
const match = subTracks.find((track) => track.lang === language);
|
||||
if (match) {
|
||||
deps.sendCommand({
|
||||
command: ['set_property', 'secondary-sid', match.id],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) {
|
||||
|
||||
@@ -92,52 +92,6 @@ test('triggerSubsyncFromConfig opens manual picker in manual mode', async () =>
|
||||
assert.equal(inProgressState, false);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
|
||||
let payloadTrackCount = 0;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: () => {},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'path') return '/tmp/video.mkv';
|
||||
if (name === 'sid') return 1;
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
|
||||
{
|
||||
id: 2,
|
||||
type: 'sub',
|
||||
selected: true,
|
||||
external: true,
|
||||
lang: 'eng',
|
||||
'external-filename': '/tmp/ref.srt',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'sub',
|
||||
selected: false,
|
||||
external: true,
|
||||
lang: 'eng',
|
||||
'external-filename': '/tmp/ref.srt',
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
|
||||
const osd: string[] = [];
|
||||
await triggerSubsyncFromConfig(
|
||||
|
||||
@@ -76,31 +76,6 @@ function normalizeTrackIds(tracks: unknown[]): MpvTrack[] {
|
||||
});
|
||||
}
|
||||
|
||||
function getSourceTrackIdentity(track: MpvTrack): string {
|
||||
if (track.external && typeof track['external-filename'] === 'string' && track['external-filename'].length > 0) {
|
||||
return `external:${track['external-filename'].toLowerCase()}`;
|
||||
}
|
||||
if (typeof track.id === 'number') {
|
||||
return `id:${track.id}`;
|
||||
}
|
||||
if (typeof track.title === 'string' && track.title.length > 0) {
|
||||
return `title:${track.title.toLowerCase()}`;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function dedupeSourceTracks(tracks: MpvTrack[]): MpvTrack[] {
|
||||
const deduped = new Map<string, MpvTrack>();
|
||||
for (const track of tracks) {
|
||||
const identity = getSourceTrackIdentity(track);
|
||||
const existing = deduped.get(identity);
|
||||
if (!existing || (track.selected && !existing.selected)) {
|
||||
deduped.set(identity, track);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
|
||||
isSubsyncInProgress: () => boolean;
|
||||
setSubsyncInProgress: (inProgress: boolean) => void;
|
||||
@@ -148,13 +123,12 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
|
||||
const filename = track['external-filename'];
|
||||
return typeof filename === 'string' && filename.length > 0;
|
||||
});
|
||||
const uniqueSourceTracks = dedupeSourceTracks(sourceTracks);
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
primaryTrack,
|
||||
secondaryTrack,
|
||||
sourceTracks: uniqueSourceTracks,
|
||||
sourceTracks,
|
||||
audioStreamIndex: client.currentAudioStreamIndex,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
configureEarlyAppPaths,
|
||||
normalizeLaunchMpvExtraArgs,
|
||||
normalizeStartupArgv,
|
||||
normalizeLaunchMpvTargets,
|
||||
sanitizeHelpEnv,
|
||||
@@ -71,61 +70,6 @@ test('launch-mpv entry helpers detect and normalize targets', () => {
|
||||
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', 'C:\\a.mkv']), [
|
||||
'C:\\a.mkv',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvExtraArgs([
|
||||
'SubMiner.exe',
|
||||
'--launch-mpv',
|
||||
'--sub-file',
|
||||
'track.srt',
|
||||
'C:\\a.mkv',
|
||||
]),
|
||||
['--sub-file', 'track.srt'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvTargets([
|
||||
'SubMiner.exe',
|
||||
'--launch-mpv',
|
||||
'--sub-file',
|
||||
'track.srt',
|
||||
'C:\\a.mkv',
|
||||
]),
|
||||
['C:\\a.mkv'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvExtraArgs([
|
||||
'SubMiner.exe',
|
||||
'--launch-mpv',
|
||||
'--profile=subminer',
|
||||
'--pause=yes',
|
||||
'C:\\a.mkv',
|
||||
]),
|
||||
['--profile=subminer', '--pause=yes'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvExtraArgs([
|
||||
'SubMiner.exe',
|
||||
'--launch-mpv',
|
||||
'--input-ipc-server',
|
||||
'\\\\.\\pipe\\custom-subminer-socket',
|
||||
'--alang',
|
||||
'ja,jpn',
|
||||
'C:\\a.mkv',
|
||||
]),
|
||||
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket', '--alang', 'ja,jpn'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeLaunchMpvTargets([
|
||||
'SubMiner.exe',
|
||||
'--launch-mpv',
|
||||
'--input-ipc-server',
|
||||
'\\\\.\\pipe\\custom-subminer-socket',
|
||||
'--alang',
|
||||
'ja,jpn',
|
||||
'C:\\a.mkv',
|
||||
'C:\\b.mkv',
|
||||
]),
|
||||
['C:\\a.mkv', 'C:\\b.mkv'],
|
||||
);
|
||||
});
|
||||
|
||||
test('stats-daemon entry helper detects internal daemon commands', () => {
|
||||
|
||||
@@ -121,82 +121,7 @@ export function shouldHandleStatsDaemonCommandAtEntry(
|
||||
}
|
||||
|
||||
export function normalizeLaunchMpvTargets(argv: string[]): string[] {
|
||||
const launchMpvIndex = argv.findIndex((arg) => arg === '--launch-mpv');
|
||||
if (launchMpvIndex < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targets: string[] = [];
|
||||
|
||||
let parsingTargets = false;
|
||||
for (let i = launchMpvIndex + 1; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (!token) continue;
|
||||
|
||||
if (parsingTargets) {
|
||||
targets.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--') {
|
||||
parsingTargets = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('--')) {
|
||||
if (!token.includes('=') && i + 1 < argv.length) {
|
||||
const value = argv[i + 1];
|
||||
if (value && !value.startsWith('-')) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith('-')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsingTargets = true;
|
||||
targets.push(token);
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
export function normalizeLaunchMpvExtraArgs(argv: string[]): string[] {
|
||||
const launchMpvIndex = argv.findIndex((arg) => arg === '--launch-mpv');
|
||||
if (launchMpvIndex < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extraArgs: string[] = [];
|
||||
for (let i = launchMpvIndex + 1; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (!token) continue;
|
||||
if (token === '--') {
|
||||
break;
|
||||
}
|
||||
if (token.startsWith('--')) {
|
||||
extraArgs.push(token);
|
||||
if (!token.includes('=') && i + 1 < argv.length) {
|
||||
const value = argv[i + 1];
|
||||
if (value && !value.startsWith('-')) {
|
||||
extraArgs.push(value);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
extraArgs.push(token);
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith('-')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return extraArgs;
|
||||
return parseCliArgs(argv).launchMpvTargets;
|
||||
}
|
||||
|
||||
export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { app, dialog } from 'electron';
|
||||
import { printHelp } from './cli/help';
|
||||
import {
|
||||
configureEarlyAppPaths,
|
||||
normalizeLaunchMpvExtraArgs,
|
||||
normalizeLaunchMpvTargets,
|
||||
normalizeStartupArgv,
|
||||
sanitizeStartupEnv,
|
||||
@@ -17,7 +15,6 @@ import {
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin';
|
||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||
|
||||
@@ -35,19 +32,6 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
|
||||
const assets = resolvePackagedFirstRunPluginAssets({
|
||||
dirname: __dirname,
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
});
|
||||
if (!assets) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return path.join(assets.pluginDirSource, 'main.lua');
|
||||
}
|
||||
|
||||
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||
configureEarlyAppPaths(app);
|
||||
@@ -84,9 +68,6 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||
dialog.showErrorBox(title, content);
|
||||
},
|
||||
}),
|
||||
normalizeLaunchMpvExtraArgs(process.argv),
|
||||
process.execPath,
|
||||
resolveBundledWindowsMpvPluginEntrypoint(),
|
||||
);
|
||||
app.exit(result.ok ? 0 : 1);
|
||||
});
|
||||
|
||||
4884
src/main.ts
4884
src/main.ts
File diff suppressed because it is too large
Load Diff
112
src/main/anilist-runtime-coordinator.ts
Normal file
112
src/main/anilist-runtime-coordinator.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, shell } from 'electron';
|
||||
|
||||
import { DEFAULT_MIN_WATCH_RATIO } from '../shared/watch-threshold';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import {
|
||||
guessAnilistMediaInfo,
|
||||
updateAnilistPostWatchProgress,
|
||||
} from '../core/services/anilist/anilist-updater';
|
||||
import type { AnilistSetupWindowLike } from './anilist-runtime';
|
||||
import { createAnilistRuntime } from './anilist-runtime';
|
||||
import {
|
||||
isAllowedAnilistExternalUrl,
|
||||
isAllowedAnilistSetupNavigationUrl,
|
||||
} from './anilist-url-guard';
|
||||
|
||||
export interface AnilistRuntimeCoordinatorInput {
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
isTrackingEnabled: (config: ResolvedConfig) => boolean;
|
||||
tokenStore: Parameters<typeof createAnilistRuntime>[0]['tokenStore'];
|
||||
updateQueue: Parameters<typeof createAnilistRuntime>[0]['updateQueue'];
|
||||
appState: {
|
||||
currentMediaPath: string | null;
|
||||
currentMediaTitle: string | null;
|
||||
mpvClient: {
|
||||
currentTimePos?: number | null;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
anilistSetupWindow: BrowserWindow | null;
|
||||
};
|
||||
dictionarySupport: {
|
||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
||||
};
|
||||
actions: {
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
};
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, details?: unknown) => void;
|
||||
error: (message: string, error?: unknown) => void;
|
||||
debug: (message: string, details?: unknown) => void;
|
||||
};
|
||||
constants: {
|
||||
authorizeUrl: string;
|
||||
clientId: string;
|
||||
responseType: string;
|
||||
redirectUri: string;
|
||||
developerSettingsUrl: string;
|
||||
durationRetryIntervalMs: number;
|
||||
minWatchSeconds: number;
|
||||
maxAttemptedUpdateKeys: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function createAnilistRuntimeCoordinator(input: AnilistRuntimeCoordinatorInput) {
|
||||
return createAnilistRuntime<ResolvedConfig, AnilistSetupWindowLike>({
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
isTrackingEnabled: (config) => input.isTrackingEnabled(config),
|
||||
tokenStore: input.tokenStore,
|
||||
updateQueue: input.updateQueue,
|
||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
||||
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
|
||||
getWatchedSeconds: () => input.appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||
hasMpvClient: () => Boolean(input.appState.mpvClient),
|
||||
requestMpvDuration: async () => input.appState.mpvClient?.requestProperty('duration'),
|
||||
resolveMediaPathForJimaku: (currentMediaPath) =>
|
||||
input.dictionarySupport.resolveMediaPathForJimaku(currentMediaPath),
|
||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||
updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
createBrowserWindow: (options) => {
|
||||
const window = new BrowserWindow(options);
|
||||
input.appState.anilistSetupWindow = window;
|
||||
window.on('closed', () => {
|
||||
if (input.appState.anilistSetupWindow === window) {
|
||||
input.appState.anilistSetupWindow = null;
|
||||
}
|
||||
});
|
||||
return window as unknown as AnilistSetupWindowLike;
|
||||
},
|
||||
authorizeUrl: input.constants.authorizeUrl,
|
||||
clientId: input.constants.clientId,
|
||||
responseType: input.constants.responseType,
|
||||
redirectUri: input.constants.redirectUri,
|
||||
developerSettingsUrl: input.constants.developerSettingsUrl,
|
||||
isAllowedExternalUrl: (url) => isAllowedAnilistExternalUrl(url),
|
||||
isAllowedNavigationUrl: (url) => isAllowedAnilistSetupNavigationUrl(url),
|
||||
openExternal: (url) => shell.openExternal(url),
|
||||
showMpvOsd: (message) => input.actions.showMpvOsd(message),
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.actions.showDesktopNotification(title, options),
|
||||
logInfo: (message) => input.logger.info(message),
|
||||
logWarn: (message, details) => input.logger.warn(message, details),
|
||||
logError: (message, error) => input.logger.error(message, error),
|
||||
logDebug: (message, details) => input.logger.debug(message, details),
|
||||
isDefaultApp: () => Boolean(process.defaultApp),
|
||||
getArgv: () => process.argv,
|
||||
execPath: process.execPath,
|
||||
resolvePath: (value) => path.resolve(value),
|
||||
setAsDefaultProtocolClient: (scheme, appPath, args) =>
|
||||
appPath
|
||||
? app.setAsDefaultProtocolClient(scheme, appPath, args)
|
||||
: app.setAsDefaultProtocolClient(scheme),
|
||||
now: () => Date.now(),
|
||||
durationRetryIntervalMs: input.constants.durationRetryIntervalMs,
|
||||
minWatchSeconds: input.constants.minWatchSeconds,
|
||||
minWatchRatio: DEFAULT_MIN_WATCH_RATIO,
|
||||
maxAttemptedUpdateKeys: input.constants.maxAttemptedUpdateKeys,
|
||||
});
|
||||
}
|
||||
192
src/main/anilist-runtime.test.ts
Normal file
192
src/main/anilist-runtime.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createAnilistRuntime } from './anilist-runtime';
|
||||
|
||||
function createSetupWindow() {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
let destroyed = false;
|
||||
return {
|
||||
window: {
|
||||
focus: () => {},
|
||||
close: () => {
|
||||
destroyed = true;
|
||||
for (const handler of handlers.get('closed') ?? []) {
|
||||
handler();
|
||||
}
|
||||
},
|
||||
isDestroyed: () => destroyed,
|
||||
on: (event: 'closed', handler: () => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
||||
},
|
||||
loadURL: async () => {},
|
||||
webContents: {
|
||||
setWindowOpenHandler: () => ({ action: 'deny' as const }),
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
||||
},
|
||||
getURL: () => 'about:blank',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntime(overrides: Partial<Parameters<typeof createAnilistRuntime>[0]> = {}) {
|
||||
const savedTokens: string[] = [];
|
||||
const queueCalls: string[] = [];
|
||||
const notifications: string[] = [];
|
||||
const state = {
|
||||
config: {
|
||||
anilist: {
|
||||
enabled: true,
|
||||
accessToken: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
const setup = createSetupWindow();
|
||||
|
||||
const runtime = createAnilistRuntime({
|
||||
getResolvedConfig: () => state.config,
|
||||
isTrackingEnabled: (config) => config.anilist.enabled === true,
|
||||
tokenStore: {
|
||||
saveToken: (token) => {
|
||||
savedTokens.push(token);
|
||||
},
|
||||
loadToken: () => null,
|
||||
clearToken: () => {
|
||||
savedTokens.push('cleared');
|
||||
},
|
||||
},
|
||||
updateQueue: {
|
||||
enqueue: (key, title, episode) => {
|
||||
queueCalls.push(`enqueue:${key}:${title}:${episode}`);
|
||||
},
|
||||
nextReady: () => ({
|
||||
key: 'retry-1',
|
||||
title: 'Demo',
|
||||
episode: 2,
|
||||
createdAt: 1,
|
||||
attemptCount: 0,
|
||||
nextAttemptAt: 0,
|
||||
lastError: null,
|
||||
}),
|
||||
markSuccess: (key) => {
|
||||
queueCalls.push(`success:${key}`);
|
||||
},
|
||||
markFailure: (key, message) => {
|
||||
queueCalls.push(`failure:${key}:${message}`);
|
||||
},
|
||||
getSnapshot: () => ({
|
||||
pending: 3,
|
||||
ready: 1,
|
||||
deadLetter: 2,
|
||||
}),
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/demo.mkv',
|
||||
getCurrentMediaTitle: () => 'Demo',
|
||||
getWatchedSeconds: () => 0,
|
||||
hasMpvClient: () => false,
|
||||
requestMpvDuration: async () => 120,
|
||||
resolveMediaPathForJimaku: (value) => value,
|
||||
guessAnilistMediaInfo: async () => null,
|
||||
updateAnilistPostWatchProgress: async () => ({
|
||||
status: 'updated',
|
||||
message: 'updated ok',
|
||||
}),
|
||||
createBrowserWindow: () => setup.window,
|
||||
authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize',
|
||||
clientId: '36084',
|
||||
responseType: 'token',
|
||||
redirectUri: 'https://anilist.subminer.moe/',
|
||||
developerSettingsUrl: 'https://anilist.co/settings/developer',
|
||||
isAllowedExternalUrl: () => true,
|
||||
isAllowedNavigationUrl: () => true,
|
||||
openExternal: async () => {},
|
||||
showMpvOsd: (message) => {
|
||||
notifications.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (_title, options) => {
|
||||
notifications.push(`notify:${options.body}`);
|
||||
},
|
||||
logInfo: (message) => {
|
||||
notifications.push(`info:${message}`);
|
||||
},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
logDebug: () => {},
|
||||
isDefaultApp: () => false,
|
||||
getArgv: () => [],
|
||||
execPath: process.execPath,
|
||||
resolvePath: (value) => value,
|
||||
setAsDefaultProtocolClient: () => true,
|
||||
now: () => 1234,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return {
|
||||
runtime,
|
||||
state,
|
||||
savedTokens,
|
||||
queueCalls,
|
||||
notifications,
|
||||
};
|
||||
}
|
||||
|
||||
test('anilist runtime saves setup token and updates resolved state', () => {
|
||||
const harness = createRuntime();
|
||||
|
||||
const consumed = harness.runtime.consumeAnilistSetupTokenFromUrl(
|
||||
'subminer://anilist-setup?access_token=token-123',
|
||||
);
|
||||
|
||||
assert.equal(consumed, true);
|
||||
assert.deepEqual(harness.savedTokens, ['token-123']);
|
||||
assert.equal(harness.runtime.getStatusSnapshot().tokenStatus, 'resolved');
|
||||
assert.equal(harness.runtime.getStatusSnapshot().tokenSource, 'stored');
|
||||
assert.equal(harness.runtime.getStatusSnapshot().tokenMessage, 'saved token from AniList login');
|
||||
assert.ok(harness.notifications.includes('notify:AniList login success'));
|
||||
});
|
||||
|
||||
test('anilist runtime bypasses refresh when tracking disabled', async () => {
|
||||
const harness = createRuntime();
|
||||
harness.state.config = {
|
||||
anilist: {
|
||||
enabled: false,
|
||||
accessToken: '',
|
||||
},
|
||||
};
|
||||
|
||||
const token = await harness.runtime.refreshAnilistClientSecretStateIfEnabled({
|
||||
force: true,
|
||||
});
|
||||
|
||||
assert.equal(token, null);
|
||||
assert.equal(harness.runtime.getStatusSnapshot().tokenStatus, 'not_checked');
|
||||
assert.equal(harness.runtime.getStatusSnapshot().tokenSource, 'none');
|
||||
});
|
||||
|
||||
test('anilist runtime refreshes queue snapshot and retry state after processing', async () => {
|
||||
const harness = createRuntime({
|
||||
tokenStore: {
|
||||
saveToken: () => {},
|
||||
loadToken: () => 'stored-token',
|
||||
clearToken: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
harness.runtime.refreshRetryQueueState();
|
||||
assert.deepEqual(harness.runtime.getQueueStatusSnapshot(), {
|
||||
pending: 3,
|
||||
ready: 1,
|
||||
deadLetter: 2,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const result = await harness.runtime.processNextAnilistRetryUpdate();
|
||||
|
||||
assert.deepEqual(result, { ok: true, message: 'updated ok' });
|
||||
assert.ok(harness.queueCalls.includes('success:retry-1'));
|
||||
assert.equal(harness.runtime.getQueueStatusSnapshot().lastAttemptAt, 1234);
|
||||
assert.equal(harness.runtime.getQueueStatusSnapshot().lastError, null);
|
||||
});
|
||||
495
src/main/anilist-runtime.ts
Normal file
495
src/main/anilist-runtime.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import {
|
||||
createInitialAnilistMediaGuessRuntimeState,
|
||||
createInitialAnilistRetryQueueState,
|
||||
createInitialAnilistSecretResolutionState,
|
||||
createInitialAnilistUpdateInFlightState,
|
||||
type AnilistMediaGuessRuntimeState,
|
||||
type AnilistRetryQueueState,
|
||||
type AnilistSecretResolutionState,
|
||||
} from './state';
|
||||
import { createAnilistStateRuntime } from './runtime/anilist-state';
|
||||
import { composeAnilistSetupHandlers } from './runtime/composers/anilist-setup-composer';
|
||||
import { composeAnilistTrackingHandlers } from './runtime/composers/anilist-tracking-composer';
|
||||
import {
|
||||
buildAnilistSetupUrl,
|
||||
consumeAnilistSetupCallbackUrl,
|
||||
loadAnilistManualTokenEntry,
|
||||
openAnilistSetupInBrowser,
|
||||
} from './runtime/anilist-setup';
|
||||
import {
|
||||
createMaybeFocusExistingAnilistSetupWindowHandler,
|
||||
createOpenAnilistSetupWindowHandler,
|
||||
} from './runtime/anilist-setup-window';
|
||||
import {
|
||||
buildAnilistAttemptKey,
|
||||
rememberAnilistAttemptedUpdateKey,
|
||||
} from './runtime/anilist-post-watch';
|
||||
import { createCreateAnilistSetupWindowHandler } from './runtime/setup-window-factory';
|
||||
import type {
|
||||
AnilistMediaGuess,
|
||||
AnilistPostWatchUpdateResult,
|
||||
} from '../core/services/anilist/anilist-updater';
|
||||
import type { AnilistUpdateQueue } from '../core/services/anilist/anilist-update-queue';
|
||||
|
||||
export interface AnilistSetupWindowLike {
|
||||
focus: () => void;
|
||||
close: () => void;
|
||||
isDestroyed: () => boolean;
|
||||
on: (event: 'closed', handler: () => void) => void;
|
||||
loadURL: (url: string) => Promise<void> | void;
|
||||
webContents: {
|
||||
setWindowOpenHandler: (handler: (details: { url: string }) => { action: 'deny' }) => void;
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => void;
|
||||
getURL: () => string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnilistTokenStoreLike {
|
||||
saveToken: (token: string) => void;
|
||||
loadToken: () => string | null | undefined;
|
||||
clearToken: () => void;
|
||||
}
|
||||
|
||||
export interface AnilistRuntimeInput<
|
||||
TConfig extends { anilist: { accessToken: string; enabled?: boolean } } = {
|
||||
anilist: { accessToken: string; enabled?: boolean };
|
||||
},
|
||||
TWindow extends AnilistSetupWindowLike = AnilistSetupWindowLike,
|
||||
> {
|
||||
getResolvedConfig: () => TConfig;
|
||||
isTrackingEnabled: (config: TConfig) => boolean;
|
||||
tokenStore: AnilistTokenStoreLike;
|
||||
updateQueue: AnilistUpdateQueue;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
getWatchedSeconds: () => number;
|
||||
hasMpvClient: () => boolean;
|
||||
requestMpvDuration: () => Promise<unknown>;
|
||||
resolveMediaPathForJimaku: (currentMediaPath: string | null) => string | null;
|
||||
guessAnilistMediaInfo: (
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
) => Promise<AnilistMediaGuess | null>;
|
||||
updateAnilistPostWatchProgress: (
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
) => Promise<AnilistPostWatchUpdateResult>;
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
||||
authorizeUrl: string;
|
||||
clientId: string;
|
||||
responseType: string;
|
||||
redirectUri: string;
|
||||
developerSettingsUrl: string;
|
||||
isAllowedExternalUrl: (url: string) => boolean;
|
||||
isAllowedNavigationUrl: (url: string) => boolean;
|
||||
openExternal: (url: string) => Promise<unknown> | void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
logDebug: (message: string, details?: unknown) => void;
|
||||
isDefaultApp: () => boolean;
|
||||
getArgv: () => string[];
|
||||
execPath: string;
|
||||
resolvePath: (value: string) => string;
|
||||
setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) => boolean;
|
||||
now?: () => number;
|
||||
durationRetryIntervalMs?: number;
|
||||
minWatchSeconds?: number;
|
||||
minWatchRatio?: number;
|
||||
maxAttemptedUpdateKeys?: number;
|
||||
}
|
||||
|
||||
export interface AnilistRuntime {
|
||||
notifyAnilistSetup: (message: string) => void;
|
||||
consumeAnilistSetupTokenFromUrl: (rawUrl: string) => boolean;
|
||||
handleAnilistSetupProtocolUrl: (rawUrl: string) => boolean;
|
||||
registerSubminerProtocolClient: () => void;
|
||||
openAnilistSetupWindow: () => void;
|
||||
refreshAnilistClientSecretState: (options?: {
|
||||
force?: boolean;
|
||||
allowSetupPrompt?: boolean;
|
||||
}) => Promise<string | null>;
|
||||
refreshAnilistClientSecretStateIfEnabled: (options?: {
|
||||
force?: boolean;
|
||||
allowSetupPrompt?: boolean;
|
||||
}) => Promise<string | null>;
|
||||
getCurrentAnilistMediaKey: () => string | null;
|
||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||
getAnilistMediaGuessRuntimeState: () => AnilistMediaGuessRuntimeState;
|
||||
setAnilistMediaGuessRuntimeState: (state: AnilistMediaGuessRuntimeState) => void;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => Promise<number | null>;
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => Promise<AnilistMediaGuess | null>;
|
||||
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
setClientSecretState: (partial: Partial<AnilistSecretResolutionState>) => void;
|
||||
refreshRetryQueueState: () => void;
|
||||
getStatusSnapshot: () => {
|
||||
tokenStatus: AnilistSecretResolutionState['status'];
|
||||
tokenSource: AnilistSecretResolutionState['source'];
|
||||
tokenMessage: string | null;
|
||||
tokenResolvedAt: number | null;
|
||||
tokenErrorAt: number | null;
|
||||
queuePending: number;
|
||||
queueReady: number;
|
||||
queueDeadLetter: number;
|
||||
queueLastAttemptAt: number | null;
|
||||
queueLastError: string | null;
|
||||
};
|
||||
getQueueStatusSnapshot: () => AnilistRetryQueueState;
|
||||
clearTokenState: () => void;
|
||||
getSetupWindow: () => AnilistSetupWindowLike | null;
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION_RETRY_INTERVAL_MS = 15_000;
|
||||
const DEFAULT_MIN_WATCH_SECONDS = 10 * 60;
|
||||
const DEFAULT_MIN_WATCH_RATIO = 0.85;
|
||||
const DEFAULT_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
|
||||
|
||||
export function createAnilistRuntime<
|
||||
TConfig extends { anilist: { accessToken: string; enabled?: boolean } },
|
||||
TWindow extends AnilistSetupWindowLike,
|
||||
>(input: AnilistRuntimeInput<TConfig, TWindow>): AnilistRuntime {
|
||||
const now = input.now ?? Date.now;
|
||||
|
||||
let setupWindow: TWindow | null = null;
|
||||
let setupPageOpened = false;
|
||||
let cachedAccessToken: string | null = null;
|
||||
let clientSecretState = createInitialAnilistSecretResolutionState();
|
||||
let retryQueueState = createInitialAnilistRetryQueueState();
|
||||
let mediaGuessRuntimeState = createInitialAnilistMediaGuessRuntimeState();
|
||||
let updateInFlightState = createInitialAnilistUpdateInFlightState();
|
||||
const attemptedUpdateKeys = new Set<string>();
|
||||
|
||||
const stateRuntime = createAnilistStateRuntime({
|
||||
getClientSecretState: () => clientSecretState,
|
||||
setClientSecretState: (next) => {
|
||||
clientSecretState = next;
|
||||
},
|
||||
getRetryQueueState: () => retryQueueState,
|
||||
setRetryQueueState: (next) => {
|
||||
retryQueueState = next;
|
||||
},
|
||||
getUpdateQueueSnapshot: () => input.updateQueue.getSnapshot(),
|
||||
clearStoredToken: () => input.tokenStore.clearToken(),
|
||||
clearCachedAccessToken: () => {
|
||||
cachedAccessToken = null;
|
||||
},
|
||||
});
|
||||
|
||||
const rememberAttemptedUpdate = (key: string): void => {
|
||||
rememberAnilistAttemptedUpdateKey(
|
||||
attemptedUpdateKeys,
|
||||
key,
|
||||
input.maxAttemptedUpdateKeys ?? DEFAULT_MAX_ATTEMPTED_UPDATE_KEYS,
|
||||
);
|
||||
};
|
||||
|
||||
const maybeFocusExistingSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({
|
||||
getSetupWindow: () => setupWindow,
|
||||
});
|
||||
const createSetupWindow = createCreateAnilistSetupWindowHandler({
|
||||
createBrowserWindow: (options) => input.createBrowserWindow(options),
|
||||
});
|
||||
|
||||
const {
|
||||
notifyAnilistSetup,
|
||||
consumeAnilistSetupTokenFromUrl,
|
||||
handleAnilistSetupProtocolUrl,
|
||||
registerSubminerProtocolClient,
|
||||
} = composeAnilistSetupHandlers({
|
||||
notifyDeps: {
|
||||
hasMpvClient: () => input.hasMpvClient(),
|
||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
||||
showDesktopNotification: (title, options) => input.showDesktopNotification(title, options),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
},
|
||||
consumeTokenDeps: {
|
||||
consumeAnilistSetupCallbackUrl,
|
||||
saveToken: (token) => input.tokenStore.saveToken(token),
|
||||
setCachedToken: (token) => {
|
||||
cachedAccessToken = token;
|
||||
},
|
||||
setResolvedState: (resolvedAt) => {
|
||||
stateRuntime.setClientSecretState({
|
||||
status: 'resolved',
|
||||
source: 'stored',
|
||||
message: 'saved token from AniList login',
|
||||
resolvedAt,
|
||||
errorAt: null,
|
||||
});
|
||||
},
|
||||
setSetupPageOpened: (opened) => {
|
||||
setupPageOpened = opened;
|
||||
},
|
||||
onSuccess: () => {
|
||||
notifyAnilistSetup('AniList login success');
|
||||
},
|
||||
closeWindow: () => {
|
||||
if (setupWindow && !setupWindow.isDestroyed()) {
|
||||
setupWindow.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
handleProtocolDeps: {
|
||||
consumeAnilistSetupTokenFromUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl),
|
||||
logWarn: (message, details) => input.logWarn(message, details),
|
||||
},
|
||||
registerProtocolClientDeps: {
|
||||
isDefaultApp: () => input.isDefaultApp(),
|
||||
getArgv: () => input.getArgv(),
|
||||
execPath: input.execPath,
|
||||
resolvePath: (value) => input.resolvePath(value),
|
||||
setAsDefaultProtocolClient: (scheme, targetPath, args) =>
|
||||
input.setAsDefaultProtocolClient(scheme, targetPath, args),
|
||||
logDebug: (message, details) => input.logDebug(message, details),
|
||||
},
|
||||
});
|
||||
|
||||
const openAnilistSetupWindow = createOpenAnilistSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => maybeFocusExistingSetupWindow(),
|
||||
createSetupWindow: () => createSetupWindow(),
|
||||
buildAuthorizeUrl: () =>
|
||||
buildAnilistSetupUrl({
|
||||
authorizeUrl: input.authorizeUrl,
|
||||
clientId: input.clientId,
|
||||
responseType: input.responseType,
|
||||
redirectUri: input.redirectUri,
|
||||
}),
|
||||
consumeCallbackUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl),
|
||||
openSetupInBrowser: (authorizeUrl) =>
|
||||
openAnilistSetupInBrowser({
|
||||
authorizeUrl,
|
||||
openExternal: async (url) => {
|
||||
await input.openExternal(url);
|
||||
},
|
||||
logError: (message, error) => input.logError(message, error),
|
||||
}),
|
||||
loadManualTokenEntry: (window, authorizeUrl) =>
|
||||
loadAnilistManualTokenEntry({
|
||||
setupWindow: window as never,
|
||||
authorizeUrl,
|
||||
developerSettingsUrl: input.developerSettingsUrl,
|
||||
logWarn: (message, details) => input.logWarn(message, details),
|
||||
}),
|
||||
redirectUri: input.redirectUri,
|
||||
developerSettingsUrl: input.developerSettingsUrl,
|
||||
isAllowedExternalUrl: (url) => input.isAllowedExternalUrl(url),
|
||||
isAllowedNavigationUrl: (url) => input.isAllowedNavigationUrl(url),
|
||||
logWarn: (message, details) => input.logWarn(message, details),
|
||||
logError: (message, details) => input.logError(message, details),
|
||||
clearSetupWindow: () => {
|
||||
setupWindow = null;
|
||||
},
|
||||
setSetupPageOpened: (opened) => {
|
||||
setupPageOpened = opened;
|
||||
},
|
||||
setSetupWindow: (window) => {
|
||||
setupWindow = window;
|
||||
},
|
||||
openExternal: (url) => {
|
||||
void input.openExternal(url);
|
||||
},
|
||||
});
|
||||
|
||||
const trackingRuntime = composeAnilistTrackingHandlers({
|
||||
refreshClientSecretMainDeps: {
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
isAnilistTrackingEnabled: (config) => input.isTrackingEnabled(config as TConfig),
|
||||
getCachedAccessToken: () => cachedAccessToken,
|
||||
setCachedAccessToken: (token) => {
|
||||
cachedAccessToken = token;
|
||||
},
|
||||
saveStoredToken: (token) => {
|
||||
input.tokenStore.saveToken(token);
|
||||
},
|
||||
loadStoredToken: () => input.tokenStore.loadToken(),
|
||||
setClientSecretState: (state) => {
|
||||
clientSecretState = state;
|
||||
},
|
||||
getAnilistSetupPageOpened: () => setupPageOpened,
|
||||
setAnilistSetupPageOpened: (opened) => {
|
||||
setupPageOpened = opened;
|
||||
},
|
||||
openAnilistSetupWindow: () => {
|
||||
openAnilistSetupWindow();
|
||||
},
|
||||
now,
|
||||
},
|
||||
getCurrentMediaKeyMainDeps: {
|
||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
||||
},
|
||||
resetMediaTrackingMainDeps: {
|
||||
setMediaKey: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaKey: value };
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaDurationSec: value };
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value };
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value };
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, lastDurationProbeAtMs: value };
|
||||
},
|
||||
},
|
||||
getMediaGuessRuntimeStateMainDeps: {
|
||||
getMediaKey: () => mediaGuessRuntimeState.mediaKey,
|
||||
getMediaDurationSec: () => mediaGuessRuntimeState.mediaDurationSec,
|
||||
getMediaGuess: () => mediaGuessRuntimeState.mediaGuess,
|
||||
getMediaGuessPromise: () => mediaGuessRuntimeState.mediaGuessPromise,
|
||||
getLastDurationProbeAtMs: () => mediaGuessRuntimeState.lastDurationProbeAtMs,
|
||||
},
|
||||
setMediaGuessRuntimeStateMainDeps: {
|
||||
setMediaKey: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaKey: value };
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaDurationSec: value };
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value };
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value };
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, lastDurationProbeAtMs: value };
|
||||
},
|
||||
},
|
||||
resetMediaGuessStateMainDeps: {
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value };
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value };
|
||||
},
|
||||
},
|
||||
maybeProbeDurationMainDeps: {
|
||||
getState: () => mediaGuessRuntimeState,
|
||||
setState: (state) => {
|
||||
mediaGuessRuntimeState = state;
|
||||
},
|
||||
durationRetryIntervalMs: input.durationRetryIntervalMs ?? DEFAULT_DURATION_RETRY_INTERVAL_MS,
|
||||
now,
|
||||
requestMpvDuration: () => input.requestMpvDuration(),
|
||||
logWarn: (message, error) => input.logWarn(message, error),
|
||||
},
|
||||
ensureMediaGuessMainDeps: {
|
||||
getState: () => mediaGuessRuntimeState,
|
||||
setState: (state) => {
|
||||
mediaGuessRuntimeState = state;
|
||||
},
|
||||
resolveMediaPathForJimaku: (currentMediaPath) =>
|
||||
input.resolveMediaPathForJimaku(currentMediaPath),
|
||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
||||
input.guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||
},
|
||||
processNextRetryUpdateMainDeps: {
|
||||
nextReady: () => input.updateQueue.nextReady(),
|
||||
refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(),
|
||||
setLastAttemptAt: (value) => {
|
||||
retryQueueState = { ...retryQueueState, lastAttemptAt: value };
|
||||
},
|
||||
setLastError: (value) => {
|
||||
retryQueueState = { ...retryQueueState, lastError: value };
|
||||
},
|
||||
refreshAnilistClientSecretState: () => trackingRuntime.refreshAnilistClientSecretState(),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||
input.updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
markSuccess: (key) => {
|
||||
input.updateQueue.markSuccess(key);
|
||||
},
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
rememberAttemptedUpdate(key);
|
||||
},
|
||||
markFailure: (key, message) => {
|
||||
input.updateQueue.markFailure(key, message);
|
||||
},
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
now,
|
||||
},
|
||||
maybeRunPostWatchUpdateMainDeps: {
|
||||
getInFlight: () => updateInFlightState.inFlight,
|
||||
setInFlight: (value) => {
|
||||
updateInFlightState = { ...updateInFlightState, inFlight: value };
|
||||
},
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
isAnilistTrackingEnabled: (config) => input.isTrackingEnabled(config as TConfig),
|
||||
getCurrentMediaKey: () => trackingRuntime.getCurrentAnilistMediaKey(),
|
||||
hasMpvClient: () => input.hasMpvClient(),
|
||||
getTrackedMediaKey: () => mediaGuessRuntimeState.mediaKey,
|
||||
resetTrackedMedia: (mediaKey) => {
|
||||
trackingRuntime.resetAnilistMediaTracking(mediaKey);
|
||||
},
|
||||
getWatchedSeconds: () => input.getWatchedSeconds(),
|
||||
maybeProbeAnilistDuration: (mediaKey) => trackingRuntime.maybeProbeAnilistDuration(mediaKey),
|
||||
ensureAnilistMediaGuess: (mediaKey) => trackingRuntime.ensureAnilistMediaGuess(mediaKey),
|
||||
hasAttemptedUpdateKey: (key) => attemptedUpdateKeys.has(key),
|
||||
processNextAnilistRetryUpdate: () => trackingRuntime.processNextAnilistRetryUpdate(),
|
||||
refreshAnilistClientSecretState: () => trackingRuntime.refreshAnilistClientSecretState(),
|
||||
enqueueRetry: (key, title, episode) => {
|
||||
input.updateQueue.enqueue(key, title, episode);
|
||||
},
|
||||
markRetryFailure: (key, message) => {
|
||||
input.updateQueue.markFailure(key, message);
|
||||
},
|
||||
markRetrySuccess: (key) => {
|
||||
input.updateQueue.markSuccess(key);
|
||||
},
|
||||
refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||
input.updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
rememberAttemptedUpdate(key);
|
||||
},
|
||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logWarn: (message) => input.logWarn(message),
|
||||
minWatchSeconds: input.minWatchSeconds ?? DEFAULT_MIN_WATCH_SECONDS,
|
||||
minWatchRatio: input.minWatchRatio ?? DEFAULT_MIN_WATCH_RATIO,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
notifyAnilistSetup,
|
||||
consumeAnilistSetupTokenFromUrl,
|
||||
handleAnilistSetupProtocolUrl,
|
||||
registerSubminerProtocolClient,
|
||||
openAnilistSetupWindow,
|
||||
refreshAnilistClientSecretState: (options) =>
|
||||
trackingRuntime.refreshAnilistClientSecretState(options),
|
||||
refreshAnilistClientSecretStateIfEnabled: (options) => {
|
||||
if (!input.isTrackingEnabled(input.getResolvedConfig())) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return trackingRuntime.refreshAnilistClientSecretState(options);
|
||||
},
|
||||
getCurrentAnilistMediaKey: () => trackingRuntime.getCurrentAnilistMediaKey(),
|
||||
resetAnilistMediaTracking: (mediaKey) => trackingRuntime.resetAnilistMediaTracking(mediaKey),
|
||||
getAnilistMediaGuessRuntimeState: () => trackingRuntime.getAnilistMediaGuessRuntimeState(),
|
||||
setAnilistMediaGuessRuntimeState: (state) =>
|
||||
trackingRuntime.setAnilistMediaGuessRuntimeState(state),
|
||||
resetAnilistMediaGuessState: () => trackingRuntime.resetAnilistMediaGuessState(),
|
||||
maybeProbeAnilistDuration: (mediaKey) => trackingRuntime.maybeProbeAnilistDuration(mediaKey),
|
||||
ensureAnilistMediaGuess: (mediaKey) => trackingRuntime.ensureAnilistMediaGuess(mediaKey),
|
||||
processNextAnilistRetryUpdate: () => trackingRuntime.processNextAnilistRetryUpdate(),
|
||||
maybeRunAnilistPostWatchUpdate: () => trackingRuntime.maybeRunAnilistPostWatchUpdate(),
|
||||
setClientSecretState: (partial) => stateRuntime.setClientSecretState(partial),
|
||||
refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(),
|
||||
getStatusSnapshot: () => stateRuntime.getStatusSnapshot(),
|
||||
getQueueStatusSnapshot: () => stateRuntime.getQueueStatusSnapshot(),
|
||||
clearTokenState: () => stateRuntime.clearTokenState(),
|
||||
getSetupWindow: () => setupWindow,
|
||||
};
|
||||
}
|
||||
|
||||
export { buildAnilistAttemptKey };
|
||||
270
src/main/app-ready-runtime.test.ts
Normal file
270
src/main/app-ready-runtime.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createAppReadyRuntime } from './app-ready-runtime';
|
||||
|
||||
test('app ready runtime shares overlay startup prereqs with youtube runtime init path', async () => {
|
||||
let subtitlePosition: unknown | null = null;
|
||||
let keybindingsCount = 0;
|
||||
let hasMpvClient = false;
|
||||
let runtimeOptionsManager: unknown | null = null;
|
||||
let subtitleTimingTracker: unknown | null = null;
|
||||
let overlayRuntimeInitialized = false;
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createAppReadyRuntime({
|
||||
reload: {
|
||||
reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }),
|
||||
logInfo: () => {},
|
||||
logWarning: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
startConfigHotReload: () => {},
|
||||
refreshAnilistClientSecretState: async () => undefined,
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {},
|
||||
},
|
||||
},
|
||||
criticalConfig: {
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {
|
||||
throw new Error('quit');
|
||||
},
|
||||
},
|
||||
},
|
||||
immersion: {
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
immersionTracking: {
|
||||
enabled: true,
|
||||
batchSize: 1,
|
||||
flushIntervalMs: 1,
|
||||
queueCap: 1,
|
||||
payloadCapBytes: 1,
|
||||
maintenanceIntervalMs: 1,
|
||||
retention: {
|
||||
eventsDays: 1,
|
||||
telemetryDays: 1,
|
||||
sessionsDays: 1,
|
||||
dailyRollupsDays: 1,
|
||||
monthlyRollupsDays: 1,
|
||||
vacuumIntervalDays: 1,
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
|
||||
createTrackerService: () => ({}) as never,
|
||||
setTracker: () => {},
|
||||
getMpvClient: () => null,
|
||||
seedTrackerFromCurrentMedia: () => {},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
runner: {
|
||||
ensureDefaultConfigBootstrap: () => {},
|
||||
getSubtitlePosition: () => subtitlePosition,
|
||||
loadSubtitlePosition: () => {
|
||||
subtitlePosition = { mode: 'bottom' };
|
||||
calls.push('loadSubtitlePosition');
|
||||
},
|
||||
getKeybindingsCount: () => keybindingsCount,
|
||||
resolveKeybindings: () => {
|
||||
keybindingsCount = 3;
|
||||
calls.push('resolveKeybindings');
|
||||
},
|
||||
hasMpvClient: () => hasMpvClient,
|
||||
createMpvClient: () => {
|
||||
hasMpvClient = true;
|
||||
calls.push('createMpvClient');
|
||||
},
|
||||
getRuntimeOptionsManager: () => runtimeOptionsManager,
|
||||
initRuntimeOptionsManager: () => {
|
||||
runtimeOptionsManager = {};
|
||||
calls.push('initRuntimeOptionsManager');
|
||||
},
|
||||
getSubtitleTimingTracker: () => subtitleTimingTracker,
|
||||
createSubtitleTimingTracker: () => {
|
||||
subtitleTimingTracker = {};
|
||||
calls.push('createSubtitleTimingTracker');
|
||||
},
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
ankiConnect: {
|
||||
enabled: false,
|
||||
},
|
||||
}) as never,
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {},
|
||||
setSecondarySubMode: () => {},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 5174,
|
||||
defaultAnnotationWebsocketPort: 6678,
|
||||
defaultTexthookerPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
loadYomitanExtension: async () => {},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('ensureYomitanExtensionLoaded');
|
||||
},
|
||||
handleFirstRunSetup: async () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
initializeOverlayRuntime: () => {
|
||||
overlayRuntimeInitialized = true;
|
||||
calls.push('initializeOverlayRuntime');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
|
||||
},
|
||||
handleInitialArgs: () => {},
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
|
||||
});
|
||||
|
||||
runtime.ensureOverlayStartupPrereqs();
|
||||
runtime.ensureOverlayStartupPrereqs();
|
||||
await runtime.ensureYoutubePlaybackRuntimeReady();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'loadSubtitlePosition',
|
||||
'resolveKeybindings',
|
||||
'createMpvClient',
|
||||
'initRuntimeOptionsManager',
|
||||
'createSubtitleTimingTracker',
|
||||
'ensureYomitanExtensionLoaded',
|
||||
'initializeOverlayRuntime',
|
||||
]);
|
||||
});
|
||||
|
||||
test('app ready runtime reuses existing overlay runtime during youtube readiness', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createAppReadyRuntime({
|
||||
reload: {
|
||||
reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }),
|
||||
logInfo: () => {},
|
||||
logWarning: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
startConfigHotReload: () => {},
|
||||
refreshAnilistClientSecretState: async () => undefined,
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {},
|
||||
},
|
||||
},
|
||||
criticalConfig: {
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {
|
||||
throw new Error('quit');
|
||||
},
|
||||
},
|
||||
},
|
||||
immersion: {
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
immersionTracking: {
|
||||
enabled: true,
|
||||
batchSize: 1,
|
||||
flushIntervalMs: 1,
|
||||
queueCap: 1,
|
||||
payloadCapBytes: 1,
|
||||
maintenanceIntervalMs: 1,
|
||||
retention: {
|
||||
eventsDays: 1,
|
||||
telemetryDays: 1,
|
||||
sessionsDays: 1,
|
||||
dailyRollupsDays: 1,
|
||||
monthlyRollupsDays: 1,
|
||||
vacuumIntervalDays: 1,
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
|
||||
createTrackerService: () => ({}) as never,
|
||||
setTracker: () => {},
|
||||
getMpvClient: () => null,
|
||||
seedTrackerFromCurrentMedia: () => {},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
runner: {
|
||||
ensureDefaultConfigBootstrap: () => {},
|
||||
getSubtitlePosition: () => ({}) as never,
|
||||
loadSubtitlePosition: () => {
|
||||
throw new Error('should not load subtitle position');
|
||||
},
|
||||
getKeybindingsCount: () => 1,
|
||||
resolveKeybindings: () => {
|
||||
throw new Error('should not resolve keybindings');
|
||||
},
|
||||
hasMpvClient: () => true,
|
||||
createMpvClient: () => {
|
||||
throw new Error('should not create mpv client');
|
||||
},
|
||||
getRuntimeOptionsManager: () => ({}),
|
||||
initRuntimeOptionsManager: () => {
|
||||
throw new Error('should not init runtime options');
|
||||
},
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
createSubtitleTimingTracker: () => {
|
||||
throw new Error('should not create subtitle timing tracker');
|
||||
},
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {},
|
||||
setSecondarySubMode: () => {},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 5174,
|
||||
defaultAnnotationWebsocketPort: 6678,
|
||||
defaultTexthookerPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
loadYomitanExtension: async () => {},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('ensureYomitanExtensionLoaded');
|
||||
},
|
||||
handleFirstRunSetup: async () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('initializeOverlayRuntime');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
|
||||
},
|
||||
handleInitialArgs: () => {},
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
});
|
||||
|
||||
await runtime.ensureYoutubePlaybackRuntimeReady();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureYomitanExtensionLoaded',
|
||||
'ensureOverlayWindowsReadyForVisibilityActions',
|
||||
]);
|
||||
});
|
||||
264
src/main/app-ready-runtime.ts
Normal file
264
src/main/app-ready-runtime.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { LogLevelSource } from '../logger';
|
||||
import type { ConfigValidationWarning, SecondarySubMode } from '../types';
|
||||
import { composeAppReadyRuntime } from './runtime/composers/app-ready-composer';
|
||||
|
||||
type AppReadyConfigLike = {
|
||||
logging?: {
|
||||
level?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type SubtitlePositionLike = unknown;
|
||||
type RuntimeOptionsManagerLike = unknown;
|
||||
type SubtitleTimingTrackerLike = unknown;
|
||||
type ImmersionTrackingConfigLike = {
|
||||
immersionTracking?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
connect: () => void;
|
||||
};
|
||||
|
||||
export interface AppReadyReloadConfigInput {
|
||||
reloadConfigStrict: () =>
|
||||
| { ok: true; path: string; warnings: ConfigValidationWarning[] }
|
||||
| { ok: false; path: string; error: string };
|
||||
logInfo: (message: string) => void;
|
||||
logWarning: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
startConfigHotReload: () => void;
|
||||
refreshAnilistClientSecretState: (options: { force: boolean }) => Promise<unknown>;
|
||||
failHandlers: {
|
||||
logError: (details: string) => void;
|
||||
showErrorBox: (title: string, details: string) => void;
|
||||
setExitCode?: (code: number) => void;
|
||||
quit: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppReadyCriticalConfigInput {
|
||||
getConfigPath: () => string;
|
||||
failHandlers: {
|
||||
logError: (details: string) => void;
|
||||
showErrorBox: (title: string, details: string) => void;
|
||||
setExitCode?: (code: number) => void;
|
||||
quit: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppReadyImmersionInput {
|
||||
getResolvedConfig: () => ImmersionTrackingConfigLike;
|
||||
getConfiguredDbPath: () => string;
|
||||
createTrackerService: (params: {
|
||||
dbPath: string;
|
||||
policy: {
|
||||
batchSize: number;
|
||||
flushIntervalMs: number;
|
||||
queueCap: number;
|
||||
payloadCapBytes: number;
|
||||
maintenanceIntervalMs: number;
|
||||
retention: {
|
||||
eventsDays: number;
|
||||
telemetryDays: number;
|
||||
sessionsDays: number;
|
||||
dailyRollupsDays: number;
|
||||
monthlyRollupsDays: number;
|
||||
vacuumIntervalDays: number;
|
||||
};
|
||||
};
|
||||
}) => unknown;
|
||||
setTracker: (tracker: unknown | null) => void;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
shouldAutoConnectMpv?: () => boolean;
|
||||
seedTrackerFromCurrentMedia: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
logWarn: (message: string, details: unknown) => void;
|
||||
}
|
||||
|
||||
export interface AppReadyRunnerInput<TConfig extends AppReadyConfigLike = AppReadyConfigLike> {
|
||||
ensureDefaultConfigBootstrap: () => void;
|
||||
getSubtitlePosition: () => SubtitlePositionLike | null;
|
||||
loadSubtitlePosition: () => void;
|
||||
getKeybindingsCount: () => number;
|
||||
resolveKeybindings: () => void;
|
||||
hasMpvClient: () => boolean;
|
||||
createMpvClient: () => void;
|
||||
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
|
||||
initRuntimeOptionsManager: () => void;
|
||||
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
|
||||
createSubtitleTimingTracker: () => void;
|
||||
getResolvedConfig: () => TConfig;
|
||||
getConfigWarnings: () => ConfigValidationWarning[];
|
||||
logConfigWarning: (warning: ConfigValidationWarning) => void;
|
||||
setLogLevel: (level: string, source: LogLevelSource) => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
defaultSecondarySubMode: SecondarySubMode;
|
||||
defaultWebsocketPort: number;
|
||||
defaultAnnotationWebsocketPort: number;
|
||||
defaultTexthookerPort: number;
|
||||
hasMpvWebsocketPlugin: () => boolean;
|
||||
startSubtitleWebsocket: (port: number) => void;
|
||||
startAnnotationWebsocket: (port: number) => void;
|
||||
startTexthooker: (port: number, websocketUrl?: string) => void;
|
||||
log: (message: string) => void;
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
createImmersionTracker?: () => void;
|
||||
startJellyfinRemoteSession?: () => Promise<void>;
|
||||
loadYomitanExtension: () => Promise<void>;
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
handleFirstRunSetup: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries?: () => Promise<void>;
|
||||
startBackgroundWarmups: () => void;
|
||||
texthookerOnlyMode: boolean;
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
runHeadlessInitialCommand?: () => Promise<void>;
|
||||
handleInitialArgs: () => void;
|
||||
onCriticalConfigErrors?: (errors: string[]) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
now?: () => number;
|
||||
shouldRunHeadlessInitialCommand?: () => boolean;
|
||||
shouldUseMinimalStartup?: () => boolean;
|
||||
shouldSkipHeavyStartup?: () => boolean;
|
||||
}
|
||||
|
||||
export interface AppReadyRuntimeInput<TConfig extends AppReadyConfigLike = AppReadyConfigLike> {
|
||||
reload: AppReadyReloadConfigInput;
|
||||
criticalConfig: AppReadyCriticalConfigInput;
|
||||
immersion: AppReadyImmersionInput;
|
||||
runner: AppReadyRunnerInput<TConfig>;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
}
|
||||
|
||||
export interface AppReadyRuntime {
|
||||
reloadConfig: () => void;
|
||||
criticalConfigError: (errors: string[]) => never;
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
|
||||
runAppReady: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createAppReadyRuntime<TConfig extends AppReadyConfigLike>(
|
||||
input: AppReadyRuntimeInput<TConfig>,
|
||||
): AppReadyRuntime {
|
||||
const ensureSubtitlePositionLoaded = (): void => {
|
||||
if (input.runner.getSubtitlePosition() === null) {
|
||||
input.runner.loadSubtitlePosition();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureKeybindingsResolved = (): void => {
|
||||
if (input.runner.getKeybindingsCount() === 0) {
|
||||
input.runner.resolveKeybindings();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureMpvClientCreated = (): void => {
|
||||
if (!input.runner.hasMpvClient()) {
|
||||
input.runner.createMpvClient();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureRuntimeOptionsManagerInitialized = (): void => {
|
||||
if (!input.runner.getRuntimeOptionsManager()) {
|
||||
input.runner.initRuntimeOptionsManager();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureSubtitleTimingTrackerCreated = (): void => {
|
||||
if (!input.runner.getSubtitleTimingTracker()) {
|
||||
input.runner.createSubtitleTimingTracker();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureOverlayStartupPrereqs = (): void => {
|
||||
ensureSubtitlePositionLoaded();
|
||||
ensureKeybindingsResolved();
|
||||
ensureMpvClientCreated();
|
||||
ensureRuntimeOptionsManagerInitialized();
|
||||
ensureSubtitleTimingTrackerCreated();
|
||||
};
|
||||
|
||||
const ensureYoutubePlaybackRuntimeReady = async (): Promise<void> => {
|
||||
ensureOverlayStartupPrereqs();
|
||||
await input.runner.ensureYomitanExtensionLoaded();
|
||||
if (!input.isOverlayRuntimeInitialized()) {
|
||||
input.runner.initializeOverlayRuntime();
|
||||
return;
|
||||
}
|
||||
input.runner.ensureOverlayWindowsReadyForVisibilityActions();
|
||||
};
|
||||
|
||||
const createImmersionTracker = input.runner.createImmersionTracker;
|
||||
const startJellyfinRemoteSession = input.runner.startJellyfinRemoteSession;
|
||||
const prewarmSubtitleDictionaries = input.runner.prewarmSubtitleDictionaries;
|
||||
const runHeadlessInitialCommand = input.runner.runHeadlessInitialCommand;
|
||||
|
||||
const { reloadConfig, criticalConfigError, appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
reloadConfigMainDeps: input.reload,
|
||||
criticalConfigErrorMainDeps: input.criticalConfig,
|
||||
immersionTrackerStartupMainDeps: input.immersion as never,
|
||||
appReadyRuntimeMainDeps: {
|
||||
ensureDefaultConfigBootstrap: () => input.runner.ensureDefaultConfigBootstrap(),
|
||||
loadSubtitlePosition: () => ensureSubtitlePositionLoaded(),
|
||||
resolveKeybindings: () => ensureKeybindingsResolved(),
|
||||
createMpvClient: () => ensureMpvClientCreated(),
|
||||
initRuntimeOptionsManager: () => ensureRuntimeOptionsManagerInitialized(),
|
||||
createSubtitleTimingTracker: () => ensureSubtitleTimingTrackerCreated(),
|
||||
getResolvedConfig: () => input.runner.getResolvedConfig() as never,
|
||||
getConfigWarnings: () => input.runner.getConfigWarnings(),
|
||||
logConfigWarning: (warning) => input.runner.logConfigWarning(warning),
|
||||
setLogLevel: (level, source) => input.runner.setLogLevel(level, source),
|
||||
setSecondarySubMode: (mode) => input.runner.setSecondarySubMode(mode),
|
||||
defaultSecondarySubMode: input.runner.defaultSecondarySubMode,
|
||||
defaultWebsocketPort: input.runner.defaultWebsocketPort,
|
||||
defaultAnnotationWebsocketPort: input.runner.defaultAnnotationWebsocketPort,
|
||||
defaultTexthookerPort: input.runner.defaultTexthookerPort,
|
||||
hasMpvWebsocketPlugin: () => input.runner.hasMpvWebsocketPlugin(),
|
||||
startSubtitleWebsocket: (port) => input.runner.startSubtitleWebsocket(port),
|
||||
startAnnotationWebsocket: (port) => input.runner.startAnnotationWebsocket(port),
|
||||
startTexthooker: (port, websocketUrl) => input.runner.startTexthooker(port, websocketUrl),
|
||||
log: (message) => input.runner.log(message),
|
||||
createMecabTokenizerAndCheck: () => input.runner.createMecabTokenizerAndCheck(),
|
||||
createImmersionTracker: createImmersionTracker ? () => createImmersionTracker() : undefined,
|
||||
startJellyfinRemoteSession: startJellyfinRemoteSession
|
||||
? () => startJellyfinRemoteSession()
|
||||
: undefined,
|
||||
loadYomitanExtension: () => input.runner.loadYomitanExtension(),
|
||||
handleFirstRunSetup: () => input.runner.handleFirstRunSetup(),
|
||||
prewarmSubtitleDictionaries: prewarmSubtitleDictionaries
|
||||
? () => prewarmSubtitleDictionaries()
|
||||
: undefined,
|
||||
startBackgroundWarmups: () => input.runner.startBackgroundWarmups(),
|
||||
texthookerOnlyMode: input.runner.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||
input.runner.shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||
setVisibleOverlayVisible: (visible) => input.runner.setVisibleOverlayVisible(visible),
|
||||
initializeOverlayRuntime: () => input.runner.initializeOverlayRuntime(),
|
||||
runHeadlessInitialCommand: runHeadlessInitialCommand
|
||||
? () => runHeadlessInitialCommand()
|
||||
: undefined,
|
||||
handleInitialArgs: () => input.runner.handleInitialArgs(),
|
||||
logDebug: input.runner.logDebug,
|
||||
now: input.runner.now,
|
||||
shouldRunHeadlessInitialCommand: input.runner.shouldRunHeadlessInitialCommand,
|
||||
shouldUseMinimalStartup: input.runner.shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: input.runner.shouldSkipHeavyStartup,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
reloadConfig,
|
||||
criticalConfigError,
|
||||
ensureOverlayStartupPrereqs,
|
||||
ensureYoutubePlaybackRuntimeReady,
|
||||
runAppReady: async () => {
|
||||
await appReadyRuntimeRunner();
|
||||
},
|
||||
};
|
||||
}
|
||||
94
src/main/cli-startup-runtime.test.ts
Normal file
94
src/main/cli-startup-runtime.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { CliArgs } from '../cli/args';
|
||||
|
||||
import { createCliStartupRuntime } from './cli-startup-runtime';
|
||||
|
||||
test('cli startup runtime returns callable CLI handlers', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createCliStartupRuntime({
|
||||
appState: {
|
||||
appState: {} as never,
|
||||
getInitialArgs: () => null,
|
||||
isBackgroundMode: () => false,
|
||||
isTexthookerOnlyMode: () => false,
|
||||
setTexthookerOnlyMode: () => {},
|
||||
hasImmersionTracker: () => false,
|
||||
getMpvClient: () => null,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
},
|
||||
config: {
|
||||
defaultConfig: { websocket: { port: 6677 }, annotationWebsocket: { port: 6678 } } as never,
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
setCliLogLevel: () => {},
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
},
|
||||
io: {
|
||||
texthookerService: {} as never,
|
||||
openExternal: async () => {},
|
||||
logBrowserOpenError: () => {},
|
||||
showMpvOsd: () => {},
|
||||
schedule: () => 0 as never,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
},
|
||||
commands: {
|
||||
initializeOverlayRuntime: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
openFirstRunSetupWindow: () => {},
|
||||
setVisibleOverlayVisible: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
mineSentenceCard: async () => {},
|
||||
startPendingMineSentenceMultiple: () => {},
|
||||
updateLastCardFromClipboard: async () => {},
|
||||
refreshKnownWordCache: async () => {},
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetupWindow: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
|
||||
generateCharacterDictionary: async () => ({
|
||||
zipPath: '/tmp/test.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 1,
|
||||
}),
|
||||
runJellyfinCommand: async () => {},
|
||||
runStatsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
stopApp: () => {},
|
||||
hasMainWindow: () => false,
|
||||
getMultiCopyTimeoutMs: () => 0,
|
||||
},
|
||||
startup: {
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {},
|
||||
commandNeedsOverlayStartupPrereqs: () => false,
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (args) => {
|
||||
calls.push(`handle:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof runtime.handleCliCommand, 'function');
|
||||
assert.equal(typeof runtime.handleInitialArgs, 'function');
|
||||
|
||||
runtime.handleCliCommand({ command: 'start' } as unknown as CliArgs);
|
||||
assert.deepEqual(calls, ['handle:start']);
|
||||
});
|
||||
220
src/main/cli-startup-runtime.ts
Normal file
220
src/main/cli-startup-runtime.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
||||
import type {
|
||||
CliCommandRuntimeServiceContext,
|
||||
CliCommandRuntimeServiceContextHandlers,
|
||||
} from './cli-runtime';
|
||||
import { composeCliStartupHandlers } from './runtime/composers/cli-startup-composer';
|
||||
|
||||
/** Mpv client shape required by the CLI command context (appState.mpvClient). */
|
||||
type AppStateMpvClientLike = {
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
connect: () => void;
|
||||
} | null;
|
||||
|
||||
/** Mpv client shape required by the initial-args handler (getMpvClient). */
|
||||
type InitialArgsMpvClientLike = { connected: boolean; connect: () => void } | null;
|
||||
|
||||
/** Resolved config shape consumed by the CLI command context builder. */
|
||||
type ResolvedConfigLike = {
|
||||
texthooker?: { openBrowser?: boolean };
|
||||
websocket?: { enabled?: boolean | 'auto'; port?: number };
|
||||
annotationWebsocket?: { enabled?: boolean; port?: number };
|
||||
};
|
||||
|
||||
/** Mutable app state consumed by the CLI command context builder. */
|
||||
type CliCommandContextMainStateLike = {
|
||||
mpvSocketPath: string;
|
||||
mpvClient: AppStateMpvClientLike;
|
||||
texthookerPort: number;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
};
|
||||
|
||||
export interface CliStartupAppStateInput {
|
||||
appState: CliCommandContextMainStateLike;
|
||||
getInitialArgs: () => CliArgs | null | undefined;
|
||||
isBackgroundMode: () => boolean;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
setTexthookerOnlyMode: (enabled: boolean) => void;
|
||||
hasImmersionTracker: () => boolean;
|
||||
getMpvClient: () => InitialArgsMpvClientLike;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
}
|
||||
|
||||
export interface CliStartupConfigInput {
|
||||
defaultConfig: {
|
||||
websocket: { port: number };
|
||||
annotationWebsocket: { port: number };
|
||||
};
|
||||
getResolvedConfig: () => ResolvedConfigLike;
|
||||
setCliLogLevel: (level: NonNullable<CliArgs['logLevel']>) => void;
|
||||
hasMpvWebsocketPlugin: () => boolean;
|
||||
}
|
||||
|
||||
export interface CliStartupIoInput {
|
||||
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
logBrowserOpenError: (url: string, error: unknown) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
}
|
||||
|
||||
export interface CliStartupCommandsInput {
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
|
||||
clearAnilistToken: () => void;
|
||||
openAnilistSetupWindow: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
|
||||
processNextAnilistRetryUpdate: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||
runJellyfinCommand: (argsFromCommand: CliArgs) => Promise<void>;
|
||||
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
url: string;
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
source: CliCommandSource;
|
||||
}) => Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
stopApp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
}
|
||||
|
||||
export interface CliStartupStartupInput {
|
||||
shouldEnsureTrayOnStartup: () => boolean;
|
||||
shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean;
|
||||
ensureTray: () => void;
|
||||
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
}
|
||||
|
||||
export interface CliStartupRuntimeInput {
|
||||
appState: CliStartupAppStateInput;
|
||||
config: CliStartupConfigInput;
|
||||
io: CliStartupIoInput;
|
||||
commands: CliStartupCommandsInput;
|
||||
startup: CliStartupStartupInput;
|
||||
handleCliCommandRuntimeServiceWithContext: (
|
||||
args: CliArgs,
|
||||
source: CliCommandSource,
|
||||
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface CliStartupRuntime {
|
||||
handleCliCommand: (args: CliArgs, source?: CliCommandSource) => void;
|
||||
handleInitialArgs: () => void;
|
||||
}
|
||||
|
||||
export function createCliStartupRuntime(input: CliStartupRuntimeInput): CliStartupRuntime {
|
||||
const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
cliCommandContextMainDeps: {
|
||||
appState: input.appState.appState,
|
||||
setLogLevel: (level) => input.config.setCliLogLevel(level),
|
||||
texthookerService: input.io.texthookerService,
|
||||
getResolvedConfig: () => input.config.getResolvedConfig(),
|
||||
defaultWebsocketPort: input.config.defaultConfig.websocket.port,
|
||||
defaultAnnotationWebsocketPort: input.config.defaultConfig.annotationWebsocket.port,
|
||||
hasMpvWebsocketPlugin: () => input.config.hasMpvWebsocketPlugin(),
|
||||
openExternal: (url: string) => input.io.openExternal(url),
|
||||
logBrowserOpenError: (url: string, error: unknown) =>
|
||||
input.io.logBrowserOpenError(url, error),
|
||||
showMpvOsd: (text: string) => input.io.showMpvOsd(text),
|
||||
initializeOverlayRuntime: () => input.commands.initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => input.commands.toggleVisibleOverlay(),
|
||||
openFirstRunSetupWindow: () => input.commands.openFirstRunSetupWindow(),
|
||||
setVisibleOverlayVisible: (visible: boolean) =>
|
||||
input.commands.setVisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => input.commands.copyCurrentSubtitle(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => input.commands.startPendingMultiCopy(timeoutMs),
|
||||
mineSentenceCard: () => input.commands.mineSentenceCard(),
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) =>
|
||||
input.commands.startPendingMineSentenceMultiple(timeoutMs),
|
||||
updateLastCardFromClipboard: () => input.commands.updateLastCardFromClipboard(),
|
||||
refreshKnownWordCache: () => input.commands.refreshKnownWordCache(),
|
||||
triggerFieldGrouping: () => input.commands.triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => input.commands.triggerSubsyncFromConfig(),
|
||||
markLastCardAsAudioCard: () => input.commands.markLastCardAsAudioCard(),
|
||||
getAnilistStatus: () => input.commands.getAnilistStatus(),
|
||||
clearAnilistToken: () => input.commands.clearAnilistToken(),
|
||||
openAnilistSetupWindow: () => input.commands.openAnilistSetupWindow(),
|
||||
openJellyfinSetupWindow: () => input.commands.openJellyfinSetupWindow(),
|
||||
getAnilistQueueStatus: () => input.commands.getAnilistQueueStatus(),
|
||||
processNextAnilistRetryUpdate: () => input.commands.processNextAnilistRetryUpdate(),
|
||||
generateCharacterDictionary: (targetPath?: string) =>
|
||||
input.commands.generateCharacterDictionary(targetPath),
|
||||
runJellyfinCommand: (argsFromCommand: CliArgs) =>
|
||||
input.commands.runJellyfinCommand(argsFromCommand),
|
||||
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
|
||||
input.commands.runStatsCommand(argsFromCommand, source),
|
||||
runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request),
|
||||
openYomitanSettings: () => input.commands.openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => input.commands.openRuntimeOptionsPalette(),
|
||||
printHelp: () => input.commands.printHelp(),
|
||||
stopApp: () => input.commands.stopApp(),
|
||||
hasMainWindow: () => input.commands.hasMainWindow(),
|
||||
getMultiCopyTimeoutMs: () => input.commands.getMultiCopyTimeoutMs(),
|
||||
schedule: (fn: () => void, delayMs: number) => input.io.schedule(fn, delayMs),
|
||||
logInfo: (message: string) => input.io.logInfo(message),
|
||||
logWarn: (message: string) => input.io.logWarn(message),
|
||||
logError: (message: string, err: unknown) => input.io.logError(message, err),
|
||||
},
|
||||
cliCommandRuntimeHandlerMainDeps: {
|
||||
handleTexthookerOnlyModeTransitionMainDeps: {
|
||||
isTexthookerOnlyMode: () => input.appState.isTexthookerOnlyMode(),
|
||||
ensureOverlayStartupPrereqs: () => input.startup.ensureOverlayStartupPrereqs(),
|
||||
setTexthookerOnlyMode: (enabled) => input.appState.setTexthookerOnlyMode(enabled),
|
||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
||||
input.startup.commandNeedsOverlayStartupPrereqs(args),
|
||||
startBackgroundWarmups: () => input.startup.startBackgroundWarmups(),
|
||||
logInfo: (message: string) => input.io.logInfo(message),
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (args, source, context) =>
|
||||
input.handleCliCommandRuntimeServiceWithContext(args, source, context),
|
||||
},
|
||||
initialArgsRuntimeHandlerMainDeps: {
|
||||
getInitialArgs: () => input.appState.getInitialArgs() ?? null,
|
||||
isBackgroundMode: () => input.appState.isBackgroundMode(),
|
||||
shouldEnsureTrayOnStartup: () => input.startup.shouldEnsureTrayOnStartup(),
|
||||
shouldRunHeadlessInitialCommand: (args) =>
|
||||
input.startup.shouldRunHeadlessInitialCommand(args),
|
||||
ensureTray: () => input.startup.ensureTray(),
|
||||
isTexthookerOnlyMode: () => input.appState.isTexthookerOnlyMode(),
|
||||
hasImmersionTracker: () => input.appState.hasImmersionTracker(),
|
||||
getMpvClient: () => input.appState.getMpvClient(),
|
||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
||||
input.startup.commandNeedsOverlayStartupPrereqs(args),
|
||||
commandNeedsOverlayRuntime: (args) => input.startup.commandNeedsOverlayRuntime(args),
|
||||
ensureOverlayStartupPrereqs: () => input.startup.ensureOverlayStartupPrereqs(),
|
||||
isOverlayRuntimeInitialized: () => input.appState.isOverlayRuntimeInitialized(),
|
||||
initializeOverlayRuntime: () => input.commands.initializeOverlayRuntime(),
|
||||
logInfo: (message) => input.io.logInfo(message),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
handleCliCommand,
|
||||
handleInitialArgs,
|
||||
};
|
||||
}
|
||||
12
src/main/default-socket-path.ts
Normal file
12
src/main/default-socket-path.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
createBuildGetDefaultSocketPathMainDepsHandler,
|
||||
createGetDefaultSocketPathHandler,
|
||||
} from './runtime/domains/jellyfin';
|
||||
|
||||
export function createDefaultSocketPathResolver(platform: NodeJS.Platform) {
|
||||
return createGetDefaultSocketPathHandler(
|
||||
createBuildGetDefaultSocketPathMainDepsHandler({
|
||||
platform,
|
||||
})(),
|
||||
);
|
||||
}
|
||||
205
src/main/dictionary-support-runtime-input.ts
Normal file
205
src/main/dictionary-support-runtime-input.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { FrequencyDictionaryLookup, ResolvedConfig } from '../types';
|
||||
import type { JlptLookup } from './jlpt-runtime';
|
||||
import type { DictionarySupportRuntimeInput } from './dictionary-support-runtime';
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './runtime/character-dictionary-auto-sync-notifications';
|
||||
import type { StartupOsdSequencerCharacterDictionaryEvent } from './runtime/startup-osd-sequencer';
|
||||
|
||||
type BrowserWindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
webContents: {
|
||||
send: (channel: string, payload?: unknown) => void;
|
||||
};
|
||||
};
|
||||
|
||||
type ImmersionTrackerLike = {
|
||||
handleMediaChange: (path: string, title: string | null) => void;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
currentVideoPath?: string | null;
|
||||
connected?: boolean;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type StartupOsdSequencerLike = {
|
||||
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
|
||||
};
|
||||
|
||||
export interface DictionarySupportRuntimeInputBuilderInput {
|
||||
env: {
|
||||
platform: NodeJS.Platform;
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
userDataPath: string;
|
||||
appUserDataPath: string;
|
||||
homeDir: string;
|
||||
appDataDir?: string;
|
||||
cwd: string;
|
||||
subtitlePositionsDir: string;
|
||||
defaultImmersionDbPath: string;
|
||||
};
|
||||
config: {
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
};
|
||||
dictionaryState: {
|
||||
setJlptLevelLookup: (lookup: JlptLookup) => void;
|
||||
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
|
||||
};
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
debug: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
media: {
|
||||
isRemoteMediaPath: (mediaPath: string) => boolean;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
setCurrentMediaPath: (mediaPath: string | null) => void;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
setCurrentMediaTitle: (title: string | null) => void;
|
||||
getPendingSubtitlePosition: () => DictionarySupportRuntimeInput['getPendingSubtitlePosition'] extends () => infer T
|
||||
? T
|
||||
: never;
|
||||
clearPendingSubtitlePosition: () => void;
|
||||
setSubtitlePosition: DictionarySupportRuntimeInput['setSubtitlePosition'];
|
||||
};
|
||||
subtitle: {
|
||||
loadSubtitlePosition: DictionarySupportRuntimeInput['loadSubtitlePosition'];
|
||||
invalidateTokenizationCache: () => void;
|
||||
refreshSubtitlePrefetchFromActiveTrack: () => void;
|
||||
refreshCurrentSubtitle: (text: string) => void;
|
||||
getCurrentSubtitleText: () => string;
|
||||
};
|
||||
overlay: {
|
||||
broadcastSubtitlePosition: DictionarySupportRuntimeInput<OverlayHostedModal>['broadcastSubtitlePosition'];
|
||||
broadcastToOverlayWindows: DictionarySupportRuntimeInput<OverlayHostedModal>['broadcastToOverlayWindows'];
|
||||
getMainWindow: () => BrowserWindowLike | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
sendToActiveOverlayWindow: DictionarySupportRuntimeInput<OverlayHostedModal>['sendToActiveOverlayWindow'];
|
||||
};
|
||||
tracker: {
|
||||
getTracker: () => ImmersionTrackerLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
};
|
||||
anilist: {
|
||||
guessAnilistMediaInfo: DictionarySupportRuntimeInput['guessAnilistMediaInfo'];
|
||||
};
|
||||
yomitan: {
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
getYomitanDictionaryInfo: () => Promise<Array<{ title: string; revision?: string | number }>>;
|
||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
||||
upsertYomitanDictionarySettings: (
|
||||
dictionaryTitle: string,
|
||||
profileScope: ResolvedConfig['anilist']['characterDictionary']['profileScope'],
|
||||
) => Promise<boolean>;
|
||||
hasParserWindow: () => boolean;
|
||||
clearParserCaches: () => void;
|
||||
};
|
||||
startup: {
|
||||
getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined;
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
startupOsdSequencer?: StartupOsdSequencerLike;
|
||||
};
|
||||
playback: {
|
||||
isYoutubePlaybackActiveNow: () => boolean;
|
||||
waitForYomitanMutationReady: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDictionarySupportRuntimeInput(
|
||||
input: DictionarySupportRuntimeInputBuilderInput,
|
||||
): DictionarySupportRuntimeInput<OverlayHostedModal> {
|
||||
return {
|
||||
platform: input.env.platform,
|
||||
dirname: input.env.dirname,
|
||||
appPath: input.env.appPath,
|
||||
resourcesPath: input.env.resourcesPath,
|
||||
userDataPath: input.env.userDataPath,
|
||||
appUserDataPath: input.env.appUserDataPath,
|
||||
homeDir: input.env.homeDir,
|
||||
appDataDir: input.env.appDataDir,
|
||||
cwd: input.env.cwd,
|
||||
subtitlePositionsDir: input.env.subtitlePositionsDir,
|
||||
getResolvedConfig: () => input.config.getResolvedConfig(),
|
||||
isJlptEnabled: () => input.config.getResolvedConfig().subtitleStyle.enableJlpt,
|
||||
isFrequencyDictionaryEnabled: () =>
|
||||
input.config.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
getFrequencyDictionarySourcePath: () =>
|
||||
input.config.getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath,
|
||||
setJlptLevelLookup: (lookup) => input.dictionaryState.setJlptLevelLookup(lookup),
|
||||
setFrequencyRankLookup: (lookup) => input.dictionaryState.setFrequencyRankLookup(lookup),
|
||||
logInfo: (message) => input.logger.info(message),
|
||||
logDebug: (message) => input.logger.debug(message),
|
||||
logWarn: (message) => input.logger.warn(message),
|
||||
isRemoteMediaPath: (mediaPath) => input.media.isRemoteMediaPath(mediaPath),
|
||||
getCurrentMediaPath: () => input.media.getCurrentMediaPath(),
|
||||
setCurrentMediaPath: (mediaPath) => input.media.setCurrentMediaPath(mediaPath),
|
||||
getCurrentMediaTitle: () => input.media.getCurrentMediaTitle(),
|
||||
setCurrentMediaTitle: (title) => input.media.setCurrentMediaTitle(title),
|
||||
getPendingSubtitlePosition: () => input.media.getPendingSubtitlePosition(),
|
||||
loadSubtitlePosition: () => input.subtitle.loadSubtitlePosition(),
|
||||
clearPendingSubtitlePosition: () => input.media.clearPendingSubtitlePosition(),
|
||||
setSubtitlePosition: (position) => input.media.setSubtitlePosition(position),
|
||||
broadcastSubtitlePosition: (position) => input.overlay.broadcastSubtitlePosition(position),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
input.overlay.broadcastToOverlayWindows(channel, payload),
|
||||
getTracker: () => input.tracker.getTracker(),
|
||||
getMpvClient: () => input.tracker.getMpvClient(),
|
||||
defaultImmersionDbPath: input.env.defaultImmersionDbPath,
|
||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
||||
input.anilist.guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||
getCollapsibleSectionOpenState: (section) =>
|
||||
input.config.getResolvedConfig().anilist.characterDictionary.collapsibleSections[section],
|
||||
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
|
||||
isYoutubePlaybackActiveNow: () => input.playback.isYoutubePlaybackActiveNow(),
|
||||
waitForYomitanMutationReady: () => input.playback.waitForYomitanMutationReady(),
|
||||
getYomitanDictionaryInfo: () => input.yomitan.getYomitanDictionaryInfo(),
|
||||
importYomitanDictionary: (zipPath) => input.yomitan.importYomitanDictionary(zipPath),
|
||||
deleteYomitanDictionary: (dictionaryTitle) =>
|
||||
input.yomitan.deleteYomitanDictionary(dictionaryTitle),
|
||||
upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) =>
|
||||
input.yomitan.upsertYomitanDictionarySettings(dictionaryTitle, profileScope),
|
||||
getCharacterDictionaryConfig: () => {
|
||||
const config = input.config.getResolvedConfig().anilist.characterDictionary;
|
||||
return {
|
||||
enabled:
|
||||
config.enabled &&
|
||||
input.yomitan.isCharacterDictionaryEnabled() &&
|
||||
!input.playback.isYoutubePlaybackActiveNow(),
|
||||
maxLoaded: config.maxLoaded,
|
||||
profileScope: config.profileScope,
|
||||
};
|
||||
},
|
||||
notifyCharacterDictionaryAutoSyncStatus: (event) => {
|
||||
notifyCharacterDictionaryAutoSyncStatus(event, {
|
||||
getNotificationType: () => input.startup.getNotificationType(),
|
||||
showOsd: (message) => input.startup.showMpvOsd(message),
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.startup.showDesktopNotification(title, options),
|
||||
startupOsdSequencer: input.startup.startupOsdSequencer,
|
||||
});
|
||||
},
|
||||
characterDictionaryAutoSyncCompleteDeps: {
|
||||
hasParserWindow: () => input.yomitan.hasParserWindow(),
|
||||
clearParserCaches: () => input.yomitan.clearParserCaches(),
|
||||
invalidateTokenizationCache: () => input.subtitle.invalidateTokenizationCache(),
|
||||
refreshSubtitlePrefetch: () => input.subtitle.refreshSubtitlePrefetchFromActiveTrack(),
|
||||
refreshCurrentSubtitle: () =>
|
||||
input.subtitle.refreshCurrentSubtitle(input.subtitle.getCurrentSubtitleText()),
|
||||
logInfo: (message) => input.logger.info(message),
|
||||
},
|
||||
getMainWindow: () => input.overlay.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
|
||||
setVisibleOverlayVisible: (visible) => input.overlay.setVisibleOverlayVisible(visible),
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
input.overlay.getRestoreVisibleOverlayOnModalClose(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
input.overlay.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
};
|
||||
}
|
||||
215
src/main/dictionary-support-runtime.test.ts
Normal file
215
src/main/dictionary-support-runtime.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createDictionarySupportRuntime } from './dictionary-support-runtime';
|
||||
|
||||
function createRuntime() {
|
||||
const state = {
|
||||
currentMediaPath: null as string | null,
|
||||
currentMediaTitle: null as string | null,
|
||||
jlptLookupSet: 0,
|
||||
frequencyLookupSet: 0,
|
||||
trackerCalls: [] as Array<{ path: string; title: string | null }>,
|
||||
characterDictionaryConfig: {
|
||||
enabled: false,
|
||||
maxLoaded: 1,
|
||||
profileScope: 'global',
|
||||
},
|
||||
youtubePlaybackActive: false,
|
||||
};
|
||||
|
||||
const runtime = createDictionarySupportRuntime({
|
||||
platform: 'darwin',
|
||||
dirname: '/repo/dist/main',
|
||||
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
|
||||
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
|
||||
userDataPath: '/Users/a/Library/Application Support/SubMiner',
|
||||
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
|
||||
homeDir: '/Users/a',
|
||||
cwd: '/repo',
|
||||
subtitlePositionsDir: '/Users/a/Library/Application Support/SubMiner/subtitle-positions',
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
subtitleStyle: {
|
||||
enableJlpt: false,
|
||||
frequencyDictionary: {
|
||||
enabled: false,
|
||||
sourcePath: '',
|
||||
},
|
||||
},
|
||||
anilist: {
|
||||
characterDictionary: {
|
||||
enabled: false,
|
||||
maxLoaded: 1,
|
||||
profileScope: 'global',
|
||||
collapsibleSections: {
|
||||
description: false,
|
||||
glossary: false,
|
||||
termEntry: false,
|
||||
nameReading: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
ankiConnect: {
|
||||
behavior: {
|
||||
notificationType: 'none',
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
isJlptEnabled: () => false,
|
||||
isFrequencyDictionaryEnabled: () => false,
|
||||
getFrequencyDictionarySourcePath: () => undefined,
|
||||
setJlptLevelLookup: () => {
|
||||
state.jlptLookupSet += 1;
|
||||
},
|
||||
setFrequencyRankLookup: () => {
|
||||
state.frequencyLookupSet += 1;
|
||||
},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
isRemoteMediaPath: (mediaPath) => mediaPath.startsWith('remote:'),
|
||||
getCurrentMediaPath: () => state.currentMediaPath,
|
||||
setCurrentMediaPath: (mediaPath) => {
|
||||
state.currentMediaPath = mediaPath;
|
||||
},
|
||||
getCurrentMediaTitle: () => state.currentMediaTitle,
|
||||
setCurrentMediaTitle: (title) => {
|
||||
state.currentMediaTitle = title;
|
||||
},
|
||||
getPendingSubtitlePosition: () => null,
|
||||
loadSubtitlePosition: () => null,
|
||||
clearPendingSubtitlePosition: () => {},
|
||||
setSubtitlePosition: () => {},
|
||||
broadcastSubtitlePosition: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
getTracker: () =>
|
||||
({
|
||||
handleMediaChange: (path: string, title: string | null) => {
|
||||
state.trackerCalls.push({ path, title });
|
||||
},
|
||||
}) as never,
|
||||
getMpvClient: () => null,
|
||||
defaultImmersionDbPath: '/tmp/immersion.db',
|
||||
guessAnilistMediaInfo: async () => null,
|
||||
getCollapsibleSectionOpenState: () => false,
|
||||
isCharacterDictionaryEnabled: () => state.characterDictionaryConfig.enabled,
|
||||
isYoutubePlaybackActiveNow: () => state.youtubePlaybackActive,
|
||||
waitForYomitanMutationReady: async () => {},
|
||||
getYomitanDictionaryInfo: async () => [],
|
||||
importYomitanDictionary: async () => false,
|
||||
deleteYomitanDictionary: async () => false,
|
||||
upsertYomitanDictionarySettings: async () => false,
|
||||
getCharacterDictionaryConfig: () => state.characterDictionaryConfig as never,
|
||||
notifyCharacterDictionaryAutoSyncStatus: () => {},
|
||||
characterDictionaryAutoSyncCompleteDeps: {
|
||||
hasParserWindow: () => false,
|
||||
clearParserCaches: () => {},
|
||||
invalidateTokenizationCache: () => {},
|
||||
refreshSubtitlePrefetch: () => {},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
logInfo: () => {},
|
||||
},
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<string>(),
|
||||
sendToActiveOverlayWindow: () => true,
|
||||
});
|
||||
|
||||
return { runtime, state };
|
||||
}
|
||||
|
||||
test('dictionary support runtime wires field grouping resolver and callback', async () => {
|
||||
const { runtime } = createRuntime();
|
||||
const choice = {
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
const callback = runtime.createFieldGroupingCallback();
|
||||
const pending = callback({} as never);
|
||||
const resolver = runtime.getFieldGroupingResolver();
|
||||
assert.ok(resolver);
|
||||
resolver(choice as never);
|
||||
assert.deepEqual(await pending, choice);
|
||||
assert.equal(typeof runtime.getFieldGroupingResolver(), 'function');
|
||||
|
||||
runtime.setFieldGroupingResolver(null);
|
||||
assert.equal(runtime.getFieldGroupingResolver(), null);
|
||||
});
|
||||
|
||||
test('dictionary support runtime resolves media paths and keeps title in sync', () => {
|
||||
const { runtime, state } = createRuntime();
|
||||
|
||||
runtime.updateCurrentMediaTitle(' Example Title ');
|
||||
runtime.updateCurrentMediaPath('remote://media' as never);
|
||||
assert.equal(state.currentMediaTitle, 'Example Title');
|
||||
assert.equal(runtime.resolveMediaPathForJimaku('remote://media'), 'Example Title');
|
||||
|
||||
runtime.updateCurrentMediaPath('local.mp4' as never);
|
||||
assert.equal(state.currentMediaTitle, null);
|
||||
assert.equal(runtime.resolveMediaPathForJimaku('remote://media'), 'remote://media');
|
||||
});
|
||||
|
||||
test('dictionary support runtime skips disabled lookup and sync paths', async () => {
|
||||
const { runtime, state } = createRuntime();
|
||||
|
||||
await runtime.ensureJlptDictionaryLookup();
|
||||
await runtime.ensureFrequencyDictionaryLookup();
|
||||
runtime.scheduleCharacterDictionarySync();
|
||||
|
||||
assert.equal(state.jlptLookupSet, 0);
|
||||
assert.equal(state.frequencyLookupSet, 0);
|
||||
});
|
||||
|
||||
test('dictionary support runtime syncs immersion media from current state', async () => {
|
||||
const { runtime, state } = createRuntime();
|
||||
|
||||
runtime.updateCurrentMediaTitle(' Example Title ');
|
||||
runtime.updateCurrentMediaPath('remote://media' as never);
|
||||
await runtime.seedImmersionMediaFromCurrentMedia();
|
||||
runtime.syncImmersionMediaState();
|
||||
|
||||
assert.deepEqual(state.trackerCalls, [
|
||||
{ path: 'remote://media', title: 'Example Title' },
|
||||
{ path: 'remote://media', title: 'Example Title' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('dictionary support runtime gates character dictionary auto-sync scheduling', () => {
|
||||
const { runtime, state } = createRuntime();
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
let timeoutCalls = 0;
|
||||
|
||||
try {
|
||||
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: never[]) => {
|
||||
timeoutCalls += 1;
|
||||
return originalSetTimeout(handler, timeout ?? 0, ...args);
|
||||
}) as typeof globalThis.setTimeout;
|
||||
globalThis.clearTimeout = ((handle: number | NodeJS.Timeout | undefined) => {
|
||||
originalClearTimeout(handle);
|
||||
}) as typeof globalThis.clearTimeout;
|
||||
|
||||
runtime.scheduleCharacterDictionarySync();
|
||||
assert.equal(timeoutCalls, 0);
|
||||
|
||||
state.characterDictionaryConfig = {
|
||||
enabled: true,
|
||||
maxLoaded: 1,
|
||||
profileScope: 'global',
|
||||
};
|
||||
runtime.scheduleCharacterDictionarySync();
|
||||
assert.equal(timeoutCalls, 1);
|
||||
|
||||
state.youtubePlaybackActive = true;
|
||||
runtime.scheduleCharacterDictionarySync();
|
||||
assert.equal(timeoutCalls, 1);
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
}
|
||||
});
|
||||
306
src/main/dictionary-support-runtime.ts
Normal file
306
src/main/dictionary-support-runtime.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import * as path from 'path';
|
||||
|
||||
import type {
|
||||
AnilistCharacterDictionaryProfileScope,
|
||||
FrequencyDictionaryLookup,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuFieldGroupingRequestData,
|
||||
SubtitlePosition,
|
||||
ResolvedConfig,
|
||||
} from '../types';
|
||||
import {
|
||||
createBuildDictionaryRootsMainHandler,
|
||||
createBuildFrequencyDictionaryRuntimeMainDepsHandler,
|
||||
createBuildJlptDictionaryRuntimeMainDepsHandler,
|
||||
} from './runtime/dictionary-runtime-main-deps';
|
||||
import { createImmersionMediaRuntime } from './runtime/immersion-media';
|
||||
import {
|
||||
createFrequencyDictionaryRuntimeService,
|
||||
getFrequencyDictionarySearchPaths,
|
||||
} from './frequency-dictionary-runtime';
|
||||
import {
|
||||
createJlptDictionaryRuntimeService,
|
||||
getJlptDictionarySearchPaths,
|
||||
type JlptLookup,
|
||||
} from './jlpt-runtime';
|
||||
import { createMediaRuntimeService } from './media-runtime';
|
||||
import {
|
||||
createCharacterDictionaryRuntimeService,
|
||||
type CharacterDictionaryBuildResult,
|
||||
} from './character-dictionary-runtime';
|
||||
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
|
||||
import {
|
||||
createCharacterDictionaryAutoSyncRuntimeService,
|
||||
type CharacterDictionaryAutoSyncConfig,
|
||||
type CharacterDictionaryAutoSyncStatusEvent,
|
||||
} from './runtime/character-dictionary-auto-sync';
|
||||
import { handleCharacterDictionaryAutoSyncComplete } from './runtime/character-dictionary-auto-sync-completion';
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createFieldGroupingOverlayRuntime } from '../core/services/field-grouping-overlay';
|
||||
|
||||
type FieldGroupingResolver = ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
|
||||
type BrowserWindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
webContents: {
|
||||
send: (channel: string, payload?: unknown) => void;
|
||||
};
|
||||
};
|
||||
|
||||
type ImmersionTrackerLike = {
|
||||
handleMediaChange: (path: string, title: string | null) => void;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
currentVideoPath?: string | null;
|
||||
connected?: boolean;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type CharacterDictionaryAutoSyncCompleteDeps = {
|
||||
hasParserWindow: () => boolean;
|
||||
clearParserCaches: () => void;
|
||||
invalidateTokenizationCache: () => void;
|
||||
refreshSubtitlePrefetch: () => void;
|
||||
refreshCurrentSubtitle: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
};
|
||||
|
||||
export interface DictionarySupportRuntimeInput<TModal extends string = string> {
|
||||
platform: NodeJS.Platform;
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
userDataPath: string;
|
||||
appUserDataPath: string;
|
||||
homeDir: string;
|
||||
appDataDir?: string;
|
||||
cwd: string;
|
||||
subtitlePositionsDir: string;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
isJlptEnabled: () => boolean;
|
||||
isFrequencyDictionaryEnabled: () => boolean;
|
||||
getFrequencyDictionarySourcePath: () => string | undefined;
|
||||
setJlptLevelLookup: (lookup: JlptLookup) => void;
|
||||
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
isRemoteMediaPath: (mediaPath: string) => boolean;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
setCurrentMediaPath: (mediaPath: string | null) => void;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
setCurrentMediaTitle: (title: string | null) => void;
|
||||
getPendingSubtitlePosition: () => SubtitlePosition | null;
|
||||
loadSubtitlePosition: () => SubtitlePosition | null;
|
||||
clearPendingSubtitlePosition: () => void;
|
||||
setSubtitlePosition: (position: SubtitlePosition | null) => void;
|
||||
broadcastSubtitlePosition: (position: SubtitlePosition | null) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload?: unknown) => void;
|
||||
getTracker: () => ImmersionTrackerLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
defaultImmersionDbPath: string;
|
||||
guessAnilistMediaInfo: (
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
) => Promise<AnilistMediaGuess | null>;
|
||||
getCollapsibleSectionOpenState: (
|
||||
section: keyof ResolvedConfig['anilist']['characterDictionary']['collapsibleSections'],
|
||||
) => boolean;
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
isYoutubePlaybackActiveNow: () => boolean;
|
||||
waitForYomitanMutationReady: () => Promise<void>;
|
||||
getYomitanDictionaryInfo: () => Promise<Array<{ title: string; revision?: string | number }>>;
|
||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
||||
upsertYomitanDictionarySettings: (
|
||||
dictionaryTitle: string,
|
||||
profileScope: AnilistCharacterDictionaryProfileScope,
|
||||
) => Promise<boolean>;
|
||||
getCharacterDictionaryConfig: () => CharacterDictionaryAutoSyncConfig;
|
||||
notifyCharacterDictionaryAutoSyncStatus: (event: CharacterDictionaryAutoSyncStatusEvent) => void;
|
||||
characterDictionaryAutoSyncCompleteDeps: CharacterDictionaryAutoSyncCompleteDeps;
|
||||
getMainWindow: () => BrowserWindowLike | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<TModal>;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: TModal },
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
export interface DictionarySupportRuntime {
|
||||
ensureJlptDictionaryLookup: () => Promise<void>;
|
||||
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
||||
getFieldGroupingResolver: () => FieldGroupingResolver;
|
||||
setFieldGroupingResolver: (resolver: FieldGroupingResolver) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getConfiguredDbPath: () => string;
|
||||
seedImmersionMediaFromCurrentMedia: () => Promise<void>;
|
||||
syncImmersionMediaState: () => void;
|
||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
||||
updateCurrentMediaPath: (mediaPath: unknown) => void;
|
||||
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
|
||||
scheduleCharacterDictionarySync: () => void;
|
||||
generateCharacterDictionaryForCurrentMedia: (
|
||||
targetPath?: string,
|
||||
) => Promise<CharacterDictionaryBuildResult>;
|
||||
}
|
||||
|
||||
export function createDictionarySupportRuntime<TModal extends string>(
|
||||
input: DictionarySupportRuntimeInput<TModal>,
|
||||
): DictionarySupportRuntime {
|
||||
const dictionaryRoots = createBuildDictionaryRootsMainHandler({
|
||||
platform: input.platform,
|
||||
dirname: input.dirname,
|
||||
appPath: input.appPath,
|
||||
resourcesPath: input.resourcesPath,
|
||||
userDataPath: input.userDataPath,
|
||||
appUserDataPath: input.appUserDataPath,
|
||||
homeDir: input.homeDir,
|
||||
appDataDir: input.appDataDir,
|
||||
cwd: input.cwd,
|
||||
joinPath: (...parts: string[]) => path.join(...parts),
|
||||
});
|
||||
|
||||
const jlptRuntime = createJlptDictionaryRuntimeService(
|
||||
createBuildJlptDictionaryRuntimeMainDepsHandler({
|
||||
isJlptEnabled: () => input.isJlptEnabled(),
|
||||
getDictionaryRoots: () => dictionaryRoots(),
|
||||
getJlptDictionarySearchPaths,
|
||||
setJlptLevelLookup: (lookup) => input.setJlptLevelLookup(lookup),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
})(),
|
||||
);
|
||||
|
||||
const frequencyRuntime = createFrequencyDictionaryRuntimeService(
|
||||
createBuildFrequencyDictionaryRuntimeMainDepsHandler({
|
||||
isFrequencyDictionaryEnabled: () => input.isFrequencyDictionaryEnabled(),
|
||||
getDictionaryRoots: () => dictionaryRoots(),
|
||||
getFrequencyDictionarySearchPaths,
|
||||
getSourcePath: () => input.getFrequencyDictionarySourcePath(),
|
||||
setFrequencyRankLookup: (lookup) => input.setFrequencyRankLookup(lookup),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
})(),
|
||||
);
|
||||
|
||||
let fieldGroupingResolver: FieldGroupingResolver = null;
|
||||
let fieldGroupingResolverSequence = 0;
|
||||
|
||||
const getFieldGroupingResolver = (): FieldGroupingResolver => fieldGroupingResolver;
|
||||
const setFieldGroupingResolver = (resolver: FieldGroupingResolver): void => {
|
||||
if (!resolver) {
|
||||
fieldGroupingResolver = null;
|
||||
return;
|
||||
}
|
||||
const sequence = ++fieldGroupingResolverSequence;
|
||||
fieldGroupingResolver = (choice) => {
|
||||
if (sequence !== fieldGroupingResolverSequence) {
|
||||
return;
|
||||
}
|
||||
resolver(choice);
|
||||
};
|
||||
};
|
||||
|
||||
const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<TModal>({
|
||||
getMainWindow: () => input.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => input.getVisibleOverlayVisible(),
|
||||
setVisibleOverlayVisible: (visible) => input.setVisibleOverlayVisible(visible),
|
||||
getResolver: () => getFieldGroupingResolver(),
|
||||
setResolver: (resolver) => setFieldGroupingResolver(resolver),
|
||||
getRestoreVisibleOverlayOnModalClose: () => input.getRestoreVisibleOverlayOnModalClose(),
|
||||
sendToVisibleOverlay: (channel, payload, runtimeOptions) =>
|
||||
input.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
});
|
||||
|
||||
const immersionMediaRuntime = createImmersionMediaRuntime({
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
defaultImmersionDbPath: input.defaultImmersionDbPath,
|
||||
getTracker: () => input.getTracker(),
|
||||
getMpvClient: () => input.getMpvClient(),
|
||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
||||
logDebug: (message) => (input.logDebug ?? input.logInfo)(message),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
});
|
||||
|
||||
const mediaRuntime = createMediaRuntimeService({
|
||||
isRemoteMediaPath: (mediaPath) => input.isRemoteMediaPath(mediaPath),
|
||||
loadSubtitlePosition: () => input.loadSubtitlePosition(),
|
||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
||||
getPendingSubtitlePosition: () => input.getPendingSubtitlePosition(),
|
||||
getSubtitlePositionsDir: () => input.subtitlePositionsDir,
|
||||
setCurrentMediaPath: (mediaPath) => input.setCurrentMediaPath(mediaPath),
|
||||
clearPendingSubtitlePosition: () => input.clearPendingSubtitlePosition(),
|
||||
setSubtitlePosition: (position) => input.setSubtitlePosition(position),
|
||||
broadcastSubtitlePosition: (position) => input.broadcastSubtitlePosition(position),
|
||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
||||
setCurrentMediaTitle: (title) => input.setCurrentMediaTitle(title),
|
||||
});
|
||||
|
||||
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath: input.userDataPath,
|
||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
||||
input.guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||
getCollapsibleSectionOpenState: (section) => input.getCollapsibleSectionOpenState(section),
|
||||
now: () => Date.now(),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logWarn: (message) => input.logWarn(message),
|
||||
});
|
||||
|
||||
const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath: input.userDataPath,
|
||||
getConfig: () => input.getCharacterDictionaryConfig(),
|
||||
getOrCreateCurrentSnapshot: (targetPath, progress) =>
|
||||
characterDictionaryRuntime.getOrCreateCurrentSnapshot(targetPath, progress),
|
||||
buildMergedDictionary: (mediaIds) => characterDictionaryRuntime.buildMergedDictionary(mediaIds),
|
||||
waitForYomitanMutationReady: () => input.waitForYomitanMutationReady(),
|
||||
getYomitanDictionaryInfo: () => input.getYomitanDictionaryInfo(),
|
||||
importYomitanDictionary: (zipPath) => input.importYomitanDictionary(zipPath),
|
||||
deleteYomitanDictionary: (dictionaryTitle) => input.deleteYomitanDictionary(dictionaryTitle),
|
||||
upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) =>
|
||||
input.upsertYomitanDictionarySettings(dictionaryTitle, profileScope),
|
||||
now: () => Date.now(),
|
||||
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
||||
clearSchedule: (timer) => clearTimeout(timer),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logWarn: (message) => input.logWarn(message),
|
||||
onSyncStatus: (event) => input.notifyCharacterDictionaryAutoSyncStatus(event),
|
||||
onSyncComplete: (result) =>
|
||||
handleCharacterDictionaryAutoSyncComplete(
|
||||
result,
|
||||
input.characterDictionaryAutoSyncCompleteDeps,
|
||||
),
|
||||
});
|
||||
|
||||
const scheduleCharacterDictionarySync = (): void => {
|
||||
if (!input.isCharacterDictionaryEnabled() || input.isYoutubePlaybackActiveNow()) {
|
||||
return;
|
||||
}
|
||||
characterDictionaryAutoSyncRuntime.scheduleSync();
|
||||
};
|
||||
|
||||
return {
|
||||
ensureJlptDictionaryLookup: () => jlptRuntime.ensureJlptDictionaryLookup(),
|
||||
ensureFrequencyDictionaryLookup: () => frequencyRuntime.ensureFrequencyDictionaryLookup(),
|
||||
getFieldGroupingResolver,
|
||||
setFieldGroupingResolver,
|
||||
createFieldGroupingCallback: () => fieldGroupingOverlayRuntime.createFieldGroupingCallback(),
|
||||
getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(),
|
||||
seedImmersionMediaFromCurrentMedia: () => immersionMediaRuntime.seedFromCurrentMedia(),
|
||||
syncImmersionMediaState: () => immersionMediaRuntime.syncFromCurrentMediaState(),
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
||||
updateCurrentMediaPath: (mediaPath) => mediaRuntime.updateCurrentMediaPath(mediaPath),
|
||||
updateCurrentMediaTitle: (mediaTitle) => mediaRuntime.updateCurrentMediaTitle(mediaTitle),
|
||||
scheduleCharacterDictionarySync,
|
||||
generateCharacterDictionaryForCurrentMedia: (targetPath?: string) =>
|
||||
characterDictionaryRuntime.generateForCurrentMedia(targetPath),
|
||||
};
|
||||
}
|
||||
149
src/main/discord-presence-lifecycle-runtime.test.ts
Normal file
149
src/main/discord-presence-lifecycle-runtime.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createDiscordPresenceLifecycleRuntime } from './discord-presence-lifecycle-runtime';
|
||||
|
||||
test('discord presence lifecycle runtime starts service and publishes presence when enabled', async () => {
|
||||
const calls: string[] = [];
|
||||
let service: { start: () => Promise<void>; stop: () => Promise<void> } | null = null;
|
||||
|
||||
const runtime = createDiscordPresenceLifecycleRuntime({
|
||||
getResolvedConfig: () => ({ discordPresence: { enabled: true } }),
|
||||
getDiscordPresenceService: () => service as never,
|
||||
setDiscordPresenceService: (next) => {
|
||||
service = next as typeof service;
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
getCurrentMediaTitle: () => 'Demo',
|
||||
getCurrentMediaPath: () => '/tmp/demo.mkv',
|
||||
getCurrentSubtitleText: () => 'subtitle',
|
||||
getPlaybackPaused: () => false,
|
||||
getFallbackMediaDurationSec: () => 12,
|
||||
createDiscordPresenceService: () => ({
|
||||
start: async () => {
|
||||
calls.push('start');
|
||||
},
|
||||
stop: async () => {
|
||||
calls.push('stop');
|
||||
},
|
||||
publish: () => {
|
||||
calls.push('publish');
|
||||
},
|
||||
}),
|
||||
createDiscordRuntime: (input) => ({
|
||||
refreshDiscordPresenceMediaDuration: async () => {},
|
||||
publishDiscordPresence: () => {
|
||||
calls.push(input.getCurrentMediaTitle() ?? 'unknown');
|
||||
input.getDiscordPresenceService()?.publish({
|
||||
mediaTitle: input.getCurrentMediaTitle(),
|
||||
mediaPath: input.getCurrentMediaPath(),
|
||||
subtitleText: input.getCurrentSubtitleText(),
|
||||
currentTimeSec: null,
|
||||
mediaDurationSec: input.getFallbackMediaDurationSec(),
|
||||
paused: input.getPlaybackPaused(),
|
||||
connected: false,
|
||||
sessionStartedAtMs: input.getSessionStartedAtMs(),
|
||||
});
|
||||
},
|
||||
}),
|
||||
now: () => 123,
|
||||
});
|
||||
|
||||
await runtime.initializeDiscordPresenceService();
|
||||
|
||||
assert.deepEqual(calls, ['start', 'Demo', 'publish']);
|
||||
});
|
||||
|
||||
test('discord presence lifecycle runtime stops the existing service before replacement', async () => {
|
||||
const calls: string[] = [];
|
||||
let service: { start: () => Promise<void>; stop: () => Promise<void> } | null = {
|
||||
start: async () => {
|
||||
calls.push('old-start');
|
||||
},
|
||||
stop: async () => {
|
||||
calls.push('old-stop');
|
||||
},
|
||||
};
|
||||
|
||||
const runtime = createDiscordPresenceLifecycleRuntime({
|
||||
getResolvedConfig: () => ({ discordPresence: { enabled: true } }),
|
||||
getDiscordPresenceService: () => service as never,
|
||||
setDiscordPresenceService: (next) => {
|
||||
service = next as typeof service;
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
getCurrentMediaTitle: () => 'Demo',
|
||||
getCurrentMediaPath: () => '/tmp/demo.mkv',
|
||||
getCurrentSubtitleText: () => 'subtitle',
|
||||
getPlaybackPaused: () => false,
|
||||
getFallbackMediaDurationSec: () => 12,
|
||||
createDiscordPresenceService: () => ({
|
||||
start: async () => {
|
||||
calls.push('new-start');
|
||||
},
|
||||
stop: async () => {
|
||||
calls.push('new-stop');
|
||||
},
|
||||
publish: () => {
|
||||
calls.push('publish');
|
||||
},
|
||||
}),
|
||||
createDiscordRuntime: () => ({
|
||||
refreshDiscordPresenceMediaDuration: async () => {},
|
||||
publishDiscordPresence: () => {
|
||||
calls.push('runtime-publish');
|
||||
},
|
||||
}),
|
||||
now: () => 123,
|
||||
});
|
||||
|
||||
await runtime.initializeDiscordPresenceService();
|
||||
|
||||
assert.deepEqual(calls, ['old-stop', 'new-start', 'runtime-publish']);
|
||||
});
|
||||
|
||||
test('discord presence lifecycle runtime stops the existing service when disabled', async () => {
|
||||
const calls: string[] = [];
|
||||
let service: { start: () => Promise<void>; stop: () => Promise<void> } | null = {
|
||||
start: async () => {
|
||||
calls.push('old-start');
|
||||
},
|
||||
stop: async () => {
|
||||
calls.push('old-stop');
|
||||
},
|
||||
};
|
||||
|
||||
const runtime = createDiscordPresenceLifecycleRuntime({
|
||||
getResolvedConfig: () => ({ discordPresence: { enabled: false } }),
|
||||
getDiscordPresenceService: () => service as never,
|
||||
setDiscordPresenceService: (next) => {
|
||||
service = next as typeof service;
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
getCurrentMediaTitle: () => 'Demo',
|
||||
getCurrentMediaPath: () => '/tmp/demo.mkv',
|
||||
getCurrentSubtitleText: () => 'subtitle',
|
||||
getPlaybackPaused: () => false,
|
||||
getFallbackMediaDurationSec: () => 12,
|
||||
createDiscordPresenceService: () => {
|
||||
calls.push('create');
|
||||
return {
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
publish: () => {},
|
||||
};
|
||||
},
|
||||
createDiscordRuntime: () => ({
|
||||
refreshDiscordPresenceMediaDuration: async () => {},
|
||||
publishDiscordPresence: () => {
|
||||
calls.push('runtime-publish');
|
||||
},
|
||||
}),
|
||||
now: () => 123,
|
||||
});
|
||||
|
||||
await runtime.initializeDiscordPresenceService();
|
||||
|
||||
assert.equal(service, null);
|
||||
assert.deepEqual(calls, ['old-stop']);
|
||||
});
|
||||
92
src/main/discord-presence-lifecycle-runtime.ts
Normal file
92
src/main/discord-presence-lifecycle-runtime.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { createDiscordPresenceRuntime } from './runtime/discord-presence-runtime';
|
||||
|
||||
type DiscordPresenceConfigLike = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type DiscordPresenceServiceLike = {
|
||||
start: () => Promise<void>;
|
||||
stop?: () => Promise<void>;
|
||||
publish: (snapshot: {
|
||||
mediaTitle: string | null;
|
||||
mediaPath: string | null;
|
||||
subtitleText: string;
|
||||
currentTimeSec: number | null;
|
||||
mediaDurationSec: number | null;
|
||||
paused: boolean | null;
|
||||
connected: boolean;
|
||||
sessionStartedAtMs: number;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export interface DiscordPresenceLifecycleRuntimeInput {
|
||||
getResolvedConfig: () => { discordPresence: DiscordPresenceConfigLike };
|
||||
getDiscordPresenceService: () => DiscordPresenceServiceLike | null;
|
||||
setDiscordPresenceService: (service: DiscordPresenceServiceLike | null) => void;
|
||||
getMpvClient: () => {
|
||||
connected?: boolean;
|
||||
currentTimePos?: number | null;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getCurrentSubtitleText: () => string;
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getFallbackMediaDurationSec: () => number | null;
|
||||
createDiscordPresenceService: (config: unknown) => DiscordPresenceServiceLike;
|
||||
createDiscordRuntime?: typeof createDiscordPresenceRuntime;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface DiscordPresenceLifecycleRuntime {
|
||||
publishDiscordPresence: () => void;
|
||||
initializeDiscordPresenceService: () => Promise<void>;
|
||||
stopDiscordPresenceService: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createDiscordPresenceLifecycleRuntime(
|
||||
input: DiscordPresenceLifecycleRuntimeInput,
|
||||
): DiscordPresenceLifecycleRuntime {
|
||||
let discordPresenceMediaDurationSec: number | null = null;
|
||||
const discordPresenceSessionStartedAtMs = input.now ? input.now() : Date.now();
|
||||
const stopCurrentDiscordPresenceService = async (): Promise<void> => {
|
||||
await input.getDiscordPresenceService()?.stop?.();
|
||||
input.setDiscordPresenceService(null);
|
||||
};
|
||||
|
||||
const discordPresenceRuntime = (input.createDiscordRuntime ?? createDiscordPresenceRuntime)({
|
||||
getDiscordPresenceService: () => input.getDiscordPresenceService(),
|
||||
isDiscordPresenceEnabled: () => input.getResolvedConfig().discordPresence.enabled === true,
|
||||
getMpvClient: () => input.getMpvClient(),
|
||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
||||
getCurrentSubtitleText: () => input.getCurrentSubtitleText(),
|
||||
getPlaybackPaused: () => input.getPlaybackPaused(),
|
||||
getFallbackMediaDurationSec: () => input.getFallbackMediaDurationSec(),
|
||||
getSessionStartedAtMs: () => discordPresenceSessionStartedAtMs,
|
||||
getMediaDurationSec: () => discordPresenceMediaDurationSec,
|
||||
setMediaDurationSec: (next) => {
|
||||
discordPresenceMediaDurationSec = next;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
publishDiscordPresence: () => {
|
||||
discordPresenceRuntime.publishDiscordPresence();
|
||||
},
|
||||
initializeDiscordPresenceService: async () => {
|
||||
if (input.getResolvedConfig().discordPresence.enabled !== true) {
|
||||
await stopCurrentDiscordPresenceService();
|
||||
return;
|
||||
}
|
||||
|
||||
await stopCurrentDiscordPresenceService();
|
||||
input.setDiscordPresenceService(
|
||||
input.createDiscordPresenceService(input.getResolvedConfig().discordPresence),
|
||||
);
|
||||
await input.getDiscordPresenceService()?.start();
|
||||
discordPresenceRuntime.publishDiscordPresence();
|
||||
},
|
||||
stopDiscordPresenceService: stopCurrentDiscordPresenceService,
|
||||
};
|
||||
}
|
||||
92
src/main/first-run-runtime-coordinator.ts
Normal file
92
src/main/first-run-runtime-coordinator.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import { getYomitanDictionaryInfo } from '../core/services';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import { createFirstRunRuntime } from './first-run-runtime';
|
||||
import type { AppState } from './state';
|
||||
|
||||
export interface FirstRunRuntimeCoordinatorInput {
|
||||
platform: NodeJS.Platform;
|
||||
configDir: string;
|
||||
homeDir: string;
|
||||
xdgConfigHome?: string;
|
||||
binaryPath: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
appDataDir: string;
|
||||
desktopDir: string;
|
||||
appState: Pick<AppState, 'firstRunSetupWindow' | 'firstRunSetupCompleted' | 'backgroundMode'>;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
yomitan: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
getParserRuntimeDeps: () => Parameters<typeof getYomitanDictionaryInfo>[0];
|
||||
openYomitanSettings: () => boolean;
|
||||
};
|
||||
overlay: {
|
||||
ensureTray: () => void;
|
||||
hasTray: () => boolean;
|
||||
};
|
||||
actions: {
|
||||
writeShortcutLink: (
|
||||
shortcutPath: string,
|
||||
operation: 'create' | 'update' | 'replace',
|
||||
details: {
|
||||
target: string;
|
||||
args?: string;
|
||||
cwd?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
iconIndex?: number;
|
||||
},
|
||||
) => boolean;
|
||||
requestAppQuit: () => void;
|
||||
};
|
||||
logger: {
|
||||
error: (message: string, error: unknown) => void;
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function createFirstRunRuntimeCoordinator(input: FirstRunRuntimeCoordinatorInput) {
|
||||
return createFirstRunRuntime<BrowserWindow>({
|
||||
platform: input.platform,
|
||||
configDir: input.configDir,
|
||||
homeDir: input.homeDir,
|
||||
xdgConfigHome: input.xdgConfigHome,
|
||||
binaryPath: input.binaryPath,
|
||||
appPath: input.appPath,
|
||||
resourcesPath: input.resourcesPath,
|
||||
appDataDir: input.appDataDir,
|
||||
desktopDir: input.desktopDir,
|
||||
getYomitanDictionaryCount: async () => {
|
||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
||||
const dictionaries = await getYomitanDictionaryInfo(input.yomitan.getParserRuntimeDeps(), {
|
||||
error: (message, ...args) => input.logger.error(message, args[0]),
|
||||
info: (message, ...args) => input.logger.info(message, ...args),
|
||||
});
|
||||
return dictionaries.length;
|
||||
},
|
||||
isExternalYomitanConfigured: () =>
|
||||
input.getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
||||
createBrowserWindow: (options) => {
|
||||
const window = new BrowserWindow(options);
|
||||
input.appState.firstRunSetupWindow = window;
|
||||
window.on('closed', () => {
|
||||
input.appState.firstRunSetupWindow = null;
|
||||
});
|
||||
return window;
|
||||
},
|
||||
writeShortcutLink: (shortcutPath, operation, details) =>
|
||||
input.actions.writeShortcutLink(shortcutPath, operation, details),
|
||||
openYomitanSettings: () => input.yomitan.openYomitanSettings(),
|
||||
shouldQuitWhenClosedIncomplete: () => !input.appState.backgroundMode,
|
||||
quitApp: () => input.actions.requestAppQuit(),
|
||||
logError: (message, error) => input.logger.error(message, error),
|
||||
onStateChanged: (state) => {
|
||||
input.appState.firstRunSetupCompleted = state.status === 'completed';
|
||||
if (input.overlay.hasTray()) {
|
||||
input.overlay.ensureTray();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
155
src/main/first-run-runtime.test.ts
Normal file
155
src/main/first-run-runtime.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createFirstRunRuntime } from './first-run-runtime';
|
||||
|
||||
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-runtime-test-'));
|
||||
const result = fn(dir);
|
||||
if (result instanceof Promise) {
|
||||
return result.finally(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function createMockSetupWindow() {
|
||||
const calls: string[] = [];
|
||||
let closedHandler: (() => void) | null = null;
|
||||
let navigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null;
|
||||
|
||||
return {
|
||||
calls,
|
||||
window: {
|
||||
webContents: {
|
||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => {
|
||||
if (event === 'will-navigate') {
|
||||
navigateHandler = handler;
|
||||
}
|
||||
},
|
||||
},
|
||||
loadURL: async (url: string) => {
|
||||
calls.push(`load:${url.slice(0, 24)}`);
|
||||
},
|
||||
on: (event: 'closed', handler: () => void) => {
|
||||
if (event === 'closed') {
|
||||
closedHandler = handler;
|
||||
}
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
close: () => {
|
||||
calls.push('close');
|
||||
closedHandler?.();
|
||||
},
|
||||
focus: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
triggerNavigate: (url: string) => {
|
||||
navigateHandler?.(
|
||||
{
|
||||
preventDefault: () => {
|
||||
calls.push('prevent-default');
|
||||
},
|
||||
},
|
||||
url,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('first-run runtime focuses an existing window instead of creating a new one', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
let createCount = 0;
|
||||
const mock = createMockSetupWindow();
|
||||
const runtime = createFirstRunRuntime({
|
||||
platform: 'darwin',
|
||||
configDir,
|
||||
homeDir: os.homedir(),
|
||||
binaryPath: process.execPath,
|
||||
appPath: '/app',
|
||||
resourcesPath: '/resources',
|
||||
appDataDir: path.join(root, 'appData'),
|
||||
desktopDir: path.join(root, 'desktop'),
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
isExternalYomitanConfigured: () => false,
|
||||
createBrowserWindow: () => {
|
||||
createCount += 1;
|
||||
return mock.window;
|
||||
},
|
||||
writeShortcutLink: () => true,
|
||||
openYomitanSettings: () => false,
|
||||
shouldQuitWhenClosedIncomplete: () => true,
|
||||
quitApp: () => {
|
||||
throw new Error('quit should not be called');
|
||||
},
|
||||
logError: () => {
|
||||
throw new Error('logError should not be called');
|
||||
},
|
||||
});
|
||||
|
||||
runtime.openFirstRunSetupWindow();
|
||||
runtime.openFirstRunSetupWindow();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(createCount, 1);
|
||||
assert.equal(mock.calls.filter((call) => call === 'focus').length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('first-run runtime closes the setup window after completion', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
const events: string[] = [];
|
||||
const mock = createMockSetupWindow();
|
||||
const runtime = createFirstRunRuntime({
|
||||
platform: 'linux',
|
||||
configDir,
|
||||
homeDir: os.homedir(),
|
||||
binaryPath: process.execPath,
|
||||
appPath: '/app',
|
||||
resourcesPath: '/resources',
|
||||
appDataDir: path.join(root, 'appData'),
|
||||
desktopDir: path.join(root, 'desktop'),
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
isExternalYomitanConfigured: () => false,
|
||||
createBrowserWindow: () => mock.window,
|
||||
writeShortcutLink: () => true,
|
||||
openYomitanSettings: () => false,
|
||||
shouldQuitWhenClosedIncomplete: () => true,
|
||||
quitApp: () => {
|
||||
events.push('quit');
|
||||
},
|
||||
logError: (message, error) => {
|
||||
events.push(`${message}:${String(error)}`);
|
||||
},
|
||||
onStateChanged: (state) => {
|
||||
events.push(state.status);
|
||||
},
|
||||
});
|
||||
|
||||
runtime.openFirstRunSetupWindow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
mock.window.triggerNavigate('subminer://first-run-setup?action=finish');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(runtime.isSetupCompleted(), true);
|
||||
assert.equal(events[0], 'in_progress');
|
||||
assert.equal(events.at(-1), 'completed');
|
||||
assert.equal(mock.calls.includes('close'), true);
|
||||
assert.equal(events.includes('quit'), false);
|
||||
});
|
||||
});
|
||||
235
src/main/first-run-runtime.ts
Normal file
235
src/main/first-run-runtime.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
createFirstRunSetupService,
|
||||
shouldAutoOpenFirstRunSetup,
|
||||
type FirstRunSetupService,
|
||||
type PluginInstallResult,
|
||||
type SetupStatusSnapshot,
|
||||
} from './runtime/first-run-setup-service';
|
||||
import type { SetupState } from '../shared/setup-state';
|
||||
import {
|
||||
buildFirstRunSetupHtml,
|
||||
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
||||
createOpenFirstRunSetupWindowHandler,
|
||||
parseFirstRunSetupSubmissionUrl,
|
||||
} from './runtime/first-run-setup-window';
|
||||
import { createCreateFirstRunSetupWindowHandler } from './runtime/setup-window-factory';
|
||||
import {
|
||||
detectInstalledFirstRunPlugin,
|
||||
installFirstRunPluginToDefaultLocation,
|
||||
syncInstalledFirstRunPluginBinaryPath,
|
||||
} from './runtime/first-run-setup-plugin';
|
||||
import {
|
||||
applyWindowsMpvShortcuts,
|
||||
detectWindowsMpvShortcuts,
|
||||
resolveWindowsMpvShortcutPaths,
|
||||
} from './runtime/windows-mpv-shortcuts';
|
||||
import { resolveDefaultMpvInstallPaths } from '../shared/setup-state';
|
||||
|
||||
export interface FirstRunSetupWindowLike {
|
||||
webContents: {
|
||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
|
||||
};
|
||||
loadURL: (url: string) => Promise<void> | void;
|
||||
on: (event: 'closed', handler: () => void) => void;
|
||||
isDestroyed: () => boolean;
|
||||
close: () => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
export interface FirstRunRuntimeInput<
|
||||
TWindow extends FirstRunSetupWindowLike = FirstRunSetupWindowLike,
|
||||
> {
|
||||
platform: NodeJS.Platform;
|
||||
configDir: string;
|
||||
homeDir: string;
|
||||
xdgConfigHome?: string;
|
||||
binaryPath: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
appDataDir: string;
|
||||
desktopDir: string;
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
isExternalYomitanConfigured: () => boolean;
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
||||
writeShortcutLink: (
|
||||
shortcutPath: string,
|
||||
operation: 'create' | 'update' | 'replace',
|
||||
details: {
|
||||
target: string;
|
||||
args?: string;
|
||||
cwd?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
iconIndex?: number;
|
||||
},
|
||||
) => boolean;
|
||||
openYomitanSettings: () => boolean;
|
||||
shouldQuitWhenClosedIncomplete: () => boolean;
|
||||
quitApp: () => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
onStateChanged?: (state: SetupState) => void;
|
||||
}
|
||||
|
||||
export interface FirstRunRuntime {
|
||||
ensureSetupStateInitialized: () => Promise<SetupStatusSnapshot>;
|
||||
isSetupCompleted: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
}
|
||||
|
||||
export function createFirstRunRuntime<TWindow extends FirstRunSetupWindowLike>(
|
||||
input: FirstRunRuntimeInput<TWindow>,
|
||||
): FirstRunRuntime {
|
||||
syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: input.platform,
|
||||
homeDir: input.homeDir,
|
||||
xdgConfigHome: input.xdgConfigHome,
|
||||
binaryPath: input.binaryPath,
|
||||
});
|
||||
|
||||
const firstRunSetupService = createFirstRunSetupService({
|
||||
platform: input.platform,
|
||||
configDir: input.configDir,
|
||||
getYomitanDictionaryCount: input.getYomitanDictionaryCount,
|
||||
isExternalYomitanConfigured: input.isExternalYomitanConfigured,
|
||||
detectPluginInstalled: () =>
|
||||
detectInstalledFirstRunPlugin(
|
||||
resolveDefaultMpvInstallPaths(input.platform, input.homeDir, input.xdgConfigHome),
|
||||
),
|
||||
installPlugin: async (): Promise<PluginInstallResult> =>
|
||||
installFirstRunPluginToDefaultLocation({
|
||||
platform: input.platform,
|
||||
homeDir: input.homeDir,
|
||||
xdgConfigHome: input.xdgConfigHome,
|
||||
dirname: __dirname,
|
||||
appPath: input.appPath,
|
||||
resourcesPath: input.resourcesPath,
|
||||
binaryPath: input.binaryPath,
|
||||
}),
|
||||
detectWindowsMpvShortcuts: async () =>
|
||||
detectWindowsMpvShortcuts(
|
||||
resolveWindowsMpvShortcutPaths({
|
||||
appDataDir: input.appDataDir,
|
||||
desktopDir: input.desktopDir,
|
||||
}),
|
||||
),
|
||||
applyWindowsMpvShortcuts: async (preferences) =>
|
||||
applyWindowsMpvShortcuts({
|
||||
preferences,
|
||||
paths: resolveWindowsMpvShortcutPaths({
|
||||
appDataDir: input.appDataDir,
|
||||
desktopDir: input.desktopDir,
|
||||
}),
|
||||
exePath: input.binaryPath,
|
||||
writeShortcutLink: (shortcutPath, operation, details) =>
|
||||
input.writeShortcutLink(shortcutPath, operation, details),
|
||||
}),
|
||||
onStateChanged: (state) => {
|
||||
input.onStateChanged?.(state);
|
||||
},
|
||||
});
|
||||
|
||||
let firstRunSetupWindow: TWindow | null = null;
|
||||
let firstRunSetupMessage: string | null = null;
|
||||
|
||||
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
|
||||
getSetupWindow: () => firstRunSetupWindow,
|
||||
});
|
||||
|
||||
const createSetupWindow = createCreateFirstRunSetupWindowHandler({
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) =>
|
||||
input.createBrowserWindow(options),
|
||||
});
|
||||
|
||||
const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => maybeFocusExistingFirstRunSetupWindow(),
|
||||
createSetupWindow: () => {
|
||||
const window = createSetupWindow();
|
||||
firstRunSetupWindow = window;
|
||||
return window;
|
||||
},
|
||||
getSetupSnapshot: async () => {
|
||||
const snapshot = await firstRunSetupService.getSetupStatus();
|
||||
return {
|
||||
...snapshot,
|
||||
message: firstRunSetupMessage,
|
||||
};
|
||||
},
|
||||
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
|
||||
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
|
||||
handleAction: async (submission) => {
|
||||
if (submission.action === 'install-plugin') {
|
||||
const snapshot = await firstRunSetupService.installMpvPlugin();
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (submission.action === 'configure-windows-mpv-shortcuts') {
|
||||
const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({
|
||||
startMenuEnabled: submission.startMenuEnabled === true,
|
||||
desktopEnabled: submission.desktopEnabled === true,
|
||||
});
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (submission.action === 'open-yomitan-settings') {
|
||||
firstRunSetupMessage = input.openYomitanSettings()
|
||||
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
|
||||
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (submission.action === 'refresh') {
|
||||
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (submission.action === 'skip-plugin') {
|
||||
await firstRunSetupService.skipPluginInstall();
|
||||
firstRunSetupMessage = 'mpv plugin installation skipped.';
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await firstRunSetupService.markSetupCompleted();
|
||||
if (snapshot.state.status === 'completed') {
|
||||
firstRunSetupMessage = null;
|
||||
return { closeWindow: true };
|
||||
}
|
||||
firstRunSetupMessage = 'Install at least one Yomitan dictionary before finishing setup.';
|
||||
return undefined;
|
||||
},
|
||||
markSetupInProgress: async () => {
|
||||
firstRunSetupMessage = null;
|
||||
await firstRunSetupService.markSetupInProgress();
|
||||
},
|
||||
markSetupCancelled: async () => {
|
||||
firstRunSetupMessage = null;
|
||||
await firstRunSetupService.markSetupCancelled();
|
||||
},
|
||||
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
|
||||
shouldQuitWhenClosedIncomplete: () => input.shouldQuitWhenClosedIncomplete(),
|
||||
quitApp: () => input.quitApp(),
|
||||
clearSetupWindow: () => {
|
||||
firstRunSetupWindow = null;
|
||||
},
|
||||
setSetupWindow: (window) => {
|
||||
firstRunSetupWindow = window;
|
||||
},
|
||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
||||
logError: (message, error) => input.logError(message, error),
|
||||
});
|
||||
|
||||
return {
|
||||
ensureSetupStateInitialized: () => firstRunSetupService.ensureSetupStateInitialized(),
|
||||
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => {
|
||||
if (firstRunSetupService.isSetupCompleted()) {
|
||||
return;
|
||||
}
|
||||
openFirstRunSetupWindowHandler();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { shouldAutoOpenFirstRunSetup };
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as path from 'path';
|
||||
import type { FrequencyDictionaryLookup } from '../types';
|
||||
import { createFrequencyDictionaryLookup } from '../core/services';
|
||||
import { createFrequencyDictionaryLookup } from '../core/services/frequency-dictionary';
|
||||
|
||||
export interface FrequencyDictionarySearchPathDeps {
|
||||
getDictionaryRoots: () => string[];
|
||||
|
||||
56
src/main/headless-known-word-refresh.ts
Normal file
56
src/main/headless-known-word-refresh.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { mergeAiConfig } from '../ai/config';
|
||||
import { AnkiIntegration } from '../anki-integration';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
export async function runHeadlessKnownWordRefresh(input: {
|
||||
resolvedConfig: ResolvedConfig;
|
||||
runtimeOptionsManager: {
|
||||
getEffectiveAnkiConnectConfig: (config: AnkiConnectConfig) => AnkiConnectConfig;
|
||||
} | null;
|
||||
userDataPath: string;
|
||||
logger: {
|
||||
error: (message: string, error?: unknown) => void;
|
||||
};
|
||||
requestAppQuit: () => void;
|
||||
}): Promise<void> {
|
||||
const effectiveAnkiConfig =
|
||||
input.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(input.resolvedConfig.ankiConnect) ??
|
||||
input.resolvedConfig.ankiConnect;
|
||||
|
||||
if (effectiveAnkiConfig.enabled !== true) {
|
||||
input.logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled');
|
||||
process.exitCode = 1;
|
||||
input.requestAppQuit();
|
||||
return;
|
||||
}
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
effectiveAnkiConfig,
|
||||
new SubtitleTimingTracker(),
|
||||
{ send: () => undefined } as never,
|
||||
undefined,
|
||||
undefined,
|
||||
async () => ({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: false,
|
||||
cancelled: true,
|
||||
}),
|
||||
path.join(input.userDataPath, 'known-words-cache.json'),
|
||||
mergeAiConfig(input.resolvedConfig.ai, effectiveAnkiConfig.ai),
|
||||
);
|
||||
|
||||
try {
|
||||
await integration.refreshKnownWordCache();
|
||||
} catch (error) {
|
||||
input.logger.error('Headless known-word refresh failed:', error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
integration.stop();
|
||||
input.requestAppQuit();
|
||||
}
|
||||
}
|
||||
176
src/main/headless-startup-runtime.test.ts
Normal file
176
src/main/headless-startup-runtime.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import type { LogLevelSource } from '../logger';
|
||||
import type { StartupBootstrapRuntimeFactoryDeps } from './startup';
|
||||
|
||||
import { createHeadlessStartupRuntime } from './headless-startup-runtime';
|
||||
|
||||
test('headless startup runtime returns callable handlers and applies startup state', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createHeadlessStartupRuntime<
|
||||
{ mode: string },
|
||||
{ startAppLifecycle: (args: CliArgs) => void }
|
||||
>({
|
||||
appLifecycleRuntimeRunnerMainDeps: {
|
||||
app: { on: () => {} } as never,
|
||||
platform: 'darwin',
|
||||
shouldStartApp: () => true,
|
||||
parseArgs: () => ({}) as never,
|
||||
handleCliCommand: () => {},
|
||||
printHelp: () => {},
|
||||
logNoRunningInstance: () => {},
|
||||
onReady: async () => {},
|
||||
onWillQuitCleanup: () => {},
|
||||
shouldRestoreWindowsOnActivate: () => false,
|
||||
restoreWindowsOnActivate: () => {},
|
||||
shouldQuitOnWindowAllClosed: () => false,
|
||||
},
|
||||
bootstrap: {
|
||||
argv: ['node', 'main.js'],
|
||||
parseArgs: () => ({ command: 'start' }) as never,
|
||||
setLogLevel: (_level: string, _source: LogLevelSource) => {},
|
||||
forceX11Backend: () => {},
|
||||
enforceUnsupportedWaylandMode: () => {},
|
||||
shouldStartApp: () => true,
|
||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
configDir: '/tmp/config',
|
||||
defaultConfig: {} as never,
|
||||
generateConfigTemplate: () => 'template',
|
||||
generateDefaultConfigFile: async () => 0,
|
||||
setExitCode: () => {},
|
||||
quitApp: () => {},
|
||||
logGenerateConfigError: () => {},
|
||||
startAppLifecycle: (args: CliArgs) => {
|
||||
calls.push(`bootstrap:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
},
|
||||
createAppLifecycleRuntimeRunner: () => (args: CliArgs) => {
|
||||
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => ({
|
||||
startAppLifecycle: deps.startAppLifecycle,
|
||||
}),
|
||||
runStartupBootstrapRuntime: (deps) => {
|
||||
deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs);
|
||||
return { mode: 'started' };
|
||||
},
|
||||
applyStartupState: (state: { mode: string }) => {
|
||||
calls.push(`apply:${state.mode}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof runtime.appLifecycleRuntimeRunner, 'function');
|
||||
assert.equal(typeof runtime.runAndApplyStartupState, 'function');
|
||||
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
|
||||
assert.deepEqual(calls, ['lifecycle:start', 'apply:started']);
|
||||
});
|
||||
|
||||
test('headless startup runtime accepts grouped app lifecycle input', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createHeadlessStartupRuntime<
|
||||
{ mode: string },
|
||||
{ startAppLifecycle: (args: CliArgs) => void }
|
||||
>({
|
||||
appLifecycle: {
|
||||
app: { on: () => {} } as never,
|
||||
platform: 'darwin',
|
||||
shouldStartApp: () => true,
|
||||
parseArgs: () => ({}) as never,
|
||||
handleCliCommand: () => {},
|
||||
printHelp: () => {},
|
||||
logNoRunningInstance: () => {},
|
||||
onReady: async () => {},
|
||||
onWillQuitCleanup: () => {},
|
||||
shouldRestoreWindowsOnActivate: () => false,
|
||||
restoreWindowsOnActivate: () => {},
|
||||
shouldQuitOnWindowAllClosed: () => false,
|
||||
},
|
||||
bootstrap: {
|
||||
argv: ['node', 'main.js'],
|
||||
parseArgs: () => ({ command: 'start' }) as never,
|
||||
setLogLevel: (_level: string, _source: LogLevelSource) => {},
|
||||
forceX11Backend: () => {},
|
||||
enforceUnsupportedWaylandMode: () => {},
|
||||
shouldStartApp: () => true,
|
||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
configDir: '/tmp/config',
|
||||
defaultConfig: {} as never,
|
||||
generateConfigTemplate: () => 'template',
|
||||
generateDefaultConfigFile: async () => 0,
|
||||
setExitCode: () => {},
|
||||
quitApp: () => {},
|
||||
logGenerateConfigError: () => {},
|
||||
startAppLifecycle: (args: CliArgs) => {
|
||||
calls.push(`bootstrap:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
},
|
||||
createAppLifecycleRuntimeRunner: () => (args: CliArgs) => {
|
||||
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => ({
|
||||
startAppLifecycle: deps.startAppLifecycle,
|
||||
}),
|
||||
runStartupBootstrapRuntime: (deps) => {
|
||||
deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs);
|
||||
return { mode: 'started' };
|
||||
},
|
||||
applyStartupState: (state: { mode: string }) => {
|
||||
calls.push(`apply:${state.mode}`);
|
||||
},
|
||||
});
|
||||
|
||||
runtime.appLifecycleRuntimeRunner({ command: 'start' } as unknown as CliArgs);
|
||||
|
||||
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
|
||||
assert.deepEqual(calls, ['lifecycle:start', 'lifecycle:start', 'apply:started']);
|
||||
});
|
||||
|
||||
createHeadlessStartupRuntime<
|
||||
{ mode: string },
|
||||
{ startAppLifecycle: (args: CliArgs) => void; customFlag: boolean }
|
||||
>(
|
||||
// @ts-expect-error custom bootstrap deps require an explicit factory
|
||||
{
|
||||
appLifecycleRuntimeRunnerMainDeps: {
|
||||
app: { on: () => {} } as never,
|
||||
platform: 'darwin',
|
||||
shouldStartApp: () => true,
|
||||
parseArgs: () => ({}) as never,
|
||||
handleCliCommand: () => {},
|
||||
printHelp: () => {},
|
||||
logNoRunningInstance: () => {},
|
||||
onReady: async () => {},
|
||||
onWillQuitCleanup: () => {},
|
||||
shouldRestoreWindowsOnActivate: () => false,
|
||||
restoreWindowsOnActivate: () => {},
|
||||
shouldQuitOnWindowAllClosed: () => false,
|
||||
},
|
||||
bootstrap: {
|
||||
argv: ['node', 'main.js'],
|
||||
parseArgs: () => ({ command: 'start' }) as never,
|
||||
setLogLevel: (_level: string, _source: LogLevelSource) => {},
|
||||
forceX11Backend: () => {},
|
||||
enforceUnsupportedWaylandMode: () => {},
|
||||
shouldStartApp: () => true,
|
||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
configDir: '/tmp/config',
|
||||
defaultConfig: {} as never,
|
||||
generateConfigTemplate: () => 'template',
|
||||
generateDefaultConfigFile: async () => 0,
|
||||
setExitCode: () => {},
|
||||
quitApp: () => {},
|
||||
logGenerateConfigError: () => {},
|
||||
startAppLifecycle: () => {},
|
||||
},
|
||||
runStartupBootstrapRuntime: (deps) => {
|
||||
assert.equal(deps.customFlag, true);
|
||||
return { mode: 'started' };
|
||||
},
|
||||
applyStartupState: () => {},
|
||||
});
|
||||
147
src/main/headless-startup-runtime.ts
Normal file
147
src/main/headless-startup-runtime.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import type { LogLevelSource } from '../logger';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import type { StartupBootstrapRuntimeDeps } from '../core/services/startup';
|
||||
import { createAppLifecycleDepsRuntime, startAppLifecycle } from '../core/services/app-lifecycle';
|
||||
import type { AppLifecycleDepsRuntimeOptions } from '../core/services/app-lifecycle';
|
||||
import type { AppLifecycleRuntimeRunnerParams } from './startup-lifecycle';
|
||||
import type { StartupBootstrapRuntimeFactoryDeps } from './startup';
|
||||
import { createStartupBootstrapRuntimeDeps } from './startup';
|
||||
import { composeHeadlessStartupHandlers } from './runtime/composers/headless-startup-composer';
|
||||
import { createAppLifecycleRuntimeDeps } from './app-lifecycle';
|
||||
import { createBuildAppLifecycleRuntimeRunnerMainDepsHandler } from './runtime/startup-lifecycle-main-deps';
|
||||
|
||||
export interface HeadlessStartupBootstrapInput {
|
||||
argv: string[];
|
||||
parseArgs: (argv: string[]) => CliArgs;
|
||||
setLogLevel: (level: string, source: LogLevelSource) => void;
|
||||
forceX11Backend: (args: CliArgs) => void;
|
||||
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
|
||||
shouldStartApp: (args: CliArgs) => boolean;
|
||||
getDefaultSocketPath: () => string;
|
||||
defaultTexthookerPort: number;
|
||||
configDir: string;
|
||||
defaultConfig: ResolvedConfig;
|
||||
generateConfigTemplate: (config: ResolvedConfig) => string;
|
||||
generateDefaultConfigFile: (
|
||||
args: CliArgs,
|
||||
options: {
|
||||
configDir: string;
|
||||
defaultConfig: unknown;
|
||||
generateTemplate: (config: unknown) => string;
|
||||
},
|
||||
) => Promise<number>;
|
||||
setExitCode: (code: number) => void;
|
||||
quitApp: () => void;
|
||||
logGenerateConfigError: (message: string) => void;
|
||||
startAppLifecycle: (args: CliArgs) => void;
|
||||
}
|
||||
|
||||
export type HeadlessStartupAppLifecycleInput = AppLifecycleRuntimeRunnerParams;
|
||||
|
||||
interface HeadlessStartupRuntimeSharedInput<TStartupState> {
|
||||
appLifecycleRuntimeRunnerMainDeps?: AppLifecycleDepsRuntimeOptions;
|
||||
appLifecycle?: HeadlessStartupAppLifecycleInput;
|
||||
bootstrap: HeadlessStartupBootstrapInput;
|
||||
createAppLifecycleRuntimeRunner?: (
|
||||
params: AppLifecycleDepsRuntimeOptions,
|
||||
) => (args: CliArgs) => void;
|
||||
applyStartupState: (startupState: TStartupState) => void;
|
||||
}
|
||||
|
||||
export interface HeadlessStartupRuntimeDefaultInput<TStartupState>
|
||||
extends HeadlessStartupRuntimeSharedInput<TStartupState> {
|
||||
createStartupBootstrapRuntimeDeps?: undefined;
|
||||
runStartupBootstrapRuntime: (deps: StartupBootstrapRuntimeDeps) => TStartupState;
|
||||
}
|
||||
|
||||
export interface HeadlessStartupRuntimeCustomInput<TStartupState, TStartupBootstrapRuntimeDeps>
|
||||
extends HeadlessStartupRuntimeSharedInput<TStartupState> {
|
||||
createStartupBootstrapRuntimeDeps: (
|
||||
deps: StartupBootstrapRuntimeFactoryDeps,
|
||||
) => TStartupBootstrapRuntimeDeps;
|
||||
runStartupBootstrapRuntime: (deps: TStartupBootstrapRuntimeDeps) => TStartupState;
|
||||
}
|
||||
|
||||
export type HeadlessStartupRuntimeInput<
|
||||
TStartupState,
|
||||
TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps,
|
||||
> =
|
||||
| HeadlessStartupRuntimeDefaultInput<TStartupState>
|
||||
| HeadlessStartupRuntimeCustomInput<TStartupState, TStartupBootstrapRuntimeDeps>;
|
||||
|
||||
export interface HeadlessStartupRuntime<TStartupState> {
|
||||
appLifecycleRuntimeRunner: (args: CliArgs) => void;
|
||||
runAndApplyStartupState: () => TStartupState;
|
||||
}
|
||||
|
||||
function resolveAppLifecycleRuntimeRunnerMainDeps(
|
||||
input: Pick<
|
||||
HeadlessStartupRuntimeSharedInput<unknown>,
|
||||
'appLifecycleRuntimeRunnerMainDeps' | 'appLifecycle'
|
||||
>,
|
||||
) {
|
||||
const appLifecycleRuntimeRunnerMainDeps =
|
||||
input.appLifecycleRuntimeRunnerMainDeps ?? input.appLifecycle;
|
||||
|
||||
if (!appLifecycleRuntimeRunnerMainDeps) {
|
||||
throw new Error('Headless startup runtime needs app lifecycle runtime runner deps');
|
||||
}
|
||||
|
||||
return createBuildAppLifecycleRuntimeRunnerMainDepsHandler(appLifecycleRuntimeRunnerMainDeps)();
|
||||
}
|
||||
|
||||
function buildHeadlessStartupHandlersDeps<TStartupState>(
|
||||
input: HeadlessStartupRuntimeSharedInput<TStartupState>,
|
||||
) {
|
||||
const appLifecycleRuntimeRunnerMainDeps =
|
||||
resolveAppLifecycleRuntimeRunnerMainDeps(input);
|
||||
|
||||
return {
|
||||
appLifecycleRuntimeRunnerMainDeps,
|
||||
createAppLifecycleRuntimeRunner:
|
||||
input.createAppLifecycleRuntimeRunner ??
|
||||
((params: AppLifecycleDepsRuntimeOptions) => (args: CliArgs) =>
|
||||
startAppLifecycle(args, createAppLifecycleDepsRuntime(createAppLifecycleRuntimeDeps(params)))),
|
||||
buildStartupBootstrapMainDeps: (startAppLifecycle: (args: CliArgs) => void) => ({
|
||||
...input.bootstrap,
|
||||
startAppLifecycle,
|
||||
}),
|
||||
applyStartupState: input.applyStartupState,
|
||||
};
|
||||
}
|
||||
|
||||
export function createHeadlessStartupRuntime<TStartupState>(
|
||||
input: HeadlessStartupRuntimeDefaultInput<TStartupState>,
|
||||
): HeadlessStartupRuntime<TStartupState>;
|
||||
export function createHeadlessStartupRuntime<TStartupState, TStartupBootstrapRuntimeDeps>(
|
||||
input: HeadlessStartupRuntimeCustomInput<TStartupState, TStartupBootstrapRuntimeDeps>,
|
||||
): HeadlessStartupRuntime<TStartupState>;
|
||||
export function createHeadlessStartupRuntime<TStartupState, TStartupBootstrapRuntimeDeps>(
|
||||
input: HeadlessStartupRuntimeInput<TStartupState, TStartupBootstrapRuntimeDeps>,
|
||||
): HeadlessStartupRuntime<TStartupState> {
|
||||
const baseDeps = buildHeadlessStartupHandlersDeps(input);
|
||||
const { appLifecycleRuntimeRunner, runAndApplyStartupState } =
|
||||
'createStartupBootstrapRuntimeDeps' in input && input.createStartupBootstrapRuntimeDeps
|
||||
? composeHeadlessStartupHandlers<CliArgs, TStartupState, TStartupBootstrapRuntimeDeps>({
|
||||
startupRuntimeHandlersDeps: {
|
||||
...baseDeps,
|
||||
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) =>
|
||||
input.createStartupBootstrapRuntimeDeps(deps),
|
||||
runStartupBootstrapRuntime: input.runStartupBootstrapRuntime,
|
||||
},
|
||||
})
|
||||
: composeHeadlessStartupHandlers<CliArgs, TStartupState, StartupBootstrapRuntimeDeps>({
|
||||
startupRuntimeHandlersDeps: {
|
||||
...baseDeps,
|
||||
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) =>
|
||||
createStartupBootstrapRuntimeDeps(deps),
|
||||
runStartupBootstrapRuntime: input.runStartupBootstrapRuntime,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
appLifecycleRuntimeRunner,
|
||||
runAndApplyStartupState,
|
||||
};
|
||||
}
|
||||
258
src/main/ipc-runtime-bootstrap.ts
Normal file
258
src/main/ipc-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { AnkiIntegration } from '../anki-integration';
|
||||
import type {
|
||||
JimakuApiResponse,
|
||||
JimakuLanguagePreference,
|
||||
KikuFieldGroupingChoice,
|
||||
ResolvedConfig,
|
||||
SubsyncManualRunRequest,
|
||||
SubsyncResult,
|
||||
} from '../types';
|
||||
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from '../jimaku/utils';
|
||||
import { applyRuntimeOptionResultRuntime } from '../core/services/runtime-options-ipc';
|
||||
import {
|
||||
playNextSubtitleRuntime,
|
||||
replayCurrentSubtitleRuntime,
|
||||
sendMpvCommandRuntime,
|
||||
} from '../core/services';
|
||||
import type { ConfigService } from '../config';
|
||||
import { applyControllerConfigUpdate } from './controller-config-update.js';
|
||||
import type { AnilistRuntime } from './anilist-runtime';
|
||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
||||
import { createIpcRuntimeFromMainState, type IpcRuntime } from './ipc-runtime';
|
||||
import type { MiningRuntime } from './mining-runtime';
|
||||
import type { MpvRuntime } from './mpv-runtime';
|
||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
||||
import type { OverlayUiRuntime } from './overlay-ui-runtime';
|
||||
import type { AppState } from './state';
|
||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
||||
import type { PlaylistBrowserIpcRuntime } from './runtime/playlist-browser-ipc';
|
||||
import type { YoutubeRuntime } from './youtube-runtime';
|
||||
import { resolveSubtitleStyleForRenderer } from './runtime/domains/overlay';
|
||||
import type { ShortcutsRuntime } from './shortcuts-runtime';
|
||||
|
||||
type OverlayManagerLike = {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
};
|
||||
|
||||
type OverlayUiLike = Pick<
|
||||
OverlayUiRuntime<BrowserWindow>,
|
||||
| 'broadcastRuntimeOptionsChanged'
|
||||
| 'handleOverlayModalClosed'
|
||||
| 'openRuntimeOptionsPalette'
|
||||
| 'toggleVisibleOverlay'
|
||||
>;
|
||||
|
||||
type OverlayContentMeasurementStoreLike = {
|
||||
report: (payload: unknown) => void;
|
||||
};
|
||||
|
||||
type ConfigDerivedRuntimeLike = {
|
||||
jimakuFetchJson: <T>(
|
||||
endpoint: string,
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => Promise<JimakuApiResponse<T>>;
|
||||
getJimakuMaxEntryResults: () => number;
|
||||
getJimakuLanguagePreference: () => JimakuLanguagePreference;
|
||||
resolveJimakuApiKey: () => Promise<string | null>;
|
||||
};
|
||||
|
||||
type SubsyncRuntimeLike = {
|
||||
triggerFromConfig: () => Promise<void>;
|
||||
runManualFromIpc: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
};
|
||||
|
||||
export interface IpcRuntimeBootstrapInput {
|
||||
appState: AppState;
|
||||
userDataPath: string;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
configService: Pick<ConfigService, 'getRawConfig' | 'patchRawConfig'>;
|
||||
overlay: {
|
||||
manager: OverlayManagerLike;
|
||||
getOverlayUi: () => OverlayUiLike | undefined;
|
||||
modalRuntime: Pick<OverlayModalRuntime, 'notifyOverlayModalOpened'>;
|
||||
contentMeasurementStore: OverlayContentMeasurementStoreLike;
|
||||
};
|
||||
subtitle: SubtitleRuntime;
|
||||
mpvRuntime: Pick<MpvRuntime, 'shiftSubtitleDelayToAdjacentCue' | 'showMpvOsd'>;
|
||||
shortcuts: Pick<ShortcutsRuntime, 'getConfiguredShortcuts'>;
|
||||
actions: {
|
||||
requestAppQuit: () => void;
|
||||
openYomitanSettings: () => boolean;
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
||||
};
|
||||
runtimes: {
|
||||
youtube: Pick<YoutubeRuntime, 'openYoutubeTrackPickerFromPlayback' | 'resolveActivePicker'>;
|
||||
anilist: Pick<
|
||||
AnilistRuntime,
|
||||
| 'getStatusSnapshot'
|
||||
| 'clearTokenState'
|
||||
| 'openAnilistSetupWindow'
|
||||
| 'getQueueStatusSnapshot'
|
||||
| 'processNextAnilistRetryUpdate'
|
||||
>;
|
||||
mining: Pick<MiningRuntime, 'appendClipboardVideoToQueue'>;
|
||||
dictionarySupport: Pick<
|
||||
DictionarySupportRuntime,
|
||||
| 'createFieldGroupingCallback'
|
||||
| 'getFieldGroupingResolver'
|
||||
| 'setFieldGroupingResolver'
|
||||
| 'resolveMediaPathForJimaku'
|
||||
>;
|
||||
playlistBrowser: Pick<PlaylistBrowserIpcRuntime, 'playlistBrowserMainDeps'>;
|
||||
configDerived: ConfigDerivedRuntimeLike;
|
||||
subsync: SubsyncRuntimeLike;
|
||||
};
|
||||
}
|
||||
|
||||
export function createIpcRuntimeBootstrap(input: IpcRuntimeBootstrapInput): IpcRuntime {
|
||||
return createIpcRuntimeFromMainState({
|
||||
mpv: {
|
||||
mainDeps: {
|
||||
triggerSubsyncFromConfig: () => input.runtimes.subsync.triggerFromConfig(),
|
||||
openRuntimeOptionsPalette: () => input.overlay.getOverlayUi()?.openRuntimeOptionsPalette(),
|
||||
openYoutubeTrackPicker: () => input.runtimes.youtube.openYoutubeTrackPickerFromPlayback(),
|
||||
openPlaylistBrowser: () => input.actions.openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => {
|
||||
if (!input.appState.runtimeOptionsManager) {
|
||||
return { ok: false, error: 'Runtime options manager unavailable' };
|
||||
}
|
||||
return applyRuntimeOptionResultRuntime(
|
||||
input.appState.runtimeOptionsManager.cycleOption(id, direction),
|
||||
(text) => input.mpvRuntime.showMpvOsd(text),
|
||||
);
|
||||
},
|
||||
showMpvOsd: (text) => input.mpvRuntime.showMpvOsd(text),
|
||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(input.appState.mpvClient),
|
||||
playNextSubtitle: () => playNextSubtitleRuntime(input.appState.mpvClient),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
input.mpvRuntime.shiftSubtitleDelayToAdjacentCue(direction),
|
||||
sendMpvCommand: (rawCommand) => sendMpvCommandRuntime(input.appState.mpvClient, rawCommand),
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
isMpvConnected: () =>
|
||||
Boolean(input.appState.mpvClient && input.appState.mpvClient.connected),
|
||||
hasRuntimeOptionsManager: () => input.appState.runtimeOptionsManager !== null,
|
||||
},
|
||||
runSubsyncManualFromIpc: (request) => input.runtimes.subsync.runManualFromIpc(request),
|
||||
},
|
||||
runtimeOptions: {
|
||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
||||
showMpvOsd: (text) => input.mpvRuntime.showMpvOsd(text),
|
||||
},
|
||||
main: {
|
||||
window: {
|
||||
getMainWindow: () => input.overlay.manager.getMainWindow(),
|
||||
getVisibleOverlayVisibility: () => input.overlay.manager.getVisibleOverlayVisible(),
|
||||
focusMainWindow: () => {
|
||||
const mainWindow = input.overlay.manager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (!mainWindow.isFocused()) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
},
|
||||
onOverlayModalClosed: (modal) =>
|
||||
input.overlay.getOverlayUi()?.handleOverlayModalClosed(modal),
|
||||
onOverlayModalOpened: (modal) => {
|
||||
input.overlay.modalRuntime.notifyOverlayModalOpened(modal);
|
||||
},
|
||||
onYoutubePickerResolve: (request) => input.runtimes.youtube.resolveActivePicker(request),
|
||||
openYomitanSettings: () => input.actions.openYomitanSettings(),
|
||||
quitApp: () => input.actions.requestAppQuit(),
|
||||
toggleVisibleOverlay: () => input.overlay.getOverlayUi()?.toggleVisibleOverlay(),
|
||||
},
|
||||
subtitle: {
|
||||
tokenizeCurrentSubtitle: async () => await input.subtitle.tokenizeCurrentSubtitle(),
|
||||
getCurrentSubtitleRaw: () => input.appState.currentSubText,
|
||||
getCurrentSubtitleAss: () => input.appState.currentSubAssText,
|
||||
getSubtitleSidebarSnapshot: async () => await input.subtitle.getSubtitleSidebarSnapshot(),
|
||||
getPlaybackPaused: () => input.appState.playbackPaused,
|
||||
getSubtitlePosition: () => input.subtitle.loadSubtitlePosition(),
|
||||
getSubtitleStyle: () => resolveSubtitleStyleForRenderer(input.getResolvedConfig()),
|
||||
saveSubtitlePosition: (position) => input.subtitle.saveSubtitlePosition(position),
|
||||
getMecabTokenizer: () => input.appState.mecabTokenizer,
|
||||
getKeybindings: () => input.appState.keybindings,
|
||||
getConfiguredShortcuts: () => input.shortcuts.getConfiguredShortcuts(),
|
||||
getStatsToggleKey: () => input.getResolvedConfig().stats.toggleKey,
|
||||
getMarkWatchedKey: () => input.getResolvedConfig().stats.markWatchedKey,
|
||||
getSecondarySubMode: () => input.appState.secondarySubMode,
|
||||
},
|
||||
controller: {
|
||||
getControllerConfig: () => input.getResolvedConfig().controller,
|
||||
saveControllerConfig: (update) => {
|
||||
const currentRawConfig = input.configService.getRawConfig();
|
||||
input.configService.patchRawConfig({
|
||||
controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
|
||||
});
|
||||
},
|
||||
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
|
||||
input.configService.patchRawConfig({
|
||||
controller: {
|
||||
preferredGamepadId,
|
||||
preferredGamepadLabel,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
getAnkiConnectStatus: () => input.appState.ankiIntegration !== null,
|
||||
getRuntimeOptions: () => input.appState.runtimeOptionsManager?.listOptions() ?? [],
|
||||
reportOverlayContentBounds: (payload) => {
|
||||
input.overlay.contentMeasurementStore.report(payload);
|
||||
},
|
||||
getImmersionTracker: () => input.appState.immersionTracker,
|
||||
},
|
||||
playlistBrowser: input.runtimes.playlistBrowser.playlistBrowserMainDeps,
|
||||
anilist: {
|
||||
getStatus: () => input.runtimes.anilist.getStatusSnapshot(),
|
||||
clearToken: () => input.runtimes.anilist.clearTokenState(),
|
||||
openSetup: () => input.runtimes.anilist.openAnilistSetupWindow(),
|
||||
getQueueStatus: () => input.runtimes.anilist.getQueueStatusSnapshot(),
|
||||
retryQueueNow: () => input.runtimes.anilist.processNextAnilistRetryUpdate(),
|
||||
},
|
||||
mining: {
|
||||
appendClipboardVideoToQueue: () => input.runtimes.mining.appendClipboardVideoToQueue(),
|
||||
},
|
||||
},
|
||||
ankiJimaku: {
|
||||
patchAnkiConnectEnabled: (enabled) => {
|
||||
input.configService.patchRawConfig({ ankiConnect: { enabled } });
|
||||
},
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
||||
getSubtitleTimingTracker: () => input.appState.subtitleTimingTracker,
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
getAnkiIntegration: () => input.appState.ankiIntegration,
|
||||
setAnkiIntegration: (integration) => input.actions.setAnkiIntegration(integration),
|
||||
getKnownWordCacheStatePath: () => path.join(input.userDataPath, 'known-words-cache.json'),
|
||||
showDesktopNotification: input.actions.showDesktopNotification,
|
||||
createFieldGroupingCallback: () =>
|
||||
input.runtimes.dictionarySupport.createFieldGroupingCallback(),
|
||||
broadcastRuntimeOptionsChanged: () =>
|
||||
input.overlay.getOverlayUi()?.broadcastRuntimeOptionsChanged(),
|
||||
getFieldGroupingResolver: () => input.runtimes.dictionarySupport.getFieldGroupingResolver(),
|
||||
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) =>
|
||||
input.runtimes.dictionarySupport.setFieldGroupingResolver(resolver),
|
||||
parseMediaInfo: (mediaPath: string | null) =>
|
||||
parseMediaInfo(input.runtimes.dictionarySupport.resolveMediaPathForJimaku(mediaPath)),
|
||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
||||
jimakuFetchJson: <T>(
|
||||
endpoint: string,
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
): Promise<JimakuApiResponse<T>> =>
|
||||
input.runtimes.configDerived.jimakuFetchJson<T>(endpoint, query),
|
||||
getJimakuMaxEntryResults: () => input.runtimes.configDerived.getJimakuMaxEntryResults(),
|
||||
getJimakuLanguagePreference: () => input.runtimes.configDerived.getJimakuLanguagePreference(),
|
||||
resolveJimakuApiKey: () => input.runtimes.configDerived.resolveJimakuApiKey(),
|
||||
isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath),
|
||||
downloadToFile: (url: string, destPath: string, headers: Record<string, string>) =>
|
||||
downloadToFile(url, destPath, headers),
|
||||
},
|
||||
});
|
||||
}
|
||||
43
src/main/ipc-runtime-services.ts
Normal file
43
src/main/ipc-runtime-services.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
createIpcDepsRuntime,
|
||||
registerAnkiJimakuIpcRuntime,
|
||||
registerIpcHandlers,
|
||||
} from '../core/services';
|
||||
import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc';
|
||||
import {
|
||||
createAnkiJimakuIpcRuntimeServiceDeps,
|
||||
createMainIpcRuntimeServiceDeps,
|
||||
createRuntimeOptionsIpcDeps,
|
||||
} from './dependencies';
|
||||
import type {
|
||||
AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
MainIpcRuntimeServiceDepsParams,
|
||||
} from './dependencies';
|
||||
import type { RegisterIpcRuntimeServicesParams } from './ipc-runtime';
|
||||
|
||||
export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void {
|
||||
registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params)));
|
||||
}
|
||||
|
||||
export function registerAnkiJimakuIpcRuntimeServices(
|
||||
params: AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
): void {
|
||||
registerAnkiJimakuIpcRuntime(
|
||||
createAnkiJimakuIpcRuntimeServiceDeps(params),
|
||||
registerAnkiJimakuIpcHandlers,
|
||||
);
|
||||
}
|
||||
|
||||
export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void {
|
||||
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
|
||||
getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager,
|
||||
showMpvOsd: params.runtimeOptions.showMpvOsd,
|
||||
});
|
||||
|
||||
registerMainIpcRuntimeServices({
|
||||
...params.mainDeps,
|
||||
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
|
||||
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
|
||||
});
|
||||
registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps);
|
||||
}
|
||||
196
src/main/ipc-runtime.test.ts
Normal file
196
src/main/ipc-runtime.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createIpcRuntime, createIpcRuntimeFromMainState } from './ipc-runtime';
|
||||
|
||||
function createBaseRuntimeInput(capturedRegistration: { value: unknown | null }) {
|
||||
const manualResult = { ok: true, summary: 'done' };
|
||||
const main = {
|
||||
window: {
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
focusMainWindow: () => {},
|
||||
onOverlayModalClosed: () => {},
|
||||
onOverlayModalOpened: () => {},
|
||||
onYoutubePickerResolve: async () => ({ ok: true }) as never,
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
},
|
||||
playlistBrowser: {
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: null,
|
||||
directoryAvailable: false,
|
||||
directoryStatus: '',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: null,
|
||||
currentFilePath: null,
|
||||
}),
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
subtitle: {
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getSubtitleSidebarSnapshot: async () => null as never,
|
||||
getPlaybackPaused: () => false,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: () => {},
|
||||
getMecabTokenizer: () => null,
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => null,
|
||||
getStatsToggleKey: () => '',
|
||||
getMarkWatchedKey: () => '',
|
||||
getSecondarySubMode: () => 'hover',
|
||||
},
|
||||
controller: {
|
||||
getControllerConfig: () => ({}) as never,
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
},
|
||||
runtime: {
|
||||
getMpvClient: () => null,
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => [],
|
||||
reportOverlayContentBounds: () => {},
|
||||
getImmersionTracker: () => null,
|
||||
},
|
||||
anilist: {
|
||||
getStatus: () => null,
|
||||
clearToken: () => {},
|
||||
openSetup: () => {},
|
||||
getQueueStatus: () => null,
|
||||
retryQueueNow: async () => ({ ok: true, message: 'ok' }) as never,
|
||||
},
|
||||
mining: {
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
};
|
||||
const ankiJimaku = {
|
||||
patchAnkiConnectEnabled: () => {},
|
||||
getResolvedConfig: () => ({}),
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getMpvClient: () => null,
|
||||
getAnkiIntegration: () => null,
|
||||
setAnkiIntegration: () => {},
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words.json',
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({}) as never,
|
||||
broadcastRuntimeOptionsChanged: () => {},
|
||||
getFieldGroupingResolver: () => null,
|
||||
setFieldGroupingResolver: () => {},
|
||||
parseMediaInfo: () => ({}) as never,
|
||||
getCurrentMediaPath: () => null,
|
||||
jimakuFetchJson: async () => ({ data: null, error: null }) as never,
|
||||
getJimakuMaxEntryResults: () => 5,
|
||||
getJimakuLanguagePreference: () => 'ja' as const,
|
||||
resolveJimakuApiKey: async () => null,
|
||||
isRemoteMediaPath: () => false,
|
||||
downloadToFile: async () => ({ ok: true, path: '/tmp/file' }) as never,
|
||||
};
|
||||
|
||||
return {
|
||||
mpv: {
|
||||
mainDeps: {
|
||||
triggerSubsyncFromConfig: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
openPlaylistBrowser: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||
sendMpvCommand: () => {},
|
||||
getMpvClient: () => null,
|
||||
isMpvConnected: () => true,
|
||||
hasRuntimeOptionsManager: () => true,
|
||||
},
|
||||
handleMpvCommandFromIpcRuntime: () => {},
|
||||
runSubsyncManualFromIpc: async () => manualResult as never,
|
||||
},
|
||||
main,
|
||||
ankiJimaku,
|
||||
registration: {
|
||||
runtimeOptions: {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
showMpvOsd: () => {},
|
||||
},
|
||||
main,
|
||||
ankiJimaku,
|
||||
registerIpcRuntimeServices: (params: unknown) => {
|
||||
capturedRegistration.value = params;
|
||||
},
|
||||
},
|
||||
registerIpcRuntimeServices: (params: unknown) => {
|
||||
capturedRegistration.value = params;
|
||||
},
|
||||
manualResult,
|
||||
};
|
||||
}
|
||||
|
||||
test('ipc runtime registers composed IPC handlers from explicit registration input', async () => {
|
||||
const capturedRegistration = { value: null as unknown | null };
|
||||
const input = createBaseRuntimeInput(capturedRegistration);
|
||||
|
||||
const runtime = createIpcRuntime({
|
||||
mpv: input.mpv,
|
||||
registration: input.registration,
|
||||
});
|
||||
|
||||
runtime.registerIpcRuntimeHandlers();
|
||||
|
||||
assert.ok(capturedRegistration.value);
|
||||
const registration = capturedRegistration.value as {
|
||||
runtimeOptions: { showMpvOsd: unknown };
|
||||
mainDeps: {
|
||||
getPlaylistBrowserSnapshot: unknown;
|
||||
handleMpvCommand: unknown;
|
||||
runSubsyncManual: (payload: unknown) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
assert.equal(registration.runtimeOptions.showMpvOsd !== undefined, true);
|
||||
assert.equal(registration.mainDeps.getPlaylistBrowserSnapshot instanceof Function, true);
|
||||
assert.equal(registration.mainDeps.handleMpvCommand instanceof Function, true);
|
||||
assert.deepEqual(
|
||||
await registration.mainDeps.runSubsyncManual({ payload: null } as never),
|
||||
input.manualResult,
|
||||
);
|
||||
});
|
||||
|
||||
test('ipc runtime builds grouped registration input from main state', async () => {
|
||||
const capturedRegistration = { value: null as unknown | null };
|
||||
const input = createBaseRuntimeInput(capturedRegistration);
|
||||
|
||||
const runtime = createIpcRuntimeFromMainState({
|
||||
mpv: input.mpv,
|
||||
runtimeOptions: input.registration.runtimeOptions,
|
||||
main: input.main,
|
||||
ankiJimaku: input.ankiJimaku,
|
||||
});
|
||||
|
||||
runtime.registerIpcRuntimeHandlers();
|
||||
|
||||
assert.ok(capturedRegistration.value);
|
||||
const registration = capturedRegistration.value as {
|
||||
runtimeOptions: { showMpvOsd: unknown };
|
||||
mainDeps: {
|
||||
getPlaylistBrowserSnapshot: unknown;
|
||||
handleMpvCommand: unknown;
|
||||
runSubsyncManual: (payload: unknown) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
assert.equal(registration.runtimeOptions.showMpvOsd !== undefined, true);
|
||||
assert.equal(registration.mainDeps.getPlaylistBrowserSnapshot instanceof Function, true);
|
||||
assert.equal(registration.mainDeps.handleMpvCommand instanceof Function, true);
|
||||
assert.deepEqual(
|
||||
await registration.mainDeps.runSubsyncManual({ payload: null } as never),
|
||||
input.manualResult,
|
||||
);
|
||||
});
|
||||
@@ -1,17 +1,15 @@
|
||||
import {
|
||||
createIpcDepsRuntime,
|
||||
registerAnkiJimakuIpcRuntime,
|
||||
registerIpcHandlers,
|
||||
} from '../core/services';
|
||||
import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc';
|
||||
import {
|
||||
createAnkiJimakuIpcRuntimeServiceDeps,
|
||||
import type {
|
||||
AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
createMainIpcRuntimeServiceDeps,
|
||||
MainIpcRuntimeServiceDepsParams,
|
||||
createRuntimeOptionsIpcDeps,
|
||||
RuntimeOptionsIpcDepsParams,
|
||||
} from './dependencies';
|
||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './dependencies';
|
||||
import {
|
||||
handleMpvCommandFromIpcRuntime,
|
||||
type MpvCommandFromIpcRuntimeDeps,
|
||||
} from './ipc-mpv-command';
|
||||
import { registerIpcRuntimeServices } from './ipc-runtime-services';
|
||||
import { composeIpcRuntimeHandlers } from './runtime/composers/ipc-runtime-composer';
|
||||
|
||||
export interface RegisterIpcRuntimeServicesParams {
|
||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
||||
@@ -19,28 +17,140 @@ export interface RegisterIpcRuntimeServicesParams {
|
||||
ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||
}
|
||||
|
||||
export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void {
|
||||
registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params)));
|
||||
export interface IpcRuntimeMainInput {
|
||||
window: Pick<
|
||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||
| 'getMainWindow'
|
||||
| 'getVisibleOverlayVisibility'
|
||||
| 'focusMainWindow'
|
||||
| 'onOverlayModalClosed'
|
||||
| 'onOverlayModalOpened'
|
||||
| 'onYoutubePickerResolve'
|
||||
| 'openYomitanSettings'
|
||||
| 'quitApp'
|
||||
| 'toggleVisibleOverlay'
|
||||
>;
|
||||
playlistBrowser: Pick<
|
||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||
| 'getPlaylistBrowserSnapshot'
|
||||
| 'appendPlaylistBrowserFile'
|
||||
| 'playPlaylistBrowserIndex'
|
||||
| 'removePlaylistBrowserIndex'
|
||||
| 'movePlaylistBrowserIndex'
|
||||
>;
|
||||
subtitle: Pick<
|
||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||
| 'tokenizeCurrentSubtitle'
|
||||
| 'getCurrentSubtitleRaw'
|
||||
| 'getCurrentSubtitleAss'
|
||||
| 'getSubtitleSidebarSnapshot'
|
||||
| 'getPlaybackPaused'
|
||||
| 'getSubtitlePosition'
|
||||
| 'getSubtitleStyle'
|
||||
| 'saveSubtitlePosition'
|
||||
| 'getMecabTokenizer'
|
||||
| 'getKeybindings'
|
||||
| 'getConfiguredShortcuts'
|
||||
| 'getStatsToggleKey'
|
||||
| 'getMarkWatchedKey'
|
||||
| 'getSecondarySubMode'
|
||||
>;
|
||||
controller: Pick<
|
||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||
'getControllerConfig' | 'saveControllerConfig' | 'saveControllerPreference'
|
||||
>;
|
||||
runtime: Pick<
|
||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||
'getMpvClient' | 'getAnkiConnectStatus' | 'getRuntimeOptions' | 'reportOverlayContentBounds'
|
||||
> &
|
||||
Partial<Pick<RegisterIpcRuntimeServicesParams['mainDeps'], 'getImmersionTracker'>>;
|
||||
anilist: {
|
||||
getStatus: RegisterIpcRuntimeServicesParams['mainDeps']['getAnilistStatus'];
|
||||
clearToken: RegisterIpcRuntimeServicesParams['mainDeps']['clearAnilistToken'];
|
||||
openSetup: RegisterIpcRuntimeServicesParams['mainDeps']['openAnilistSetup'];
|
||||
getQueueStatus: RegisterIpcRuntimeServicesParams['mainDeps']['getAnilistQueueStatus'];
|
||||
retryQueueNow: RegisterIpcRuntimeServicesParams['mainDeps']['retryAnilistQueueNow'];
|
||||
};
|
||||
mining: {
|
||||
appendClipboardVideoToQueue: RegisterIpcRuntimeServicesParams['mainDeps']['appendClipboardVideoToQueue'];
|
||||
};
|
||||
}
|
||||
|
||||
export function registerAnkiJimakuIpcRuntimeServices(
|
||||
params: AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
): void {
|
||||
registerAnkiJimakuIpcRuntime(
|
||||
createAnkiJimakuIpcRuntimeServiceDeps(params),
|
||||
registerAnkiJimakuIpcHandlers,
|
||||
);
|
||||
export interface IpcRuntimeRegistrationInput {
|
||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
||||
main: IpcRuntimeMainInput;
|
||||
ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||
registerIpcRuntimeServices: (params: RegisterIpcRuntimeServicesParams) => void;
|
||||
}
|
||||
|
||||
export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void {
|
||||
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
|
||||
getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager,
|
||||
showMpvOsd: params.runtimeOptions.showMpvOsd,
|
||||
});
|
||||
registerMainIpcRuntimeServices({
|
||||
...params.mainDeps,
|
||||
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
|
||||
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
|
||||
});
|
||||
registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps);
|
||||
export interface IpcRuntimeInput {
|
||||
mpv: {
|
||||
mainDeps: MpvCommandFromIpcRuntimeDeps;
|
||||
handleMpvCommandFromIpcRuntime: (
|
||||
command: (string | number)[],
|
||||
deps: MpvCommandFromIpcRuntimeDeps,
|
||||
) => void;
|
||||
runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual'];
|
||||
};
|
||||
registration: IpcRuntimeRegistrationInput;
|
||||
}
|
||||
|
||||
export interface IpcRuntime {
|
||||
registerIpcRuntimeHandlers: () => void;
|
||||
}
|
||||
|
||||
export interface IpcRuntimeFromMainStateInput {
|
||||
mpv: {
|
||||
mainDeps: MpvCommandFromIpcRuntimeDeps;
|
||||
runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual'];
|
||||
};
|
||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
||||
main: IpcRuntimeMainInput;
|
||||
ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||
}
|
||||
|
||||
export function createIpcRuntime(input: IpcRuntimeInput): IpcRuntime {
|
||||
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
mpvCommandMainDeps: input.mpv.mainDeps,
|
||||
handleMpvCommandFromIpcRuntime: input.mpv.handleMpvCommandFromIpcRuntime,
|
||||
runSubsyncManualFromIpc: input.mpv.runSubsyncManualFromIpc,
|
||||
registration: {
|
||||
runtimeOptions: input.registration.runtimeOptions,
|
||||
mainDeps: {
|
||||
...input.registration.main.window,
|
||||
...input.registration.main.playlistBrowser,
|
||||
...input.registration.main.subtitle,
|
||||
...input.registration.main.controller,
|
||||
...input.registration.main.runtime,
|
||||
getAnilistStatus: input.registration.main.anilist.getStatus,
|
||||
clearAnilistToken: input.registration.main.anilist.clearToken,
|
||||
openAnilistSetup: input.registration.main.anilist.openSetup,
|
||||
getAnilistQueueStatus: input.registration.main.anilist.getQueueStatus,
|
||||
retryAnilistQueueNow: input.registration.main.anilist.retryQueueNow,
|
||||
appendClipboardVideoToQueue: input.registration.main.mining.appendClipboardVideoToQueue,
|
||||
},
|
||||
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps(input.registration.ankiJimaku),
|
||||
registerIpcRuntimeServices: (params) => input.registration.registerIpcRuntimeServices(params),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
registerIpcRuntimeHandlers,
|
||||
};
|
||||
}
|
||||
|
||||
export function createIpcRuntimeFromMainState(input: IpcRuntimeFromMainStateInput): IpcRuntime {
|
||||
return createIpcRuntime({
|
||||
mpv: {
|
||||
mainDeps: input.mpv.mainDeps,
|
||||
handleMpvCommandFromIpcRuntime,
|
||||
runSubsyncManualFromIpc: input.mpv.runSubsyncManualFromIpc,
|
||||
},
|
||||
registration: {
|
||||
runtimeOptions: input.runtimeOptions,
|
||||
main: input.main,
|
||||
ankiJimaku: input.ankiJimaku,
|
||||
registerIpcRuntimeServices,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
160
src/main/jellyfin-runtime-coordinator.ts
Normal file
160
src/main/jellyfin-runtime-coordinator.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import { DEFAULT_CONFIG } from '../config';
|
||||
import {
|
||||
JellyfinRemoteSessionService,
|
||||
authenticateWithPasswordRuntime,
|
||||
jellyfinTicksToSecondsRuntime,
|
||||
listJellyfinItemsRuntime,
|
||||
listJellyfinLibrariesRuntime,
|
||||
listJellyfinSubtitleTracksRuntime,
|
||||
resolveJellyfinPlaybackPlanRuntime,
|
||||
sendMpvCommandRuntime,
|
||||
} from '../core/services';
|
||||
import type { MpvIpcClient } from '../core/services/mpv';
|
||||
import type { JellyfinSetupWindowLike } from './jellyfin-runtime';
|
||||
import { createJellyfinRuntime } from './jellyfin-runtime';
|
||||
|
||||
export interface JellyfinRuntimeCoordinatorInput {
|
||||
getResolvedConfig: Parameters<typeof createJellyfinRuntime>[0]['getResolvedConfig'];
|
||||
configService: {
|
||||
patchRawConfig: Parameters<typeof createJellyfinRuntime>[0]['patchRawConfig'];
|
||||
};
|
||||
tokenStore: Parameters<typeof createJellyfinRuntime>[0]['tokenStore'];
|
||||
platform: NodeJS.Platform;
|
||||
execPath: string;
|
||||
defaultMpvLogPath: string;
|
||||
defaultMpvArgs: readonly string[];
|
||||
connectTimeoutMs: number;
|
||||
autoLaunchTimeoutMs: number;
|
||||
langPref: string;
|
||||
progressIntervalMs: number;
|
||||
ticksPerSecond: number;
|
||||
appState: {
|
||||
mpvSocketPath: string;
|
||||
mpvClient: MpvIpcClient | null;
|
||||
jellyfinSetupWindow: BrowserWindow | null;
|
||||
};
|
||||
actions: {
|
||||
createMpvClient: () => MpvIpcClient;
|
||||
applyJellyfinMpvDefaults: (client: MpvIpcClient) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
};
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, details?: unknown) => void;
|
||||
debug: (message: string, details?: unknown) => void;
|
||||
error: (message: string, error?: unknown) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function createJellyfinRuntimeCoordinator(input: JellyfinRuntimeCoordinatorInput) {
|
||||
return createJellyfinRuntime<JellyfinSetupWindowLike>({
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
getEnv: (name) => process.env[name],
|
||||
patchRawConfig: (patch) => {
|
||||
input.configService.patchRawConfig(patch);
|
||||
},
|
||||
defaultJellyfinConfig: DEFAULT_CONFIG.jellyfin,
|
||||
tokenStore: input.tokenStore,
|
||||
platform: input.platform,
|
||||
execPath: input.execPath,
|
||||
defaultMpvLogPath: input.defaultMpvLogPath,
|
||||
defaultMpvArgs: [...input.defaultMpvArgs],
|
||||
connectTimeoutMs: input.connectTimeoutMs,
|
||||
autoLaunchTimeoutMs: input.autoLaunchTimeoutMs,
|
||||
langPref: input.langPref,
|
||||
progressIntervalMs: input.progressIntervalMs,
|
||||
ticksPerSecond: input.ticksPerSecond,
|
||||
getMpvSocketPath: () => input.appState.mpvSocketPath,
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
setMpvClient: (client) => {
|
||||
input.appState.mpvClient = client as MpvIpcClient | null;
|
||||
},
|
||||
createMpvClient: () => input.actions.createMpvClient(),
|
||||
sendMpvCommand: (client, command) => sendMpvCommandRuntime(client as MpvIpcClient, command),
|
||||
applyJellyfinMpvDefaults: (client) =>
|
||||
input.actions.applyJellyfinMpvDefaults(client as MpvIpcClient),
|
||||
showMpvOsd: (message) => input.actions.showMpvOsd(message),
|
||||
removeSocketPath: (socketPath) => {
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
},
|
||||
spawnMpv: (args) =>
|
||||
spawn('mpv', args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
}),
|
||||
wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
|
||||
authenticateWithPassword: (serverUrl, username, password, clientInfo) =>
|
||||
authenticateWithPasswordRuntime(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
clientInfo as Parameters<typeof authenticateWithPasswordRuntime>[3],
|
||||
),
|
||||
listJellyfinLibraries: (session, clientInfo) =>
|
||||
listJellyfinLibrariesRuntime(
|
||||
session as Parameters<typeof listJellyfinLibrariesRuntime>[0],
|
||||
clientInfo as Parameters<typeof listJellyfinLibrariesRuntime>[1],
|
||||
),
|
||||
listJellyfinItems: (session, clientInfo, params) =>
|
||||
listJellyfinItemsRuntime(
|
||||
session as Parameters<typeof listJellyfinItemsRuntime>[0],
|
||||
clientInfo as Parameters<typeof listJellyfinItemsRuntime>[1],
|
||||
params as Parameters<typeof listJellyfinItemsRuntime>[2],
|
||||
),
|
||||
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
||||
listJellyfinSubtitleTracksRuntime(
|
||||
session as Parameters<typeof listJellyfinSubtitleTracksRuntime>[0],
|
||||
clientInfo as Parameters<typeof listJellyfinSubtitleTracksRuntime>[1],
|
||||
itemId,
|
||||
),
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||
fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
},
|
||||
resolvePlaybackPlan: (params) =>
|
||||
resolveJellyfinPlaybackPlanRuntime(
|
||||
(params as { session: Parameters<typeof resolveJellyfinPlaybackPlanRuntime>[0] }).session,
|
||||
(params as { clientInfo: Parameters<typeof resolveJellyfinPlaybackPlanRuntime>[1] })
|
||||
.clientInfo,
|
||||
(
|
||||
params as {
|
||||
jellyfinConfig: ReturnType<
|
||||
JellyfinRuntimeCoordinatorInput['getResolvedConfig']
|
||||
>['jellyfin'];
|
||||
}
|
||||
).jellyfinConfig,
|
||||
{
|
||||
itemId: (params as { itemId: string }).itemId,
|
||||
audioStreamIndex:
|
||||
(params as { audioStreamIndex?: number | null }).audioStreamIndex ?? undefined,
|
||||
subtitleStreamIndex:
|
||||
(params as { subtitleStreamIndex?: number | null }).subtitleStreamIndex ?? undefined,
|
||||
},
|
||||
),
|
||||
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
|
||||
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options as never),
|
||||
defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId,
|
||||
defaultClientName: DEFAULT_CONFIG.jellyfin.clientName,
|
||||
defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion,
|
||||
createBrowserWindow: (options) => {
|
||||
const window = new BrowserWindow(options);
|
||||
input.appState.jellyfinSetupWindow = window;
|
||||
window.on('closed', () => {
|
||||
input.appState.jellyfinSetupWindow = null;
|
||||
});
|
||||
return window as unknown as JellyfinSetupWindowLike;
|
||||
},
|
||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
||||
logInfo: (message) => input.logger.info(message),
|
||||
logWarn: (message, details) => input.logger.warn(message, details),
|
||||
logDebug: (message, details) => input.logger.debug(message, details),
|
||||
logError: (message, error) => input.logger.error(message, error),
|
||||
now: () => Date.now(),
|
||||
});
|
||||
}
|
||||
95
src/main/jellyfin-runtime.test.ts
Normal file
95
src/main/jellyfin-runtime.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { JellyfinRuntimeInput } from './jellyfin-runtime';
|
||||
import { createJellyfinRuntime } from './jellyfin-runtime';
|
||||
|
||||
test('jellyfin runtime reuses existing setup window', () => {
|
||||
const calls: string[] = [];
|
||||
let windowCount = 0;
|
||||
|
||||
const runtime = createJellyfinRuntime({
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
jellyfin: {
|
||||
enabled: true,
|
||||
serverUrl: 'https://media.example',
|
||||
username: 'demo',
|
||||
},
|
||||
}) as ReturnType<JellyfinRuntimeInput['getResolvedConfig']>,
|
||||
getEnv: () => undefined,
|
||||
patchRawConfig: () => {},
|
||||
defaultJellyfinConfig: {
|
||||
enabled: false,
|
||||
serverUrl: '',
|
||||
username: '',
|
||||
} as JellyfinRuntimeInput['defaultJellyfinConfig'],
|
||||
tokenStore: {
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
clearSession: () => {},
|
||||
},
|
||||
platform: 'linux',
|
||||
execPath: '/usr/bin/electron',
|
||||
defaultMpvLogPath: '/tmp/mpv.log',
|
||||
defaultMpvArgs: ['--idle=yes'],
|
||||
connectTimeoutMs: 1000,
|
||||
autoLaunchTimeoutMs: 1000,
|
||||
langPref: 'ja,en',
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
getMpvClient: () => null,
|
||||
setMpvClient: () => {},
|
||||
createMpvClient: () => ({}),
|
||||
sendMpvCommand: () => {},
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showMpvOsd: () => {},
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: () => ({}),
|
||||
wait: async () => {},
|
||||
authenticateWithPassword: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
resolvePlaybackPlan: async () => ({}),
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
createRemoteSessionService: () => ({}),
|
||||
defaultDeviceId: 'device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0.0',
|
||||
createBrowserWindow: () => {
|
||||
windowCount += 1;
|
||||
return {
|
||||
webContents: {
|
||||
on: () => {},
|
||||
},
|
||||
loadURL: () => {
|
||||
calls.push('loadURL');
|
||||
},
|
||||
on: () => {},
|
||||
focus: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
close: () => {},
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
},
|
||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logDebug: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
runtime.openJellyfinSetupWindow();
|
||||
runtime.openJellyfinSetupWindow();
|
||||
|
||||
assert.equal(windowCount, 1);
|
||||
assert.deepEqual(calls, ['loadURL', 'focus']);
|
||||
assert.equal(runtime.getQuitOnDisconnectArmed(), false);
|
||||
assert.ok(runtime.getSetupWindow());
|
||||
});
|
||||
423
src/main/jellyfin-runtime.ts
Normal file
423
src/main/jellyfin-runtime.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import {
|
||||
buildJellyfinSetupFormHtml,
|
||||
getConfiguredJellyfinSession,
|
||||
parseJellyfinSetupSubmissionUrl,
|
||||
} from './runtime/domains/jellyfin';
|
||||
import {
|
||||
composeJellyfinRuntimeHandlers,
|
||||
type JellyfinRuntimeComposerOptions,
|
||||
} from './runtime/composers/jellyfin-runtime-composer';
|
||||
import { createCreateJellyfinSetupWindowHandler } from './runtime/setup-window-factory';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: extract each dep-block's type from the composer options.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Deps<K extends keyof JellyfinRuntimeComposerOptions> = JellyfinRuntimeComposerOptions[K];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolved-config shape (extracted from composer).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ResolvedConfigShape =
|
||||
Deps<'getResolvedJellyfinConfigMainDeps'> extends {
|
||||
getResolvedConfig: () => infer R;
|
||||
}
|
||||
? R
|
||||
: never;
|
||||
type JellyfinConfigShape = ResolvedConfigShape extends { jellyfin: infer J } ? J : never;
|
||||
|
||||
/** Stored-session shape (what the token store persists). */
|
||||
type StoredSessionShape = { accessToken: string; userId: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface JellyfinSessionStoreLike {
|
||||
loadSession: () => StoredSessionShape | null | undefined;
|
||||
saveSession: (session: StoredSessionShape) => void;
|
||||
clearSession: () => void;
|
||||
}
|
||||
|
||||
export interface JellyfinSetupWindowLike {
|
||||
webContents: {
|
||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
|
||||
};
|
||||
loadURL: (url: string) => Promise<void> | void;
|
||||
on: (event: 'closed', handler: () => void) => void;
|
||||
focus: () => void;
|
||||
close: () => void;
|
||||
isDestroyed: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for createJellyfinRuntime.
|
||||
*
|
||||
* Fields whose types vary across handler files (MpvClient, Session, ClientInfo,
|
||||
* RemoteSessionService, etc.) are typed as `unknown`. The factory body bridges
|
||||
* these to the handler-specific structural types via per-dep-block type
|
||||
* annotations (`Deps<K>`) with targeted `as` casts on the individual
|
||||
* function references. This keeps the public-facing input surface simple and
|
||||
* avoids 7+ generic type parameters that previously required `as never` casts.
|
||||
*/
|
||||
export interface JellyfinRuntimeInput<
|
||||
TSetupWindow extends JellyfinSetupWindowLike = JellyfinSetupWindowLike,
|
||||
> {
|
||||
getResolvedConfig: () => ResolvedConfigShape;
|
||||
getEnv: (name: string) => string | undefined;
|
||||
patchRawConfig: (patch: unknown) => void;
|
||||
defaultJellyfinConfig: JellyfinConfigShape;
|
||||
tokenStore: JellyfinSessionStoreLike;
|
||||
platform: NodeJS.Platform;
|
||||
execPath: string;
|
||||
defaultMpvLogPath: string;
|
||||
defaultMpvArgs: readonly string[];
|
||||
connectTimeoutMs: number;
|
||||
autoLaunchTimeoutMs: number;
|
||||
langPref: string;
|
||||
progressIntervalMs: number;
|
||||
ticksPerSecond: number;
|
||||
getMpvSocketPath: () => string;
|
||||
getMpvClient: () => unknown;
|
||||
setMpvClient: (client: unknown) => void;
|
||||
createMpvClient: () => unknown;
|
||||
sendMpvCommand: (client: unknown, command: Array<string | number>) => void;
|
||||
applyJellyfinMpvDefaults: (client: unknown) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
removeSocketPath: (socketPath: string) => void;
|
||||
spawnMpv: (args: string[]) => unknown;
|
||||
wait: (delayMs: number) => Promise<void>;
|
||||
authenticateWithPassword: (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
clientInfo: unknown,
|
||||
) => Promise<unknown>;
|
||||
listJellyfinLibraries: (session: unknown, clientInfo: unknown) => Promise<unknown>;
|
||||
listJellyfinItems: (session: unknown, clientInfo: unknown, params: unknown) => Promise<unknown>;
|
||||
listJellyfinSubtitleTracks: (
|
||||
session: unknown,
|
||||
clientInfo: unknown,
|
||||
itemId: string,
|
||||
) => Promise<unknown>;
|
||||
writeJellyfinPreviewAuth: (responsePath: string, payload: unknown) => void;
|
||||
resolvePlaybackPlan: (params: unknown) => Promise<unknown>;
|
||||
convertTicksToSeconds: (ticks: number) => number;
|
||||
createRemoteSessionService: (options: unknown) => unknown;
|
||||
defaultDeviceId: string;
|
||||
defaultClientName: string;
|
||||
defaultClientVersion: string;
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TSetupWindow;
|
||||
encodeURIComponent: (value: string) => string;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
logDebug: (message: string, details?: unknown) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
now?: () => number;
|
||||
schedule?: (callback: () => void, delayMs: number) => void;
|
||||
}
|
||||
|
||||
export interface JellyfinRuntime<
|
||||
TSetupWindow extends JellyfinSetupWindowLike = JellyfinSetupWindowLike,
|
||||
> {
|
||||
getResolvedJellyfinConfig: () => JellyfinConfigShape;
|
||||
reportJellyfinRemoteProgress: (forceImmediate?: boolean) => Promise<void>;
|
||||
reportJellyfinRemoteStopped: () => Promise<void>;
|
||||
startJellyfinRemoteSession: () => Promise<void>;
|
||||
stopJellyfinRemoteSession: () => Promise<void>;
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
getQuitOnDisconnectArmed: () => boolean;
|
||||
clearQuitOnDisconnectArm: () => void;
|
||||
getRemoteSession: () => unknown;
|
||||
getSetupWindow: () => TSetupWindow | null;
|
||||
}
|
||||
|
||||
export function createJellyfinRuntime<TSetupWindow extends JellyfinSetupWindowLike>(
|
||||
input: JellyfinRuntimeInput<TSetupWindow>,
|
||||
): JellyfinRuntime<TSetupWindow> {
|
||||
const now = input.now ?? Date.now;
|
||||
const schedule =
|
||||
input.schedule ??
|
||||
((callback: () => void, delayMs: number) => {
|
||||
setTimeout(callback, delayMs);
|
||||
});
|
||||
|
||||
let playQuitOnDisconnectArmed = false;
|
||||
let activePlayback: unknown = null;
|
||||
let lastProgressAtMs = 0;
|
||||
let mpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
||||
let remoteSession: unknown = null;
|
||||
let setupWindow: TSetupWindow | null = null;
|
||||
|
||||
// Each dep block is typed with Deps<K> so TypeScript verifies structural
|
||||
// compatibility with the composer. The `as Deps<K>[field]` casts on
|
||||
// function references bridge `unknown`-typed input methods to the
|
||||
// handler-specific structural types. This replaces 23 `as never` casts
|
||||
// with targeted, auditable type assertions.
|
||||
|
||||
const getResolvedJellyfinConfigMainDeps: Deps<'getResolvedJellyfinConfigMainDeps'> = {
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
loadStoredSession: () => input.tokenStore.loadSession(),
|
||||
getEnv: (name) => input.getEnv(name),
|
||||
};
|
||||
|
||||
const getJellyfinClientInfoMainDeps: Deps<'getJellyfinClientInfoMainDeps'> = {
|
||||
getResolvedJellyfinConfig: () => input.getResolvedConfig().jellyfin,
|
||||
getDefaultJellyfinConfig: () => input.defaultJellyfinConfig,
|
||||
};
|
||||
|
||||
const waitForMpvConnectedMainDeps: Deps<'waitForMpvConnectedMainDeps'> = {
|
||||
getMpvClient: input.getMpvClient as Deps<'waitForMpvConnectedMainDeps'>['getMpvClient'],
|
||||
now,
|
||||
sleep: (delayMs) => input.wait(delayMs),
|
||||
};
|
||||
|
||||
const launchMpvIdleForJellyfinPlaybackMainDeps: Deps<'launchMpvIdleForJellyfinPlaybackMainDeps'> =
|
||||
{
|
||||
getSocketPath: () => input.getMpvSocketPath(),
|
||||
platform: input.platform,
|
||||
execPath: input.execPath,
|
||||
defaultMpvLogPath: input.defaultMpvLogPath,
|
||||
defaultMpvArgs: input.defaultMpvArgs,
|
||||
removeSocketPath: (socketPath) => input.removeSocketPath(socketPath),
|
||||
spawnMpv: input.spawnMpv as Deps<'launchMpvIdleForJellyfinPlaybackMainDeps'>['spawnMpv'],
|
||||
logWarn: (message, error) => input.logWarn(message, error),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
};
|
||||
|
||||
const ensureMpvConnectedForJellyfinPlaybackMainDeps: Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'> =
|
||||
{
|
||||
getMpvClient:
|
||||
input.getMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['getMpvClient'],
|
||||
setMpvClient:
|
||||
input.setMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['setMpvClient'],
|
||||
createMpvClient:
|
||||
input.createMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['createMpvClient'],
|
||||
getAutoLaunchInFlight: () => mpvAutoLaunchInFlight,
|
||||
setAutoLaunchInFlight: (promise) => {
|
||||
mpvAutoLaunchInFlight = promise;
|
||||
},
|
||||
connectTimeoutMs: input.connectTimeoutMs,
|
||||
autoLaunchTimeoutMs: input.autoLaunchTimeoutMs,
|
||||
};
|
||||
|
||||
const preloadJellyfinExternalSubtitlesMainDeps: Deps<'preloadJellyfinExternalSubtitlesMainDeps'> =
|
||||
{
|
||||
listJellyfinSubtitleTracks:
|
||||
input.listJellyfinSubtitleTracks as Deps<'preloadJellyfinExternalSubtitlesMainDeps'>['listJellyfinSubtitleTracks'],
|
||||
getMpvClient:
|
||||
input.getMpvClient as Deps<'preloadJellyfinExternalSubtitlesMainDeps'>['getMpvClient'],
|
||||
sendMpvCommand: (command) => input.sendMpvCommand(input.getMpvClient(), command),
|
||||
wait: (delayMs) => input.wait(delayMs),
|
||||
logDebug: (message, error) => input.logDebug(message, error),
|
||||
};
|
||||
|
||||
const playJellyfinItemInMpvMainDeps: Deps<'playJellyfinItemInMpvMainDeps'> = {
|
||||
getMpvClient: input.getMpvClient as Deps<'playJellyfinItemInMpvMainDeps'>['getMpvClient'],
|
||||
resolvePlaybackPlan:
|
||||
input.resolvePlaybackPlan as Deps<'playJellyfinItemInMpvMainDeps'>['resolvePlaybackPlan'],
|
||||
applyJellyfinMpvDefaults:
|
||||
input.applyJellyfinMpvDefaults as Deps<'playJellyfinItemInMpvMainDeps'>['applyJellyfinMpvDefaults'],
|
||||
sendMpvCommand: (command) => input.sendMpvCommand(input.getMpvClient(), command),
|
||||
armQuitOnDisconnect: () => {
|
||||
playQuitOnDisconnectArmed = false;
|
||||
schedule(() => {
|
||||
playQuitOnDisconnectArmed = true;
|
||||
}, 3000);
|
||||
},
|
||||
schedule: (callback, delayMs) => {
|
||||
schedule(callback, delayMs);
|
||||
},
|
||||
convertTicksToSeconds: (ticks) => input.convertTicksToSeconds(ticks),
|
||||
setActivePlayback: (state) => {
|
||||
activePlayback = state;
|
||||
},
|
||||
setLastProgressAtMs: (value) => {
|
||||
lastProgressAtMs = value;
|
||||
},
|
||||
reportPlaying: (payload) => {
|
||||
const session = remoteSession as { reportPlaying?: (payload: unknown) => unknown } | null;
|
||||
if (typeof session?.reportPlaying === 'function') {
|
||||
void session.reportPlaying(payload);
|
||||
}
|
||||
},
|
||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
||||
};
|
||||
|
||||
const remoteComposerBase: Omit<Deps<'remoteComposerOptions'>, 'getConfiguredSession'> = {
|
||||
logWarn: (message) => input.logWarn(message),
|
||||
getMpvClient: input.getMpvClient as Deps<'remoteComposerOptions'>['getMpvClient'],
|
||||
sendMpvCommand: input.sendMpvCommand as Deps<'remoteComposerOptions'>['sendMpvCommand'],
|
||||
jellyfinTicksToSeconds: (ticks) => input.convertTicksToSeconds(ticks),
|
||||
getActivePlayback: () =>
|
||||
activePlayback as ReturnType<Deps<'remoteComposerOptions'>['getActivePlayback']>,
|
||||
clearActivePlayback: () => {
|
||||
activePlayback = null;
|
||||
},
|
||||
getSession: () => remoteSession as ReturnType<Deps<'remoteComposerOptions'>['getSession']>,
|
||||
getNow: now,
|
||||
getLastProgressAtMs: () => lastProgressAtMs,
|
||||
setLastProgressAtMs: (value) => {
|
||||
lastProgressAtMs = value;
|
||||
},
|
||||
progressIntervalMs: input.progressIntervalMs,
|
||||
ticksPerSecond: input.ticksPerSecond,
|
||||
logDebug: (message, error) => input.logDebug(message, error),
|
||||
};
|
||||
|
||||
const handleJellyfinAuthCommandsMainDeps: Deps<'handleJellyfinAuthCommandsMainDeps'> = {
|
||||
patchRawConfig: (patch) => input.patchRawConfig(patch),
|
||||
authenticateWithPassword:
|
||||
input.authenticateWithPassword as Deps<'handleJellyfinAuthCommandsMainDeps'>['authenticateWithPassword'],
|
||||
saveStoredSession: (session) => input.tokenStore.saveSession(session as StoredSessionShape),
|
||||
clearStoredSession: () => input.tokenStore.clearSession(),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
};
|
||||
|
||||
const handleJellyfinListCommandsMainDeps: Deps<'handleJellyfinListCommandsMainDeps'> = {
|
||||
listJellyfinLibraries:
|
||||
input.listJellyfinLibraries as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinLibraries'],
|
||||
listJellyfinItems:
|
||||
input.listJellyfinItems as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinItems'],
|
||||
listJellyfinSubtitleTracks:
|
||||
input.listJellyfinSubtitleTracks as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinSubtitleTracks'],
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) =>
|
||||
input.writeJellyfinPreviewAuth(responsePath, payload),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
};
|
||||
|
||||
const handleJellyfinPlayCommandMainDeps: Deps<'handleJellyfinPlayCommandMainDeps'> = {
|
||||
logWarn: (message) => input.logWarn(message),
|
||||
};
|
||||
|
||||
const handleJellyfinRemoteAnnounceCommandMainDeps: Deps<'handleJellyfinRemoteAnnounceCommandMainDeps'> =
|
||||
{
|
||||
getRemoteSession: () =>
|
||||
remoteSession as ReturnType<
|
||||
Deps<'handleJellyfinRemoteAnnounceCommandMainDeps'>['getRemoteSession']
|
||||
>,
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logWarn: (message) => input.logWarn(message),
|
||||
};
|
||||
|
||||
const startJellyfinRemoteSessionMainDeps: Deps<'startJellyfinRemoteSessionMainDeps'> = {
|
||||
getCurrentSession: () =>
|
||||
remoteSession as ReturnType<Deps<'startJellyfinRemoteSessionMainDeps'>['getCurrentSession']>,
|
||||
setCurrentSession: (session) => {
|
||||
remoteSession = session;
|
||||
},
|
||||
createRemoteSessionService:
|
||||
input.createRemoteSessionService as Deps<'startJellyfinRemoteSessionMainDeps'>['createRemoteSessionService'],
|
||||
defaultDeviceId: input.defaultDeviceId,
|
||||
defaultClientName: input.defaultClientName,
|
||||
defaultClientVersion: input.defaultClientVersion,
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logWarn: (message, details) => input.logWarn(message, details),
|
||||
};
|
||||
|
||||
const stopJellyfinRemoteSessionMainDeps: Deps<'stopJellyfinRemoteSessionMainDeps'> = {
|
||||
getCurrentSession: () =>
|
||||
remoteSession as ReturnType<Deps<'stopJellyfinRemoteSessionMainDeps'>['getCurrentSession']>,
|
||||
setCurrentSession: (session) => {
|
||||
remoteSession = session;
|
||||
},
|
||||
clearActivePlayback: () => {
|
||||
activePlayback = null;
|
||||
},
|
||||
};
|
||||
|
||||
const runJellyfinCommandMainDeps: Deps<'runJellyfinCommandMainDeps'> = {
|
||||
defaultServerUrl: input.defaultJellyfinConfig.serverUrl,
|
||||
};
|
||||
|
||||
const maybeFocusExistingJellyfinSetupWindowMainDeps: Deps<'maybeFocusExistingJellyfinSetupWindowMainDeps'> =
|
||||
{
|
||||
getSetupWindow: () => setupWindow,
|
||||
};
|
||||
|
||||
const openJellyfinSetupWindowMainDeps: Deps<'openJellyfinSetupWindowMainDeps'> = {
|
||||
createSetupWindow: createCreateJellyfinSetupWindowHandler({
|
||||
createBrowserWindow: (options) => input.createBrowserWindow(options),
|
||||
}),
|
||||
buildSetupFormHtml: (defaultServer, defaultUser) =>
|
||||
buildJellyfinSetupFormHtml(defaultServer, defaultUser),
|
||||
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
||||
authenticateWithPassword:
|
||||
input.authenticateWithPassword as Deps<'openJellyfinSetupWindowMainDeps'>['authenticateWithPassword'],
|
||||
saveStoredSession: (session) => input.tokenStore.saveSession(session as StoredSessionShape),
|
||||
patchJellyfinConfig: (session) => {
|
||||
const jellyfinSession = session as { serverUrl?: string; username?: string };
|
||||
input.patchRawConfig({
|
||||
jellyfin: {
|
||||
enabled: true,
|
||||
serverUrl: jellyfinSession.serverUrl,
|
||||
username: jellyfinSession.username,
|
||||
},
|
||||
});
|
||||
},
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logError: (message, error) => input.logError(message, error),
|
||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
||||
clearSetupWindow: () => {
|
||||
setupWindow = null;
|
||||
},
|
||||
setSetupWindow: (window) => {
|
||||
setupWindow = window as TSetupWindow | null;
|
||||
},
|
||||
encodeURIComponent: (value) => input.encodeURIComponent(value),
|
||||
};
|
||||
|
||||
const runtime = composeJellyfinRuntimeHandlers({
|
||||
getResolvedJellyfinConfigMainDeps,
|
||||
getJellyfinClientInfoMainDeps,
|
||||
waitForMpvConnectedMainDeps,
|
||||
launchMpvIdleForJellyfinPlaybackMainDeps,
|
||||
ensureMpvConnectedForJellyfinPlaybackMainDeps,
|
||||
preloadJellyfinExternalSubtitlesMainDeps,
|
||||
playJellyfinItemInMpvMainDeps,
|
||||
remoteComposerOptions: {
|
||||
...remoteComposerBase,
|
||||
getConfiguredSession: () => getConfiguredJellyfinSession(runtime.getResolvedJellyfinConfig()),
|
||||
},
|
||||
handleJellyfinAuthCommandsMainDeps,
|
||||
handleJellyfinListCommandsMainDeps,
|
||||
handleJellyfinPlayCommandMainDeps,
|
||||
handleJellyfinRemoteAnnounceCommandMainDeps,
|
||||
startJellyfinRemoteSessionMainDeps,
|
||||
stopJellyfinRemoteSessionMainDeps,
|
||||
runJellyfinCommandMainDeps,
|
||||
maybeFocusExistingJellyfinSetupWindowMainDeps,
|
||||
openJellyfinSetupWindowMainDeps,
|
||||
});
|
||||
|
||||
return {
|
||||
getResolvedJellyfinConfig: () => runtime.getResolvedJellyfinConfig(),
|
||||
reportJellyfinRemoteProgress: async (forceImmediate) => {
|
||||
await runtime.reportJellyfinRemoteProgress(forceImmediate);
|
||||
},
|
||||
reportJellyfinRemoteStopped: async () => {
|
||||
await runtime.reportJellyfinRemoteStopped();
|
||||
},
|
||||
startJellyfinRemoteSession: async () => {
|
||||
await runtime.startJellyfinRemoteSession();
|
||||
},
|
||||
stopJellyfinRemoteSession: async () => {
|
||||
await runtime.stopJellyfinRemoteSession();
|
||||
},
|
||||
runJellyfinCommand: async (args) => {
|
||||
await runtime.runJellyfinCommand(args);
|
||||
},
|
||||
openJellyfinSetupWindow: () => {
|
||||
runtime.openJellyfinSetupWindow();
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => playQuitOnDisconnectArmed,
|
||||
clearQuitOnDisconnectArm: () => {
|
||||
playQuitOnDisconnectArmed = false;
|
||||
},
|
||||
getRemoteSession: () => remoteSession,
|
||||
getSetupWindow: () => setupWindow,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as path from 'path';
|
||||
import type { JlptLevel } from '../types';
|
||||
|
||||
import { createJlptVocabularyLookup } from '../core/services';
|
||||
import { createJlptVocabularyLookup } from '../core/services/jlpt-vocab';
|
||||
|
||||
export interface JlptDictionarySearchPathDeps {
|
||||
getDictionaryRoots: () => string[];
|
||||
|
||||
169
src/main/main-boot-runtime.ts
Normal file
169
src/main/main-boot-runtime.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import { createAnilistTokenStore } from '../core/services/anilist/anilist-token-store';
|
||||
import { createJellyfinTokenStore } from '../core/services/jellyfin-token-store';
|
||||
import { createAnilistUpdateQueue } from '../core/services/anilist/anilist-update-queue';
|
||||
import {
|
||||
SubtitleWebSocket,
|
||||
createOverlayContentMeasurementStore,
|
||||
createOverlayManager,
|
||||
} from '../core/services';
|
||||
import { ConfigService } from '../config';
|
||||
import { resolveConfigDir } from '../config/path-resolution';
|
||||
import { createAppState } from './state';
|
||||
import {
|
||||
createMainBootServices,
|
||||
type AppLifecycleShape,
|
||||
type MainBootServicesResult,
|
||||
} from './boot/services';
|
||||
import { createLogger } from '../logger';
|
||||
import { createMainRuntimeRegistry } from './runtime/registry';
|
||||
import { createOverlayModalInputState } from './runtime/overlay-modal-input-state';
|
||||
import { createOverlayModalRuntimeService } from './overlay-runtime';
|
||||
import { buildConfigParseErrorDetails, failStartupFromConfig } from './config-validation';
|
||||
import {
|
||||
registerSecondInstanceHandlerEarly,
|
||||
requestSingleInstanceLockEarly,
|
||||
shouldBypassSingleInstanceLockForArgv,
|
||||
} from './early-single-instance';
|
||||
import {
|
||||
createBuildOverlayContentMeasurementStoreMainDepsHandler,
|
||||
createBuildOverlayModalRuntimeMainDepsHandler,
|
||||
} from './runtime/domains/overlay';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
export type MainBootRuntime = MainBootServicesResult<
|
||||
ConfigService,
|
||||
ReturnType<typeof createAnilistTokenStore>,
|
||||
ReturnType<typeof createJellyfinTokenStore>,
|
||||
ReturnType<typeof createAnilistUpdateQueue>,
|
||||
SubtitleWebSocket,
|
||||
ReturnType<typeof createLogger>,
|
||||
ReturnType<typeof createMainRuntimeRegistry>,
|
||||
ReturnType<typeof createOverlayManager>,
|
||||
ReturnType<typeof createOverlayModalInputState>,
|
||||
ReturnType<typeof createOverlayContentMeasurementStore>,
|
||||
ReturnType<typeof createOverlayModalRuntimeService>,
|
||||
ReturnType<typeof createAppState>,
|
||||
AppLifecycleShape
|
||||
>;
|
||||
|
||||
export interface MainBootRuntimeInput {
|
||||
platform: NodeJS.Platform;
|
||||
argv: string[];
|
||||
appDataDir: string | undefined;
|
||||
xdgConfigHome: string | undefined;
|
||||
homeDir: string;
|
||||
defaultMpvLogFile: string;
|
||||
envMpvLog: string | undefined;
|
||||
defaultTexthookerPort: number;
|
||||
getDefaultSocketPath: () => string;
|
||||
app: {
|
||||
setPath: (name: string, value: string) => void;
|
||||
quit: () => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
|
||||
on: Function;
|
||||
whenReady: () => Promise<void>;
|
||||
};
|
||||
dialog: {
|
||||
showErrorBox: (title: string, details: string) => void;
|
||||
};
|
||||
overlay: {
|
||||
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void;
|
||||
getSyncOverlayVisibilityForModal: () => () => void;
|
||||
createModalWindow: () => BrowserWindow;
|
||||
getOverlayGeometry: () => WindowGeometry;
|
||||
};
|
||||
notifications: {
|
||||
notifyAnilistTokenStoreWarning: (message: string) => void;
|
||||
requestAppQuit: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function createMainBootRuntime(input: MainBootRuntimeInput): MainBootRuntime {
|
||||
return createMainBootServices({
|
||||
platform: input.platform,
|
||||
argv: input.argv,
|
||||
appDataDir: input.appDataDir,
|
||||
xdgConfigHome: input.xdgConfigHome,
|
||||
homeDir: input.homeDir,
|
||||
defaultMpvLogFile: input.defaultMpvLogFile,
|
||||
envMpvLog: input.envMpvLog,
|
||||
defaultTexthookerPort: input.defaultTexthookerPort,
|
||||
getDefaultSocketPath: () => input.getDefaultSocketPath(),
|
||||
resolveConfigDir,
|
||||
existsSync: (targetPath) => fs.existsSync(targetPath),
|
||||
mkdirSync: (targetPath, options) => {
|
||||
fs.mkdirSync(targetPath, options);
|
||||
},
|
||||
joinPath: (...parts) => path.join(...parts),
|
||||
app: input.app,
|
||||
shouldBypassSingleInstanceLock: () => shouldBypassSingleInstanceLockForArgv(input.argv),
|
||||
requestSingleInstanceLockEarly: () => requestSingleInstanceLockEarly(input.app as never),
|
||||
registerSecondInstanceHandlerEarly: (listener) => {
|
||||
registerSecondInstanceHandlerEarly(input.app as never, listener);
|
||||
},
|
||||
onConfigStartupParseError: (error) => {
|
||||
failStartupFromConfig(
|
||||
'SubMiner config parse error',
|
||||
buildConfigParseErrorDetails(error.path, error.parseError),
|
||||
{
|
||||
logError: (details) => console.error(details),
|
||||
showErrorBox: (title, details) => input.dialog.showErrorBox(title, details),
|
||||
quit: () => input.notifications.requestAppQuit(),
|
||||
},
|
||||
);
|
||||
},
|
||||
createConfigService: (configDir) => new ConfigService(configDir),
|
||||
createAnilistTokenStore: (targetPath) =>
|
||||
createAnilistTokenStore(targetPath, {
|
||||
info: (message: string) => console.info(message),
|
||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||
error: (message: string, details?: unknown) => console.error(message, details),
|
||||
warnUser: (message: string) => input.notifications.notifyAnilistTokenStoreWarning(message),
|
||||
}),
|
||||
createJellyfinTokenStore: (targetPath) =>
|
||||
createJellyfinTokenStore(targetPath, {
|
||||
info: (message: string) => console.info(message),
|
||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||
error: (message: string, details?: unknown) => console.error(message, details),
|
||||
}),
|
||||
createAnilistUpdateQueue: (targetPath) =>
|
||||
createAnilistUpdateQueue(targetPath, {
|
||||
info: (message: string) => console.info(message),
|
||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||
error: (message: string, details?: unknown) => console.error(message, details),
|
||||
}),
|
||||
createSubtitleWebSocket: () => new SubtitleWebSocket(),
|
||||
createLogger,
|
||||
createMainRuntimeRegistry,
|
||||
createOverlayManager,
|
||||
createOverlayModalInputState,
|
||||
createOverlayContentMeasurementStore: ({ logger }) =>
|
||||
createOverlayContentMeasurementStore(
|
||||
createBuildOverlayContentMeasurementStoreMainDepsHandler({
|
||||
now: () => Date.now(),
|
||||
warn: (message: string) => logger.warn(message),
|
||||
})(),
|
||||
),
|
||||
getSyncOverlayShortcutsForModal: () => input.overlay.getSyncOverlayShortcutsForModal(),
|
||||
getSyncOverlayVisibilityForModal: () => input.overlay.getSyncOverlayVisibilityForModal(),
|
||||
createOverlayModalRuntime: ({ overlayManager, overlayModalInputState }) =>
|
||||
createOverlayModalRuntimeService(
|
||||
createBuildOverlayModalRuntimeMainDepsHandler({
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getModalWindow: () => overlayManager.getModalWindow(),
|
||||
createModalWindow: () => input.overlay.createModalWindow(),
|
||||
getModalGeometry: () => input.overlay.getOverlayGeometry(),
|
||||
setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry),
|
||||
})(),
|
||||
{
|
||||
onModalStateChange: (isActive: boolean) =>
|
||||
overlayModalInputState.handleModalInputStateChange(isActive),
|
||||
},
|
||||
),
|
||||
createAppState,
|
||||
}) as MainBootRuntime;
|
||||
}
|
||||
114
src/main/main-boot-services-bootstrap.test.ts
Normal file
114
src/main/main-boot-services-bootstrap.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createMainBootServicesBootstrap } from './main-boot-services-bootstrap';
|
||||
|
||||
test('main boot services bootstrap composes grouped inputs into boot services', () => {
|
||||
const calls: string[] = [];
|
||||
const modalWindow = {} as never;
|
||||
const overlayManager = {
|
||||
getModalWindow: () => modalWindow,
|
||||
};
|
||||
type AppStateStub = {
|
||||
kind: 'app-state';
|
||||
input: {
|
||||
mpvSocketPath: string;
|
||||
texthookerPort: number;
|
||||
};
|
||||
};
|
||||
type OverlayModalRuntimeStub = {
|
||||
kind: 'overlay-modal-runtime';
|
||||
};
|
||||
|
||||
const overlayModalInputState = {
|
||||
kind: 'overlay-modal-input-state',
|
||||
handleModalInputStateChange: (isActive: boolean) => {
|
||||
calls.push(`modal-state:${String(isActive)}`);
|
||||
},
|
||||
};
|
||||
const overlayModalInputStateParams: {
|
||||
getModalWindow: () => unknown;
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
}[] = [];
|
||||
const createOverlayModalInputState = (params: (typeof overlayModalInputStateParams)[number]) => {
|
||||
overlayModalInputStateParams.push(params);
|
||||
return overlayModalInputState as never;
|
||||
};
|
||||
|
||||
const createOverlayModalRuntime = (params: {
|
||||
onModalStateChange: (isActive: boolean) => void;
|
||||
}) => {
|
||||
calls.push(`modal:${String(params.onModalStateChange(true))}`);
|
||||
return { kind: 'overlay-modal-runtime' } as OverlayModalRuntimeStub;
|
||||
};
|
||||
|
||||
const boot = createMainBootServicesBootstrap({
|
||||
system: {
|
||||
platform: 'darwin',
|
||||
argv: ['node', 'main.js'],
|
||||
appDataDir: '/tmp/app-data',
|
||||
xdgConfigHome: '/tmp/xdg',
|
||||
homeDir: '/Users/test',
|
||||
defaultMpvLogFile: '/tmp/mpv.log',
|
||||
envMpvLog: '',
|
||||
defaultTexthookerPort: 5174,
|
||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
||||
resolveConfigDir: () => '/tmp/config',
|
||||
existsSync: () => true,
|
||||
mkdirSync: () => undefined,
|
||||
joinPath: (...parts: string[]) => path.posix.join(...parts),
|
||||
app: {
|
||||
setPath: () => undefined,
|
||||
quit: () => undefined,
|
||||
on: () => undefined,
|
||||
whenReady: async () => undefined,
|
||||
},
|
||||
},
|
||||
singleInstance: {
|
||||
shouldBypassSingleInstanceLock: () => false,
|
||||
requestSingleInstanceLockEarly: () => true,
|
||||
registerSecondInstanceHandlerEarly: () => undefined,
|
||||
onConfigStartupParseError: () => undefined,
|
||||
},
|
||||
factories: {
|
||||
createConfigService: () => ({ kind: 'config-service' }) as never,
|
||||
createAnilistTokenStore: () => ({ kind: 'anilist-token-store' }) as never,
|
||||
createJellyfinTokenStore: () => ({ kind: 'jellyfin-token-store' }) as never,
|
||||
createAnilistUpdateQueue: () => ({ kind: 'anilist-update-queue' }) as never,
|
||||
createSubtitleWebSocket: () => ({ kind: 'subtitle-websocket' }) as never,
|
||||
createLogger: () =>
|
||||
({
|
||||
warn: () => undefined,
|
||||
info: () => undefined,
|
||||
error: () => undefined,
|
||||
}) as never,
|
||||
createMainRuntimeRegistry: () => ({ kind: 'runtime-registry' }) as never,
|
||||
createOverlayManager: () => overlayManager as never,
|
||||
createOverlayModalInputState,
|
||||
createOverlayContentMeasurementStore: () => ({ kind: 'overlay-content-store' }) as never,
|
||||
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => {
|
||||
calls.push(`shortcuts:${String(isActive)}`);
|
||||
},
|
||||
getSyncOverlayVisibilityForModal: () => () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
createOverlayModalRuntime,
|
||||
createAppState: (input) => ({ kind: 'app-state', input }) satisfies AppStateStub,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(boot.configDir, '/tmp/config');
|
||||
assert.equal(boot.userDataPath, '/tmp/config');
|
||||
assert.equal(boot.defaultImmersionDbPath, '/tmp/config/immersion.sqlite');
|
||||
assert.equal(boot.appState.input.mpvSocketPath, '/tmp/mpv.sock');
|
||||
assert.equal(boot.appState.input.texthookerPort, 5174);
|
||||
assert.equal(overlayModalInputStateParams.length, 1);
|
||||
assert.equal(overlayModalInputStateParams[0]?.getModalWindow(), modalWindow);
|
||||
overlayModalInputStateParams[0]?.syncOverlayShortcutsForModal(true);
|
||||
overlayModalInputStateParams[0]?.syncOverlayVisibilityForModal();
|
||||
assert.deepEqual(calls, ['modal-state:true', 'modal:undefined', 'shortcuts:true', 'visibility']);
|
||||
assert.equal(boot.overlayManager, overlayManager);
|
||||
assert.equal(boot.overlayModalRuntime.kind, 'overlay-modal-runtime');
|
||||
});
|
||||
173
src/main/main-boot-services-bootstrap.ts
Normal file
173
src/main/main-boot-services-bootstrap.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { ConfigStartupParseError } from '../config';
|
||||
import {
|
||||
createMainBootServices,
|
||||
type MainBootServicesResult,
|
||||
type OverlayModalInputStateShape,
|
||||
type AppLifecycleShape,
|
||||
} from './boot/services';
|
||||
|
||||
export interface MainBootServicesBootstrapInput<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp,
|
||||
> {
|
||||
system: {
|
||||
platform: NodeJS.Platform;
|
||||
argv: string[];
|
||||
appDataDir: string | undefined;
|
||||
xdgConfigHome: string | undefined;
|
||||
homeDir: string;
|
||||
defaultMpvLogFile: string;
|
||||
envMpvLog: string | undefined;
|
||||
defaultTexthookerPort: number;
|
||||
getDefaultSocketPath: () => string;
|
||||
resolveConfigDir: (input: {
|
||||
platform: NodeJS.Platform;
|
||||
appDataDir: string | undefined;
|
||||
xdgConfigHome: string | undefined;
|
||||
homeDir: string;
|
||||
existsSync: (targetPath: string) => boolean;
|
||||
}) => string;
|
||||
existsSync: (targetPath: string) => boolean;
|
||||
mkdirSync: (targetPath: string, options: { recursive: true }) => void;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
app: {
|
||||
setPath: (name: string, value: string) => void;
|
||||
quit: () => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
|
||||
on: Function;
|
||||
whenReady: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
singleInstance: {
|
||||
shouldBypassSingleInstanceLock: () => boolean;
|
||||
requestSingleInstanceLockEarly: () => boolean;
|
||||
registerSecondInstanceHandlerEarly: (
|
||||
listener: (_event: unknown, argv: string[]) => void,
|
||||
) => void;
|
||||
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
|
||||
};
|
||||
factories: {
|
||||
createConfigService: (configDir: string) => TConfigService;
|
||||
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
|
||||
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
|
||||
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
|
||||
createSubtitleWebSocket: () => TSubtitleWebSocket;
|
||||
createLogger: (scope: string) => TLogger & {
|
||||
warn: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
error: (message: string, details?: unknown) => void;
|
||||
};
|
||||
createMainRuntimeRegistry: () => TRuntimeRegistry;
|
||||
createOverlayManager: () => TOverlayManager;
|
||||
createOverlayModalInputState: (params: {
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
}) => TOverlayModalInputState;
|
||||
createOverlayContentMeasurementStore: (params: {
|
||||
logger: TLogger;
|
||||
}) => TOverlayContentMeasurementStore;
|
||||
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void;
|
||||
getSyncOverlayVisibilityForModal: () => () => void;
|
||||
createOverlayModalRuntime: (params: {
|
||||
overlayManager: TOverlayManager;
|
||||
overlayModalInputState: TOverlayModalInputState;
|
||||
onModalStateChange: (isActive: boolean) => void;
|
||||
}) => TOverlayModalRuntime;
|
||||
createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => TAppState;
|
||||
};
|
||||
}
|
||||
|
||||
export function createMainBootServicesBootstrap<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager extends { getModalWindow: () => BrowserWindow | null },
|
||||
TOverlayModalInputState extends OverlayModalInputStateShape,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp extends AppLifecycleShape,
|
||||
>(
|
||||
input: MainBootServicesBootstrapInput<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp
|
||||
>,
|
||||
): MainBootServicesResult<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp
|
||||
> {
|
||||
return createMainBootServices({
|
||||
platform: input.system.platform,
|
||||
argv: input.system.argv,
|
||||
appDataDir: input.system.appDataDir,
|
||||
xdgConfigHome: input.system.xdgConfigHome,
|
||||
homeDir: input.system.homeDir,
|
||||
defaultMpvLogFile: input.system.defaultMpvLogFile,
|
||||
envMpvLog: input.system.envMpvLog,
|
||||
defaultTexthookerPort: input.system.defaultTexthookerPort,
|
||||
getDefaultSocketPath: input.system.getDefaultSocketPath,
|
||||
resolveConfigDir: input.system.resolveConfigDir,
|
||||
existsSync: input.system.existsSync,
|
||||
mkdirSync: input.system.mkdirSync,
|
||||
joinPath: input.system.joinPath,
|
||||
app: input.system.app,
|
||||
shouldBypassSingleInstanceLock: input.singleInstance.shouldBypassSingleInstanceLock,
|
||||
requestSingleInstanceLockEarly: input.singleInstance.requestSingleInstanceLockEarly,
|
||||
registerSecondInstanceHandlerEarly: input.singleInstance.registerSecondInstanceHandlerEarly,
|
||||
onConfigStartupParseError: input.singleInstance.onConfigStartupParseError,
|
||||
createConfigService: input.factories.createConfigService,
|
||||
createAnilistTokenStore: input.factories.createAnilistTokenStore,
|
||||
createJellyfinTokenStore: input.factories.createJellyfinTokenStore,
|
||||
createAnilistUpdateQueue: input.factories.createAnilistUpdateQueue,
|
||||
createSubtitleWebSocket: input.factories.createSubtitleWebSocket,
|
||||
createLogger: input.factories.createLogger,
|
||||
createMainRuntimeRegistry: input.factories.createMainRuntimeRegistry,
|
||||
createOverlayManager: input.factories.createOverlayManager,
|
||||
createOverlayModalInputState: input.factories.createOverlayModalInputState,
|
||||
createOverlayContentMeasurementStore: input.factories.createOverlayContentMeasurementStore,
|
||||
getSyncOverlayShortcutsForModal: input.factories.getSyncOverlayShortcutsForModal,
|
||||
getSyncOverlayVisibilityForModal: input.factories.getSyncOverlayVisibilityForModal,
|
||||
createOverlayModalRuntime: input.factories.createOverlayModalRuntime,
|
||||
createAppState: input.factories.createAppState,
|
||||
});
|
||||
}
|
||||
253
src/main/main-early-runtime.ts
Normal file
253
src/main/main-early-runtime.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { ConfigService } from '../config';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import type { AppState } from './state';
|
||||
import { createFirstRunRuntimeCoordinator } from './first-run-runtime-coordinator';
|
||||
import { createStartupSupportFromMainState } from './startup-support-coordinator';
|
||||
import { createYoutubeRuntimeFromMainState } from './youtube-runtime-coordinator';
|
||||
import { createOverlayMpvSubtitleSuppressionRuntime } from './runtime/overlay-mpv-sub-visibility';
|
||||
import { createDiscordPresenceRuntimeFromMainState } from './runtime/discord-presence-runtime';
|
||||
import type { OverlayGeometryRuntime } from './overlay-geometry-runtime';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
||||
import type { OverlayUiRuntime } from './overlay-ui-runtime';
|
||||
|
||||
export interface MainEarlyRuntimeInput {
|
||||
platform: NodeJS.Platform;
|
||||
configDir: string;
|
||||
homeDir: string;
|
||||
xdgConfigHome?: string;
|
||||
binaryPath: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
appDataDir: string;
|
||||
desktopDir: string;
|
||||
defaultImmersionDbPath: string;
|
||||
defaultJimakuLanguagePreference: ResolvedConfig['jimaku']['languagePreference'];
|
||||
defaultJimakuMaxEntryResults: number;
|
||||
defaultJimakuApiBaseUrl: string;
|
||||
jellyfinLangPref: string;
|
||||
youtube: {
|
||||
directPlaybackFormat: string;
|
||||
mpvYtdlFormat: string;
|
||||
autoLaunchTimeoutMs: number;
|
||||
connectTimeoutMs: number;
|
||||
logPath: string;
|
||||
};
|
||||
discordPresenceAppId: string;
|
||||
appState: AppState;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getFallbackDiscordMediaDurationSec: () => number | null;
|
||||
configService: Pick<ConfigService, 'reloadConfigStrict'>;
|
||||
overlay: {
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
};
|
||||
overlayModalRuntime: {
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
getOverlayUi: () => OverlayUiRuntime<BrowserWindow> | null;
|
||||
getOverlayGeometry: () => OverlayGeometryRuntime<BrowserWindow>;
|
||||
ensureTray: () => void;
|
||||
hasTray: () => boolean;
|
||||
};
|
||||
yomitan: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
getParserRuntimeDeps: () => Parameters<
|
||||
typeof import('../core/services').getYomitanDictionaryInfo
|
||||
>[0];
|
||||
openYomitanSettings: () => boolean;
|
||||
};
|
||||
subtitle: {
|
||||
getSubtitle: () => SubtitleRuntime;
|
||||
};
|
||||
tokenization: {
|
||||
startTokenizationWarmups: () => Promise<void>;
|
||||
getGate: Parameters<typeof createYoutubeRuntimeFromMainState>[0]['tokenization']['getGate'];
|
||||
};
|
||||
appReady: {
|
||||
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
|
||||
};
|
||||
shortcuts: {
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
};
|
||||
notifications: {
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
showErrorBox: (title: string, content: string) => void;
|
||||
};
|
||||
mpv: {
|
||||
sendMpvCommandRuntime: (client: AppState['mpvClient'], command: (string | number)[]) => void;
|
||||
setSubVisibility: (visible: boolean) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
};
|
||||
actions: {
|
||||
requestAppQuit: () => void;
|
||||
writeShortcutLink: (
|
||||
shortcutPath: string,
|
||||
operation: 'create' | 'update' | 'replace',
|
||||
details: {
|
||||
target: string;
|
||||
args?: string;
|
||||
cwd?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
iconIndex?: number;
|
||||
},
|
||||
) => boolean;
|
||||
};
|
||||
logger: {
|
||||
error: (message: string, error?: unknown) => void;
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, error?: unknown) => void;
|
||||
debug: (message: string, meta?: unknown) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function createMainEarlyRuntime(input: MainEarlyRuntimeInput) {
|
||||
const firstRun = createFirstRunRuntimeCoordinator({
|
||||
platform: input.platform,
|
||||
configDir: input.configDir,
|
||||
homeDir: input.homeDir,
|
||||
xdgConfigHome: input.xdgConfigHome,
|
||||
binaryPath: input.binaryPath,
|
||||
appPath: input.appPath,
|
||||
resourcesPath: input.resourcesPath,
|
||||
appDataDir: input.appDataDir,
|
||||
desktopDir: input.desktopDir,
|
||||
appState: input.appState,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
yomitan: input.yomitan,
|
||||
overlay: {
|
||||
ensureTray: () => input.overlay.ensureTray(),
|
||||
hasTray: () => input.overlay.hasTray(),
|
||||
},
|
||||
actions: {
|
||||
writeShortcutLink: (shortcutPath, operation, details) =>
|
||||
input.actions.writeShortcutLink(shortcutPath, operation, details),
|
||||
requestAppQuit: () => input.actions.requestAppQuit(),
|
||||
},
|
||||
logger: {
|
||||
error: (message, error) => input.logger.error(message, error),
|
||||
info: (message, ...args) => input.logger.info(message, ...args),
|
||||
},
|
||||
});
|
||||
|
||||
const { discordPresenceRuntime, initializeDiscordPresenceService } =
|
||||
createDiscordPresenceRuntimeFromMainState({
|
||||
appId: input.discordPresenceAppId,
|
||||
appState: input.appState,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
getFallbackMediaDurationSec: () => input.getFallbackDiscordMediaDurationSec(),
|
||||
logger: {
|
||||
debug: (message, meta) => input.logger.debug(message, meta),
|
||||
},
|
||||
});
|
||||
|
||||
const overlaySubtitleSuppression = createOverlayMpvSubtitleSuppressionRuntime({
|
||||
appState: input.appState,
|
||||
getVisibleOverlayVisible: () => input.overlay.overlayManager.getVisibleOverlayVisible(),
|
||||
setMpvSubVisibility: (visible) => input.mpv.setSubVisibility(visible),
|
||||
logWarn: (message, error) => input.logger.warn(message, error),
|
||||
});
|
||||
|
||||
const startupSupport = createStartupSupportFromMainState({
|
||||
platform: input.platform,
|
||||
defaultImmersionDbPath: input.defaultImmersionDbPath,
|
||||
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
|
||||
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
|
||||
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
|
||||
jellyfinLangPref: input.jellyfinLangPref,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
appState: input.appState,
|
||||
configService: input.configService,
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
input.overlay.overlayModalRuntime.sendToActiveOverlayWindow(
|
||||
channel,
|
||||
payload,
|
||||
runtimeOptions,
|
||||
),
|
||||
},
|
||||
shortcuts: {
|
||||
refreshGlobalAndOverlayShortcuts: () => input.shortcuts.refreshGlobalAndOverlayShortcuts(),
|
||||
},
|
||||
notifications: {
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.notifications.showDesktopNotification(title, options),
|
||||
showErrorBox: (title, details) => input.notifications.showErrorBox(title, details),
|
||||
},
|
||||
logger: {
|
||||
debug: (message) => input.logger.debug(message),
|
||||
info: (message) => input.logger.info(message),
|
||||
warn: (message, error) => input.logger.warn(message, error),
|
||||
},
|
||||
mpv: {
|
||||
sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command),
|
||||
showMpvOsd: (text) => input.mpv.showMpvOsd(text),
|
||||
},
|
||||
});
|
||||
|
||||
const youtube = createYoutubeRuntimeFromMainState({
|
||||
platform: input.platform,
|
||||
directPlaybackFormat: input.youtube.directPlaybackFormat,
|
||||
mpvYtdlFormat: input.youtube.mpvYtdlFormat,
|
||||
autoLaunchTimeoutMs: input.youtube.autoLaunchTimeoutMs,
|
||||
connectTimeoutMs: input.youtube.connectTimeoutMs,
|
||||
logPath: input.youtube.logPath,
|
||||
appState: input.appState,
|
||||
overlay: {
|
||||
getOverlayUi: () => input.overlay.getOverlayUi(),
|
||||
getMainWindow: () => input.overlay.overlayManager.getMainWindow(),
|
||||
getOverlayGeometry: () => input.overlay.getOverlayGeometry(),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload),
|
||||
},
|
||||
subtitle: {
|
||||
getSubtitle: () => input.subtitle.getSubtitle(),
|
||||
},
|
||||
tokenization: {
|
||||
startTokenizationWarmups: () => input.tokenization.startTokenizationWarmups(),
|
||||
getGate: () => input.tokenization.getGate(),
|
||||
},
|
||||
appReady: {
|
||||
ensureYoutubePlaybackRuntimeReady: () => input.appReady.ensureYoutubePlaybackRuntimeReady(),
|
||||
},
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
notifications: {
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.notifications.showDesktopNotification(title, options),
|
||||
showErrorBox: (title, content) => input.notifications.showErrorBox(title, content),
|
||||
},
|
||||
mpv: {
|
||||
sendMpvCommand: (command) =>
|
||||
input.mpv.sendMpvCommandRuntime(input.appState.mpvClient, command),
|
||||
showMpvOsd: (message) => input.mpv.showMpvOsd(message),
|
||||
},
|
||||
logger: {
|
||||
info: (message) => input.logger.info(message),
|
||||
warn: (message, error) => input.logger.warn(message, error),
|
||||
debug: (message) => input.logger.debug(message),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
firstRun,
|
||||
discordPresenceRuntime,
|
||||
initializeDiscordPresenceService,
|
||||
overlaySubtitleSuppression,
|
||||
startupSupport,
|
||||
youtube,
|
||||
};
|
||||
}
|
||||
129
src/main/main-playback-runtime.ts
Normal file
129
src/main/main-playback-runtime.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import type { MpvSubtitleRenderMetrics } from '../types';
|
||||
import type { MpvIpcClient } from '../core/services/mpv';
|
||||
import { sendMpvCommandRuntime } from '../core/services';
|
||||
import type { AnilistRuntime } from './anilist-runtime';
|
||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
||||
import type { JellyfinRuntime } from './jellyfin-runtime';
|
||||
import { createMiningRuntime } from './mining-runtime';
|
||||
import type { MiningRuntimeInput } from './mining-runtime';
|
||||
import { createMpvRuntimeFromMainState } from './mpv-runtime-bootstrap';
|
||||
import type { MpvRuntime } from './mpv-runtime';
|
||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
||||
import type { YoutubeRuntime } from './youtube-runtime';
|
||||
import type { AppState } from './state';
|
||||
|
||||
export interface MainPlaybackRuntimeInput {
|
||||
appState: AppState;
|
||||
logPath: string;
|
||||
logger: Parameters<typeof createMpvRuntimeFromMainState>[0]['logger'] & {
|
||||
error: (message: string, error: unknown) => void;
|
||||
};
|
||||
getResolvedConfig: Parameters<typeof createMpvRuntimeFromMainState>[0]['getResolvedConfig'];
|
||||
getRuntimeBooleanOption: Parameters<
|
||||
typeof createMpvRuntimeFromMainState
|
||||
>[0]['getRuntimeBooleanOption'];
|
||||
subtitle: SubtitleRuntime;
|
||||
yomitan: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
};
|
||||
currentMediaTokenizationGate: Parameters<
|
||||
typeof createMpvRuntimeFromMainState
|
||||
>[0]['currentMediaTokenizationGate'];
|
||||
startupOsdSequencer: Parameters<typeof createMpvRuntimeFromMainState>[0]['startupOsdSequencer'];
|
||||
dictionarySupport: DictionarySupportRuntime;
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined;
|
||||
};
|
||||
lifecycle: {
|
||||
requestAppQuit: () => void;
|
||||
restoreOverlayMpvSubtitles: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
publishDiscordPresence: () => void;
|
||||
};
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
};
|
||||
anilist: AnilistRuntime;
|
||||
jellyfin: JellyfinRuntime;
|
||||
youtube: YoutubeRuntime;
|
||||
mining: Omit<
|
||||
MiningRuntimeInput<any, any>,
|
||||
'showMpvOsd' | 'sendMpvCommand' | 'logError' | 'recordCardsMined'
|
||||
> & {
|
||||
readClipboardText: () => string;
|
||||
writeClipboardText: (text: string) => void;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MainPlaybackRuntime {
|
||||
mpvRuntime: MpvRuntime;
|
||||
mining: ReturnType<typeof createMiningRuntime>;
|
||||
}
|
||||
|
||||
export function createMainPlaybackRuntime(input: MainPlaybackRuntimeInput): MainPlaybackRuntime {
|
||||
let mpvRuntime!: MpvRuntime;
|
||||
|
||||
const showMpvOsd = (text: string): void => {
|
||||
mpvRuntime.showMpvOsd(text);
|
||||
};
|
||||
|
||||
const mining = createMiningRuntime({
|
||||
...input.mining,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
sendMpvCommand: (command) => {
|
||||
sendMpvCommandRuntime(input.appState.mpvClient, command);
|
||||
},
|
||||
logError: (message, err) => {
|
||||
input.logger.error(message, err);
|
||||
},
|
||||
recordCardsMined: (count, noteIds) => input.mining.recordCardsMined(count, noteIds),
|
||||
});
|
||||
|
||||
mpvRuntime = createMpvRuntimeFromMainState({
|
||||
appState: input.appState,
|
||||
logPath: input.logPath,
|
||||
logger: input.logger,
|
||||
getResolvedConfig: input.getResolvedConfig,
|
||||
getRuntimeBooleanOption: input.getRuntimeBooleanOption,
|
||||
subtitle: input.subtitle,
|
||||
yomitan: {
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
||||
},
|
||||
},
|
||||
currentMediaTokenizationGate: input.currentMediaTokenizationGate,
|
||||
startupOsdSequencer: input.startupOsdSequencer,
|
||||
dictionarySupport: input.dictionarySupport,
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
input.overlay.broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
|
||||
getOverlayUi: () => input.overlay.getOverlayUi(),
|
||||
},
|
||||
lifecycle: {
|
||||
requestAppQuit: () => input.lifecycle.requestAppQuit(),
|
||||
setQuitCheckTimer: (callback, timeoutMs) => {
|
||||
setTimeout(callback, timeoutMs);
|
||||
},
|
||||
restoreOverlayMpvSubtitles: input.lifecycle.restoreOverlayMpvSubtitles,
|
||||
syncOverlayMpvSubtitleSuppression: input.lifecycle.syncOverlayMpvSubtitleSuppression,
|
||||
publishDiscordPresence: () => input.lifecycle.publishDiscordPresence(),
|
||||
},
|
||||
stats: input.stats,
|
||||
anilist: input.anilist,
|
||||
jellyfin: input.jellyfin,
|
||||
youtube: input.youtube,
|
||||
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
|
||||
}).mpvRuntime;
|
||||
|
||||
return {
|
||||
mpvRuntime,
|
||||
mining,
|
||||
};
|
||||
}
|
||||
54
src/main/main-startup-bootstrap-types.ts
Normal file
54
src/main/main-startup-bootstrap-types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import type { ResolvedConfig, SecondarySubMode, SubtitleData } from '../types';
|
||||
import { RuntimeOptionsManager } from '../runtime-options';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
|
||||
export type StartupBootstrapMpvClientLike = {
|
||||
connected: boolean;
|
||||
connect: () => void;
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
currentSubStart?: number | null;
|
||||
currentSubEnd?: number | null;
|
||||
};
|
||||
|
||||
export type StartupBootstrapAppStateLike = {
|
||||
subtitlePosition: unknown | null;
|
||||
keybindings: unknown[];
|
||||
mpvSocketPath: string;
|
||||
texthookerPort: number;
|
||||
mpvClient: StartupBootstrapMpvClientLike | null;
|
||||
runtimeOptionsManager: RuntimeOptionsManager | null;
|
||||
subtitleTimingTracker: SubtitleTimingTracker | null;
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
currentSubText: string | null;
|
||||
initialArgs: CliArgs | null | undefined;
|
||||
backgroundMode: boolean;
|
||||
texthookerOnlyMode: boolean;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
firstRunSetupCompleted: boolean;
|
||||
secondarySubMode: SecondarySubMode;
|
||||
ankiIntegration: unknown | null;
|
||||
immersionTracker: unknown | null;
|
||||
};
|
||||
|
||||
export type StartupBootstrapSubtitleWebsocketLike = {
|
||||
start: (
|
||||
port: number,
|
||||
getPayload: () => SubtitleData | null,
|
||||
getFrequencyOptions: () => {
|
||||
enabled: boolean;
|
||||
topX: number;
|
||||
mode: ResolvedConfig['subtitleStyle']['frequencyDictionary']['mode'];
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type StartupBootstrapOverlayUiLike = {
|
||||
broadcastRuntimeOptionsChanged: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
ensureTray: () => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
};
|
||||
505
src/main/main-startup-bootstrap.ts
Normal file
505
src/main/main-startup-bootstrap.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
||||
import type { LogLevelSource } from '../logger';
|
||||
import type { ConfigValidationWarning, ResolvedConfig, SubtitleData } from '../types';
|
||||
import type { StartupBootstrapRuntimeDeps } from '../core/services/startup';
|
||||
import { resolveKeybindings } from '../core/utils';
|
||||
import { RuntimeOptionsManager } from '../runtime-options';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import type { AppReadyRuntimeInput } from './app-ready-runtime';
|
||||
import type {
|
||||
CliCommandRuntimeServiceContext,
|
||||
CliCommandRuntimeServiceContextHandlers,
|
||||
} from './cli-runtime';
|
||||
import type {
|
||||
StartupBootstrapAppStateLike,
|
||||
StartupBootstrapMpvClientLike,
|
||||
StartupBootstrapOverlayUiLike,
|
||||
StartupBootstrapSubtitleWebsocketLike,
|
||||
} from './main-startup-bootstrap-types';
|
||||
import type { MainStartupRuntime } from './main-startup-runtime';
|
||||
import { createMainStartupRuntime } from './main-startup-runtime';
|
||||
|
||||
export interface MainStartupBootstrapInput<TStartupState> {
|
||||
appState: StartupBootstrapAppStateLike;
|
||||
appLifecycle: {
|
||||
app: unknown;
|
||||
argv: string[];
|
||||
platform: NodeJS.Platform;
|
||||
};
|
||||
config: {
|
||||
configService: {
|
||||
reloadConfigStrict: AppReadyRuntimeInput['reload']['reloadConfigStrict'];
|
||||
getConfigPath: () => string;
|
||||
getWarnings: () => ConfigValidationWarning[];
|
||||
getConfig: () => ResolvedConfig;
|
||||
};
|
||||
configHotReloadRuntime: {
|
||||
start: () => void;
|
||||
};
|
||||
configDerivedRuntime: {
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
||||
};
|
||||
ensureDefaultConfigBootstrap: (options: {
|
||||
configDir: string;
|
||||
configFilePaths: unknown;
|
||||
generateTemplate: () => string;
|
||||
}) => void;
|
||||
getDefaultConfigFilePaths: (configDir: string) => unknown;
|
||||
generateConfigTemplate: (config: ResolvedConfig) => string;
|
||||
defaultConfig: ResolvedConfig;
|
||||
defaultKeybindings: unknown;
|
||||
configDir: string;
|
||||
};
|
||||
logging: {
|
||||
appLogger: {
|
||||
logInfo: (message: string) => void;
|
||||
logWarning: (message: string) => void;
|
||||
logConfigWarning: (warning: ConfigValidationWarning) => void;
|
||||
logNoRunningInstance: () => void;
|
||||
};
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, error?: unknown) => void;
|
||||
error: (message: string, error?: unknown) => void;
|
||||
debug: (message: string) => void;
|
||||
};
|
||||
setLogLevel: (level: string, source: LogLevelSource) => void;
|
||||
};
|
||||
shell: {
|
||||
dialog: {
|
||||
showErrorBox: (title: string, message: string) => void;
|
||||
};
|
||||
shell: {
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
};
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
};
|
||||
runtime: {
|
||||
subtitle: {
|
||||
loadSubtitlePosition: () => void;
|
||||
invalidateTokenizationCache: () => void;
|
||||
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
|
||||
};
|
||||
overlayUi: {
|
||||
get: () => StartupBootstrapOverlayUiLike | undefined;
|
||||
};
|
||||
overlayManager: {
|
||||
getMainWindow: () => unknown | null;
|
||||
};
|
||||
firstRun: {
|
||||
ensureSetupStateInitialized: () => Promise<{ state: { status: string } }>;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
};
|
||||
anilist: {
|
||||
refreshAnilistClientSecretStateIfEnabled: (options: {
|
||||
force: boolean;
|
||||
allowSetupPrompt?: boolean;
|
||||
}) => Promise<unknown>;
|
||||
openAnilistSetupWindow: () => void;
|
||||
getStatusSnapshot: CliCommandRuntimeServiceContext['getAnilistStatus'];
|
||||
clearTokenState: () => void;
|
||||
getQueueStatusSnapshot: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
|
||||
processNextAnilistRetryUpdate: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
|
||||
};
|
||||
jellyfin: {
|
||||
startJellyfinRemoteSession: () => Promise<void>;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
runJellyfinCommand: (argsFromCommand: CliArgs) => Promise<void>;
|
||||
};
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
runStatsCliCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
||||
};
|
||||
mining: {
|
||||
copyCurrentSubtitle: () => void;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
};
|
||||
yomitan: {
|
||||
loadYomitanExtension: () => Promise<unknown>;
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
openYomitanSettings: () => void;
|
||||
};
|
||||
subsyncRuntime: {
|
||||
triggerFromConfig: () => Promise<void>;
|
||||
};
|
||||
dictionarySupport: {
|
||||
generateCharacterDictionaryForCurrentMedia: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||
};
|
||||
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
|
||||
subtitleWsService: StartupBootstrapSubtitleWebsocketLike;
|
||||
annotationSubtitleWsService: StartupBootstrapSubtitleWebsocketLike;
|
||||
immersion: AppReadyRuntimeInput['immersion'];
|
||||
};
|
||||
commands: {
|
||||
createMpvClientRuntimeService: () => StartupBootstrapMpvClientLike;
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries: () => Promise<void>;
|
||||
startBackgroundWarmupsIfAllowed: () => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
runHeadlessInitialCommand: () => Promise<void>;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
refreshOverlayShortcuts: () => void;
|
||||
hasMpvWebsocketPlugin: () => boolean;
|
||||
startTexthooker: (port: number, websocketUrl?: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
shouldAutoOpenFirstRunSetup: (args: CliArgs) => boolean;
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
url: string;
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
source: CliCommandSource;
|
||||
}) => Promise<void>;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
shouldEnsureTrayOnStartupForInitialArgs: (
|
||||
platform: NodeJS.Platform,
|
||||
initialArgs: CliArgs | null | undefined,
|
||||
) => boolean;
|
||||
isHeadlessInitialCommand: (args: CliArgs) => boolean;
|
||||
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||
handleCliCommandRuntimeServiceWithContext: (
|
||||
args: CliArgs,
|
||||
source: CliCommandSource,
|
||||
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
|
||||
) => void;
|
||||
shouldStartApp: (args: CliArgs) => boolean;
|
||||
parseArgs: (argv: string[]) => CliArgs;
|
||||
printHelp: (defaultTexthookerPort: number) => void;
|
||||
onWillQuitCleanupHandler: () => void;
|
||||
shouldRestoreWindowsOnActivateHandler: () => boolean;
|
||||
restoreWindowsOnActivateHandler: () => void;
|
||||
forceX11Backend: (args: CliArgs) => void;
|
||||
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
|
||||
getDefaultSocketPathHandler: () => string;
|
||||
generateDefaultConfigFile: (
|
||||
args: CliArgs,
|
||||
options: {
|
||||
configDir: string;
|
||||
defaultConfig: unknown;
|
||||
generateTemplate: (config: unknown) => string;
|
||||
},
|
||||
) => Promise<number>;
|
||||
runStartupBootstrapRuntime: (deps: StartupBootstrapRuntimeDeps) => TStartupState;
|
||||
applyStartupState: (startupState: TStartupState) => void;
|
||||
getStartupModeFlags: (initialArgs: CliArgs | null | undefined) => {
|
||||
shouldUseMinimalStartup: boolean;
|
||||
shouldSkipHeavyStartup: boolean;
|
||||
};
|
||||
requestAppQuit: () => void;
|
||||
};
|
||||
constants: {
|
||||
defaultTexthookerPort: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function createMainStartupBootstrap<TStartupState>(
|
||||
input: MainStartupBootstrapInput<TStartupState>,
|
||||
): MainStartupRuntime<TStartupState> {
|
||||
let startup: MainStartupRuntime<TStartupState> | null = null;
|
||||
const getStartup = (): MainStartupRuntime<TStartupState> => {
|
||||
if (!startup) {
|
||||
throw new Error('Main startup runtime not initialized');
|
||||
}
|
||||
return startup;
|
||||
};
|
||||
const getOverlayUi = (): StartupBootstrapOverlayUiLike | undefined =>
|
||||
input.runtime.overlayUi.get();
|
||||
const getSubtitlePayload = (): SubtitleData | null =>
|
||||
input.appState.currentSubtitleData ??
|
||||
(input.appState.currentSubText
|
||||
? {
|
||||
text: input.appState.currentSubText,
|
||||
tokens: null,
|
||||
startTime: input.appState.mpvClient?.currentSubStart ?? null,
|
||||
endTime: input.appState.mpvClient?.currentSubEnd ?? null,
|
||||
}
|
||||
: null);
|
||||
const getSubtitleFrequencyOptions = () => ({
|
||||
enabled: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
topX: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.topX,
|
||||
mode: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.mode,
|
||||
});
|
||||
|
||||
startup = createMainStartupRuntime<TStartupState>({
|
||||
appReady: {
|
||||
reload: {
|
||||
reloadConfigStrict: () => input.config.configService.reloadConfigStrict(),
|
||||
logInfo: (message) => input.logging.appLogger.logInfo(message),
|
||||
logWarning: (message) => input.logging.appLogger.logWarning(message),
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.shell.showDesktopNotification(title, options),
|
||||
startConfigHotReload: () => input.config.configHotReloadRuntime.start(),
|
||||
refreshAnilistClientSecretState: (options) =>
|
||||
input.runtime.anilist.refreshAnilistClientSecretStateIfEnabled(options),
|
||||
failHandlers: {
|
||||
logError: (details) => input.logging.logger.error(details),
|
||||
showErrorBox: (title, details) => input.shell.dialog.showErrorBox(title, details),
|
||||
quit: () => input.commands.requestAppQuit(),
|
||||
},
|
||||
},
|
||||
criticalConfig: {
|
||||
getConfigPath: () => input.config.configService.getConfigPath(),
|
||||
failHandlers: {
|
||||
logError: (message) => input.logging.logger.error(message),
|
||||
showErrorBox: (title, message) => input.shell.dialog.showErrorBox(title, message),
|
||||
quit: () => input.commands.requestAppQuit(),
|
||||
},
|
||||
},
|
||||
runner: {
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
input.config.ensureDefaultConfigBootstrap({
|
||||
configDir: input.config.configDir,
|
||||
configFilePaths: input.config.getDefaultConfigFilePaths(input.config.configDir),
|
||||
generateTemplate: () => input.config.generateConfigTemplate(input.config.defaultConfig),
|
||||
});
|
||||
},
|
||||
getSubtitlePosition: () => input.appState.subtitlePosition,
|
||||
loadSubtitlePosition: () => input.runtime.subtitle.loadSubtitlePosition(),
|
||||
getKeybindingsCount: () => input.appState.keybindings.length,
|
||||
resolveKeybindings: () => {
|
||||
input.appState.keybindings = resolveKeybindings(
|
||||
input.config.configService.getConfig(),
|
||||
input.config.defaultKeybindings as never,
|
||||
);
|
||||
},
|
||||
hasMpvClient: () => Boolean(input.appState.mpvClient),
|
||||
createMpvClient: () => {
|
||||
input.appState.mpvClient = input.commands.createMpvClientRuntimeService();
|
||||
},
|
||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
||||
getResolvedConfig: () => input.config.configService.getConfig(),
|
||||
getConfigWarnings: () => input.config.configService.getWarnings(),
|
||||
logConfigWarning: (warning) => input.logging.appLogger.logConfigWarning(warning),
|
||||
setLogLevel: (level, source) => input.logging.setLogLevel(level, source),
|
||||
initRuntimeOptionsManager: () => {
|
||||
input.appState.runtimeOptionsManager = new RuntimeOptionsManager(
|
||||
() => input.config.configService.getConfig().ankiConnect,
|
||||
{
|
||||
applyAnkiPatch: (patch: unknown) => {
|
||||
(
|
||||
input.appState.ankiIntegration as {
|
||||
applyRuntimeConfigPatch?: (patch: unknown) => void;
|
||||
} | null
|
||||
)?.applyRuntimeConfigPatch?.(patch);
|
||||
},
|
||||
getSubtitleStyleConfig: () => input.config.configService.getConfig().subtitleStyle,
|
||||
onOptionsChanged: () => {
|
||||
input.runtime.subtitle.invalidateTokenizationCache();
|
||||
void input.runtime.subtitle.refreshSubtitlePrefetchFromActiveTrack();
|
||||
getOverlayUi()?.broadcastRuntimeOptionsChanged();
|
||||
input.commands.refreshOverlayShortcuts();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
getSubtitleTimingTracker: () => input.appState.subtitleTimingTracker,
|
||||
createSubtitleTimingTracker: () => {
|
||||
input.appState.subtitleTimingTracker = new SubtitleTimingTracker();
|
||||
},
|
||||
setSecondarySubMode: (mode) => {
|
||||
input.appState.secondarySubMode = mode;
|
||||
},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: input.config.defaultConfig.websocket.port,
|
||||
defaultAnnotationWebsocketPort: input.config.defaultConfig.annotationWebsocket.port,
|
||||
defaultTexthookerPort: input.constants.defaultTexthookerPort,
|
||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
||||
startSubtitleWebsocket: (port) => {
|
||||
input.runtime.subtitleWsService.start(
|
||||
port,
|
||||
getSubtitlePayload,
|
||||
getSubtitleFrequencyOptions,
|
||||
);
|
||||
},
|
||||
startAnnotationWebsocket: (port) => {
|
||||
input.runtime.annotationSubtitleWsService.start(
|
||||
port,
|
||||
getSubtitlePayload,
|
||||
getSubtitleFrequencyOptions,
|
||||
);
|
||||
},
|
||||
startTexthooker: (port, websocketUrl) => input.commands.startTexthooker(port, websocketUrl),
|
||||
log: (message) => input.logging.appLogger.logInfo(message),
|
||||
createMecabTokenizerAndCheck: () => input.commands.createMecabTokenizerAndCheck(),
|
||||
createImmersionTracker: () => {
|
||||
input.runtime.stats.ensureImmersionTrackerStarted();
|
||||
},
|
||||
startJellyfinRemoteSession: () => input.runtime.jellyfin.startJellyfinRemoteSession(),
|
||||
loadYomitanExtension: async () => {
|
||||
await input.runtime.yomitan.loadYomitanExtension();
|
||||
},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
await input.runtime.yomitan.ensureYomitanExtensionLoaded();
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
const snapshot = await input.runtime.firstRun.ensureSetupStateInitialized();
|
||||
input.appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
|
||||
if (
|
||||
input.appState.initialArgs &&
|
||||
input.commands.shouldAutoOpenFirstRunSetup(input.appState.initialArgs) &&
|
||||
snapshot.state.status !== 'completed'
|
||||
) {
|
||||
input.runtime.firstRun.openFirstRunSetupWindow();
|
||||
}
|
||||
},
|
||||
prewarmSubtitleDictionaries: () => input.commands.prewarmSubtitleDictionaries(),
|
||||
startBackgroundWarmups: () => input.commands.startBackgroundWarmupsIfAllowed(),
|
||||
texthookerOnlyMode: input.appState.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||
input.appState.backgroundMode
|
||||
? false
|
||||
: input.config.configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||
setVisibleOverlayVisible: (visible) => getOverlayUi()?.setVisibleOverlayVisible(visible),
|
||||
initializeOverlayRuntime: () => getOverlayUi()?.initializeOverlayRuntime(),
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
||||
getOverlayUi()?.ensureOverlayWindowsReadyForVisibilityActions(),
|
||||
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
|
||||
handleInitialArgs: () => getStartup().handleInitialArgs(),
|
||||
shouldRunHeadlessInitialCommand: () =>
|
||||
Boolean(
|
||||
input.appState.initialArgs &&
|
||||
input.commands.isHeadlessInitialCommand(input.appState.initialArgs),
|
||||
),
|
||||
shouldUseMinimalStartup: () =>
|
||||
input.commands.getStartupModeFlags(input.appState.initialArgs).shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: () =>
|
||||
input.commands.getStartupModeFlags(input.appState.initialArgs).shouldSkipHeavyStartup,
|
||||
logDebug: (message) => input.logging.logger.debug(message),
|
||||
now: () => Date.now(),
|
||||
},
|
||||
immersion: input.runtime.immersion,
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
},
|
||||
cli: {
|
||||
appState: {
|
||||
appState: input.appState,
|
||||
getInitialArgs: () => input.appState.initialArgs,
|
||||
isBackgroundMode: () => input.appState.backgroundMode,
|
||||
isTexthookerOnlyMode: () => input.appState.texthookerOnlyMode,
|
||||
setTexthookerOnlyMode: (enabled) => {
|
||||
input.appState.texthookerOnlyMode = enabled;
|
||||
},
|
||||
hasImmersionTracker: () => Boolean(input.appState.immersionTracker),
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
},
|
||||
config: {
|
||||
defaultConfig: input.config.defaultConfig,
|
||||
getResolvedConfig: () => input.config.configService.getConfig(),
|
||||
setCliLogLevel: (level) => input.logging.setLogLevel(level, 'cli'),
|
||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
||||
},
|
||||
io: {
|
||||
texthookerService: input.runtime.texthookerService,
|
||||
openExternal: (url) => input.shell.shell.openExternal(url),
|
||||
logBrowserOpenError: (url, error) =>
|
||||
input.logging.logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
|
||||
showMpvOsd: (text) => input.commands.showMpvOsd(text),
|
||||
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
||||
logInfo: (message) => input.logging.logger.info(message),
|
||||
logWarn: (message) => input.logging.logger.warn(message),
|
||||
logError: (message, err) => input.logging.logger.error(message, err),
|
||||
},
|
||||
commands: {
|
||||
initializeOverlayRuntime: () => getOverlayUi()?.initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => getOverlayUi()?.toggleVisibleOverlay(),
|
||||
openFirstRunSetupWindow: () => input.runtime.firstRun.openFirstRunSetupWindow(),
|
||||
setVisibleOverlayVisible: (visible) => getOverlayUi()?.setVisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(),
|
||||
startPendingMultiCopy: (timeoutMs) => input.commands.startPendingMultiCopy(timeoutMs),
|
||||
mineSentenceCard: () => input.runtime.mining.mineSentenceCard(),
|
||||
startPendingMineSentenceMultiple: (timeoutMs) =>
|
||||
input.commands.startPendingMineSentenceMultiple(timeoutMs),
|
||||
updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(),
|
||||
refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(),
|
||||
triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => input.runtime.subsyncRuntime.triggerFromConfig(),
|
||||
markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(),
|
||||
getAnilistStatus: () => input.runtime.anilist.getStatusSnapshot(),
|
||||
clearAnilistToken: () => input.runtime.anilist.clearTokenState(),
|
||||
openAnilistSetupWindow: () => input.runtime.anilist.openAnilistSetupWindow(),
|
||||
openJellyfinSetupWindow: () => input.runtime.jellyfin.openJellyfinSetupWindow(),
|
||||
getAnilistQueueStatus: () => input.runtime.anilist.getQueueStatusSnapshot(),
|
||||
processNextAnilistRetryUpdate: () => input.runtime.anilist.processNextAnilistRetryUpdate(),
|
||||
generateCharacterDictionary: (targetPath?: string) =>
|
||||
input.commands.generateCharacterDictionary(targetPath),
|
||||
runJellyfinCommand: (argsFromCommand) =>
|
||||
input.runtime.jellyfin.runJellyfinCommand(argsFromCommand),
|
||||
runStatsCommand: (argsFromCommand, source) =>
|
||||
input.runtime.stats.runStatsCliCommand(argsFromCommand, source),
|
||||
runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request),
|
||||
openYomitanSettings: () => input.runtime.yomitan.openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => getOverlayUi()?.openRuntimeOptionsPalette(),
|
||||
printHelp: () => input.commands.printHelp(input.constants.defaultTexthookerPort),
|
||||
stopApp: () => input.commands.requestAppQuit(),
|
||||
hasMainWindow: () => Boolean(input.runtime.overlayManager.getMainWindow()),
|
||||
getMultiCopyTimeoutMs: () => input.commands.getMultiCopyTimeoutMs(),
|
||||
},
|
||||
startup: {
|
||||
shouldEnsureTrayOnStartup: () =>
|
||||
input.commands.shouldEnsureTrayOnStartupForInitialArgs(
|
||||
input.appLifecycle.platform,
|
||||
input.appState.initialArgs,
|
||||
),
|
||||
shouldRunHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
|
||||
ensureTray: () => getOverlayUi()?.ensureTray(),
|
||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
||||
input.commands.commandNeedsOverlayStartupPrereqs(args),
|
||||
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
|
||||
ensureOverlayStartupPrereqs: () => getStartup().appReady.ensureOverlayStartupPrereqs(),
|
||||
startBackgroundWarmups: () => input.commands.startBackgroundWarmups(),
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (args, source, context) =>
|
||||
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, context),
|
||||
},
|
||||
headless: {
|
||||
appLifecycleRuntimeRunnerMainDeps: {
|
||||
app: input.appLifecycle.app as never,
|
||||
platform: input.appLifecycle.platform,
|
||||
shouldStartApp: (nextArgs) => input.commands.shouldStartApp(nextArgs),
|
||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
||||
handleCliCommand: (nextArgs, source) => getStartup().handleCliCommand(nextArgs, source),
|
||||
printHelp: () => input.commands.printHelp(input.constants.defaultTexthookerPort),
|
||||
logNoRunningInstance: () => input.logging.appLogger.logNoRunningInstance(),
|
||||
onReady: (): Promise<void> => getStartup().appReady.runAppReady(),
|
||||
onWillQuitCleanup: () => input.commands.onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivate: () =>
|
||||
input.commands.shouldRestoreWindowsOnActivateHandler(),
|
||||
restoreWindowsOnActivate: () => input.commands.restoreWindowsOnActivateHandler(),
|
||||
shouldQuitOnWindowAllClosed: () => !input.appState.backgroundMode,
|
||||
},
|
||||
bootstrap: {
|
||||
argv: input.appLifecycle.argv,
|
||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
||||
setLogLevel: (level, source) => input.logging.setLogLevel(level, source),
|
||||
forceX11Backend: (args) => input.commands.forceX11Backend(args),
|
||||
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
|
||||
shouldStartApp: (args) => input.commands.shouldStartApp(args),
|
||||
getDefaultSocketPath: () => input.commands.getDefaultSocketPathHandler(),
|
||||
defaultTexthookerPort: input.constants.defaultTexthookerPort,
|
||||
configDir: input.config.configDir,
|
||||
defaultConfig: input.config.defaultConfig,
|
||||
generateConfigTemplate: (config) => input.config.generateConfigTemplate(config),
|
||||
generateDefaultConfigFile: (args, options) =>
|
||||
input.commands.generateDefaultConfigFile(args, options),
|
||||
setExitCode: (exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
},
|
||||
quitApp: () => input.commands.requestAppQuit(),
|
||||
logGenerateConfigError: (message) => input.logging.logger.error(message),
|
||||
startAppLifecycle: () => {},
|
||||
},
|
||||
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
|
||||
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
|
||||
},
|
||||
});
|
||||
|
||||
return startup;
|
||||
}
|
||||
262
src/main/main-startup-runtime-bootstrap.ts
Normal file
262
src/main/main-startup-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import type { MainStartupBootstrapInput } from './main-startup-bootstrap';
|
||||
import type { MainStartupRuntime } from './main-startup-runtime';
|
||||
import type { FirstRunRuntime } from './first-run-runtime';
|
||||
import { createMainStartupBootstrap } from './main-startup-bootstrap';
|
||||
|
||||
type StartupBootstrapYomitanRuntime<TStartupState> = {
|
||||
loadYomitanExtension: MainStartupBootstrapInput<TStartupState>['runtime']['yomitan']['loadYomitanExtension'];
|
||||
ensureYomitanExtensionLoaded: MainStartupBootstrapInput<TStartupState>['runtime']['yomitan']['ensureYomitanExtensionLoaded'];
|
||||
openYomitanSettings: MainStartupBootstrapInput<TStartupState>['runtime']['yomitan']['openYomitanSettings'];
|
||||
};
|
||||
|
||||
export interface MainStartupRuntimeBootstrapInput<TStartupState> {
|
||||
appState: MainStartupBootstrapInput<TStartupState>['appState'];
|
||||
appLifecycle: {
|
||||
app: MainStartupBootstrapInput<TStartupState>['appLifecycle']['app'];
|
||||
argv: string[];
|
||||
platform: NodeJS.Platform;
|
||||
};
|
||||
config: MainStartupBootstrapInput<TStartupState>['config'];
|
||||
logging: MainStartupBootstrapInput<TStartupState>['logging'];
|
||||
shell: MainStartupBootstrapInput<TStartupState>['shell'];
|
||||
runtime: Omit<MainStartupBootstrapInput<TStartupState>['runtime'], 'overlayUi' | 'yomitan'> & {
|
||||
texthookerService: {
|
||||
isRunning: () => boolean;
|
||||
start: (port: number, websocketUrl?: string) => void;
|
||||
};
|
||||
getOverlayUi: MainStartupBootstrapInput<TStartupState>['runtime']['overlayUi']['get'];
|
||||
getYomitanRuntime: () => StartupBootstrapYomitanRuntime<TStartupState>;
|
||||
getCharacterDictionaryDisabledReason: () => string | null;
|
||||
};
|
||||
commands: Omit<
|
||||
MainStartupBootstrapInput<TStartupState>['commands'],
|
||||
| 'startTexthooker'
|
||||
| 'generateCharacterDictionary'
|
||||
| 'runYoutubePlaybackFlow'
|
||||
| 'getMultiCopyTimeoutMs'
|
||||
> & {
|
||||
getConfiguredShortcuts: () => { multiCopyTimeoutMs: number };
|
||||
runYoutubePlaybackFlow: MainStartupBootstrapInput<TStartupState>['commands']['runYoutubePlaybackFlow'];
|
||||
};
|
||||
constants: MainStartupBootstrapInput<TStartupState>['constants'];
|
||||
}
|
||||
|
||||
export interface MainStartupRuntimeBootstrap<TStartupState> {
|
||||
startupRuntime: MainStartupRuntime<TStartupState>;
|
||||
}
|
||||
|
||||
export interface MainStartupRuntimeFromMainStateInput<TStartupState> {
|
||||
appState: MainStartupRuntimeBootstrapInput<TStartupState>['appState'];
|
||||
appLifecycle: MainStartupRuntimeBootstrapInput<TStartupState>['appLifecycle'];
|
||||
config: MainStartupRuntimeBootstrapInput<TStartupState>['config'];
|
||||
logging: MainStartupRuntimeBootstrapInput<TStartupState>['logging'];
|
||||
shell: MainStartupRuntimeBootstrapInput<TStartupState>['shell'];
|
||||
runtime: {
|
||||
subtitle: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['subtitle'];
|
||||
getOverlayUi: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['getOverlayUi'];
|
||||
overlayManager: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['overlayManager'];
|
||||
firstRun: {
|
||||
ensureSetupStateInitialized: FirstRunRuntime['ensureSetupStateInitialized'];
|
||||
openFirstRunSetupWindow: () => void;
|
||||
};
|
||||
anilist: {
|
||||
refreshAnilistClientSecretStateIfEnabled: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['refreshAnilistClientSecretStateIfEnabled'];
|
||||
openAnilistSetupWindow: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['openAnilistSetupWindow'];
|
||||
getStatusSnapshot: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['getStatusSnapshot'];
|
||||
clearTokenState: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['clearTokenState'];
|
||||
getQueueStatusSnapshot: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['getQueueStatusSnapshot'];
|
||||
processNextAnilistRetryUpdate: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['processNextAnilistRetryUpdate'];
|
||||
};
|
||||
jellyfin: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['jellyfin'];
|
||||
stats: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['stats'];
|
||||
mining: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['mining'];
|
||||
texthookerService: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['texthookerService'];
|
||||
yomitan: StartupBootstrapYomitanRuntime<TStartupState>;
|
||||
getCharacterDictionaryDisabledReason: () => string | null;
|
||||
subsyncRuntime: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['subsyncRuntime'];
|
||||
dictionarySupport: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['dictionarySupport'];
|
||||
subtitleWsService: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['subtitleWsService'];
|
||||
annotationSubtitleWsService: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['annotationSubtitleWsService'];
|
||||
immersion: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['immersion'];
|
||||
};
|
||||
commands: {
|
||||
mpvRuntime: {
|
||||
createMpvClientRuntimeService: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['createMpvClientRuntimeService'];
|
||||
createMecabTokenizerAndCheck: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['createMecabTokenizerAndCheck'];
|
||||
prewarmSubtitleDictionaries: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['prewarmSubtitleDictionaries'];
|
||||
startBackgroundWarmups: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['startBackgroundWarmups'];
|
||||
};
|
||||
runHeadlessInitialCommand: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['runHeadlessInitialCommand'];
|
||||
shortcuts: {
|
||||
startPendingMultiCopy: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['startPendingMultiCopy'];
|
||||
startPendingMineSentenceMultiple: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['startPendingMineSentenceMultiple'];
|
||||
refreshOverlayShortcuts: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['refreshOverlayShortcuts'];
|
||||
getConfiguredShortcuts: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['getConfiguredShortcuts'];
|
||||
};
|
||||
cycleSecondarySubMode: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['cycleSecondarySubMode'];
|
||||
hasMpvWebsocketPlugin: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['hasMpvWebsocketPlugin'];
|
||||
showMpvOsd: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['showMpvOsd'];
|
||||
shouldAutoOpenFirstRunSetup: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldAutoOpenFirstRunSetup'];
|
||||
youtube: {
|
||||
runYoutubePlaybackFlow: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['runYoutubePlaybackFlow'];
|
||||
};
|
||||
shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldEnsureTrayOnStartupForInitialArgs'];
|
||||
isHeadlessInitialCommand: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['isHeadlessInitialCommand'];
|
||||
commandNeedsOverlayStartupPrereqs: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['commandNeedsOverlayStartupPrereqs'];
|
||||
commandNeedsOverlayRuntime: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['commandNeedsOverlayRuntime'];
|
||||
handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['handleCliCommandRuntimeServiceWithContext'];
|
||||
shouldStartApp: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldStartApp'];
|
||||
parseArgs: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['parseArgs'];
|
||||
printHelp: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['printHelp'];
|
||||
onWillQuitCleanupHandler: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['onWillQuitCleanupHandler'];
|
||||
shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldRestoreWindowsOnActivateHandler'];
|
||||
restoreWindowsOnActivateHandler: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['restoreWindowsOnActivateHandler'];
|
||||
forceX11Backend: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['forceX11Backend'];
|
||||
enforceUnsupportedWaylandMode: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['enforceUnsupportedWaylandMode'];
|
||||
getDefaultSocketPath: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['getDefaultSocketPathHandler'];
|
||||
generateDefaultConfigFile: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['generateDefaultConfigFile'];
|
||||
runStartupBootstrapRuntime: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['runStartupBootstrapRuntime'];
|
||||
applyStartupState: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['applyStartupState'];
|
||||
getStartupModeFlags: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['getStartupModeFlags'];
|
||||
requestAppQuit: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['requestAppQuit'];
|
||||
};
|
||||
constants: MainStartupRuntimeBootstrapInput<TStartupState>['constants'];
|
||||
}
|
||||
|
||||
export function createMainStartupRuntimeBootstrap<TStartupState>(
|
||||
input: MainStartupRuntimeBootstrapInput<TStartupState>,
|
||||
): MainStartupRuntimeBootstrap<TStartupState> {
|
||||
const startupRuntime = createMainStartupBootstrap<TStartupState>({
|
||||
appState: input.appState,
|
||||
appLifecycle: {
|
||||
app: input.appLifecycle.app,
|
||||
argv: input.appLifecycle.argv,
|
||||
platform: input.appLifecycle.platform,
|
||||
},
|
||||
config: input.config,
|
||||
logging: input.logging,
|
||||
shell: input.shell,
|
||||
runtime: {
|
||||
...input.runtime,
|
||||
overlayUi: {
|
||||
get: () => input.runtime.getOverlayUi(),
|
||||
},
|
||||
yomitan: {
|
||||
loadYomitanExtension: () => input.runtime.getYomitanRuntime().loadYomitanExtension(),
|
||||
ensureYomitanExtensionLoaded: () =>
|
||||
input.runtime.getYomitanRuntime().ensureYomitanExtensionLoaded(),
|
||||
openYomitanSettings: () => input.runtime.getYomitanRuntime().openYomitanSettings(),
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
...input.commands,
|
||||
startTexthooker: (port, websocketUrl) => {
|
||||
if (!input.runtime.texthookerService.isRunning()) {
|
||||
input.runtime.texthookerService.start(port, websocketUrl);
|
||||
}
|
||||
},
|
||||
generateCharacterDictionary: async (targetPath?: string) => {
|
||||
const disabledReason = input.runtime.getCharacterDictionaryDisabledReason();
|
||||
if (disabledReason) {
|
||||
throw new Error(disabledReason);
|
||||
}
|
||||
return await input.runtime.dictionarySupport.generateCharacterDictionaryForCurrentMedia(
|
||||
targetPath,
|
||||
);
|
||||
},
|
||||
runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request),
|
||||
getMultiCopyTimeoutMs: () => input.commands.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
},
|
||||
constants: input.constants,
|
||||
});
|
||||
|
||||
return {
|
||||
startupRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMainStartupRuntimeFromMainState<TStartupState>(
|
||||
input: MainStartupRuntimeFromMainStateInput<TStartupState>,
|
||||
): MainStartupRuntimeBootstrap<TStartupState> {
|
||||
return createMainStartupRuntimeBootstrap<TStartupState>({
|
||||
appState: input.appState,
|
||||
appLifecycle: input.appLifecycle,
|
||||
config: input.config,
|
||||
logging: input.logging,
|
||||
shell: input.shell,
|
||||
runtime: {
|
||||
subtitle: input.runtime.subtitle,
|
||||
getOverlayUi: () => input.runtime.getOverlayUi(),
|
||||
overlayManager: input.runtime.overlayManager,
|
||||
firstRun: {
|
||||
ensureSetupStateInitialized: () => input.runtime.firstRun.ensureSetupStateInitialized(),
|
||||
openFirstRunSetupWindow: () => input.runtime.firstRun.openFirstRunSetupWindow(),
|
||||
},
|
||||
anilist: {
|
||||
refreshAnilistClientSecretStateIfEnabled: (options) =>
|
||||
input.runtime.anilist.refreshAnilistClientSecretStateIfEnabled(options),
|
||||
openAnilistSetupWindow: () => input.runtime.anilist.openAnilistSetupWindow(),
|
||||
getStatusSnapshot: () => input.runtime.anilist.getStatusSnapshot(),
|
||||
clearTokenState: () => input.runtime.anilist.clearTokenState(),
|
||||
getQueueStatusSnapshot: () => input.runtime.anilist.getQueueStatusSnapshot(),
|
||||
processNextAnilistRetryUpdate: () => input.runtime.anilist.processNextAnilistRetryUpdate(),
|
||||
},
|
||||
jellyfin: input.runtime.jellyfin,
|
||||
stats: input.runtime.stats,
|
||||
mining: input.runtime.mining,
|
||||
texthookerService: input.runtime.texthookerService,
|
||||
getYomitanRuntime: () => input.runtime.yomitan,
|
||||
getCharacterDictionaryDisabledReason: () =>
|
||||
input.runtime.getCharacterDictionaryDisabledReason(),
|
||||
subsyncRuntime: input.runtime.subsyncRuntime,
|
||||
dictionarySupport: input.runtime.dictionarySupport,
|
||||
subtitleWsService: input.runtime.subtitleWsService,
|
||||
annotationSubtitleWsService: input.runtime.annotationSubtitleWsService,
|
||||
immersion: input.runtime.immersion,
|
||||
},
|
||||
commands: {
|
||||
createMpvClientRuntimeService: () =>
|
||||
input.commands.mpvRuntime.createMpvClientRuntimeService(),
|
||||
createMecabTokenizerAndCheck: () => input.commands.mpvRuntime.createMecabTokenizerAndCheck(),
|
||||
prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(),
|
||||
startBackgroundWarmupsIfAllowed: () => input.commands.mpvRuntime.startBackgroundWarmups(),
|
||||
startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(),
|
||||
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
|
||||
startPendingMultiCopy: (timeoutMs) =>
|
||||
input.commands.shortcuts.startPendingMultiCopy(timeoutMs),
|
||||
startPendingMineSentenceMultiple: (timeoutMs) =>
|
||||
input.commands.shortcuts.startPendingMineSentenceMultiple(timeoutMs),
|
||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
||||
refreshOverlayShortcuts: () => input.commands.shortcuts.refreshOverlayShortcuts(),
|
||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
||||
showMpvOsd: (text) => input.commands.showMpvOsd(text),
|
||||
shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args),
|
||||
getConfiguredShortcuts: () => input.commands.shortcuts.getConfiguredShortcuts(),
|
||||
runYoutubePlaybackFlow: (request) => input.commands.youtube.runYoutubePlaybackFlow(request),
|
||||
shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) =>
|
||||
input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs),
|
||||
isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
|
||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
||||
input.commands.commandNeedsOverlayStartupPrereqs(args),
|
||||
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
|
||||
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
||||
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
||||
shouldStartApp: (args) => input.commands.shouldStartApp(args),
|
||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
||||
printHelp: input.commands.printHelp,
|
||||
onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivateHandler: () =>
|
||||
input.commands.shouldRestoreWindowsOnActivateHandler(),
|
||||
restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(),
|
||||
forceX11Backend: (args) => input.commands.forceX11Backend(args),
|
||||
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
|
||||
getDefaultSocketPathHandler: () => input.commands.getDefaultSocketPath(),
|
||||
generateDefaultConfigFile: input.commands.generateDefaultConfigFile,
|
||||
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
|
||||
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
|
||||
getStartupModeFlags: input.commands.getStartupModeFlags,
|
||||
requestAppQuit: input.commands.requestAppQuit,
|
||||
},
|
||||
constants: input.constants,
|
||||
});
|
||||
}
|
||||
370
src/main/main-startup-runtime-coordinator.ts
Normal file
370
src/main/main-startup-runtime-coordinator.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import type { AnilistRuntime } from './anilist-runtime';
|
||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
||||
import type { FirstRunRuntime } from './first-run-runtime';
|
||||
import type { JellyfinRuntime } from './jellyfin-runtime';
|
||||
import {
|
||||
createMainStartupRuntimeFromMainState,
|
||||
type MainStartupRuntimeBootstrap,
|
||||
type MainStartupRuntimeFromMainStateInput,
|
||||
} from './main-startup-runtime-bootstrap';
|
||||
import type { MiningRuntime } from './mining-runtime';
|
||||
import type { MpvRuntime } from './mpv-runtime';
|
||||
import type { ShortcutsRuntime } from './shortcuts-runtime';
|
||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
||||
import type { YoutubeRuntime } from './youtube-runtime';
|
||||
|
||||
export interface MainStartupRuntimeCoordinatorInput<TStartupState> {
|
||||
appState: MainStartupRuntimeFromMainStateInput<TStartupState>['appState'];
|
||||
appLifecycle: MainStartupRuntimeFromMainStateInput<TStartupState>['appLifecycle'];
|
||||
config: MainStartupRuntimeFromMainStateInput<TStartupState>['config'];
|
||||
logging: MainStartupRuntimeFromMainStateInput<TStartupState>['logging'];
|
||||
shell: MainStartupRuntimeFromMainStateInput<TStartupState>['shell'];
|
||||
runtime: {
|
||||
subtitle: SubtitleRuntime;
|
||||
getOverlayUi: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['getOverlayUi'];
|
||||
overlayManager: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['overlayManager'];
|
||||
firstRun: Pick<FirstRunRuntime, 'ensureSetupStateInitialized' | 'openFirstRunSetupWindow'>;
|
||||
anilist: AnilistRuntime;
|
||||
jellyfin: JellyfinRuntime;
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['stats']['ensureImmersionTrackerStarted'];
|
||||
runStatsCliCommand: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['stats']['runStatsCliCommand'];
|
||||
immersion: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['immersion'];
|
||||
};
|
||||
mining: {
|
||||
copyCurrentSubtitle: Pick<MiningRuntime, 'copyCurrentSubtitle'>['copyCurrentSubtitle'];
|
||||
markLastCardAsAudioCard: Pick<
|
||||
MiningRuntime,
|
||||
'markLastCardAsAudioCard'
|
||||
>['markLastCardAsAudioCard'];
|
||||
mineSentenceCard: Pick<MiningRuntime, 'mineSentenceCard'>['mineSentenceCard'];
|
||||
refreshKnownWordCache: Pick<MiningRuntime, 'refreshKnownWordCache'>['refreshKnownWordCache'];
|
||||
triggerFieldGrouping: Pick<MiningRuntime, 'triggerFieldGrouping'>['triggerFieldGrouping'];
|
||||
updateLastCardFromClipboard: Pick<
|
||||
MiningRuntime,
|
||||
'updateLastCardFromClipboard'
|
||||
>['updateLastCardFromClipboard'];
|
||||
};
|
||||
texthookerService: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['texthookerService'];
|
||||
yomitan: {
|
||||
loadYomitanExtension: () => Promise<unknown>;
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
openYomitanSettings: () => boolean;
|
||||
getCharacterDictionaryDisabledReason: () => string | null;
|
||||
};
|
||||
subsyncRuntime: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['subsyncRuntime'];
|
||||
dictionarySupport: DictionarySupportRuntime;
|
||||
subtitleWsService: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['subtitleWsService'];
|
||||
annotationSubtitleWsService: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['annotationSubtitleWsService'];
|
||||
};
|
||||
commands: {
|
||||
mpvRuntime: {
|
||||
createMpvClientRuntimeService: Pick<
|
||||
MpvRuntime,
|
||||
'createMpvClientRuntimeService'
|
||||
>['createMpvClientRuntimeService'];
|
||||
createMecabTokenizerAndCheck: Pick<
|
||||
MpvRuntime,
|
||||
'createMecabTokenizerAndCheck'
|
||||
>['createMecabTokenizerAndCheck'];
|
||||
prewarmSubtitleDictionaries: Pick<
|
||||
MpvRuntime,
|
||||
'prewarmSubtitleDictionaries'
|
||||
>['prewarmSubtitleDictionaries'];
|
||||
startBackgroundWarmups: Pick<MpvRuntime, 'startBackgroundWarmups'>['startBackgroundWarmups'];
|
||||
};
|
||||
runHeadlessInitialCommand: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['runHeadlessInitialCommand'];
|
||||
shortcuts: Pick<
|
||||
ShortcutsRuntime,
|
||||
| 'getConfiguredShortcuts'
|
||||
| 'refreshOverlayShortcuts'
|
||||
| 'startPendingMineSentenceMultiple'
|
||||
| 'startPendingMultiCopy'
|
||||
>;
|
||||
cycleSecondarySubMode: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['cycleSecondarySubMode'];
|
||||
hasMpvWebsocketPlugin: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['hasMpvWebsocketPlugin'];
|
||||
showMpvOsd: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['showMpvOsd'];
|
||||
shouldAutoOpenFirstRunSetup: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldAutoOpenFirstRunSetup'];
|
||||
youtube: Pick<YoutubeRuntime, 'runYoutubePlaybackFlow'>;
|
||||
shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldEnsureTrayOnStartupForInitialArgs'];
|
||||
isHeadlessInitialCommand: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['isHeadlessInitialCommand'];
|
||||
commandNeedsOverlayStartupPrereqs: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['commandNeedsOverlayStartupPrereqs'];
|
||||
commandNeedsOverlayRuntime: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['commandNeedsOverlayRuntime'];
|
||||
handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['handleCliCommandRuntimeServiceWithContext'];
|
||||
shouldStartApp: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldStartApp'];
|
||||
parseArgs: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['parseArgs'];
|
||||
printHelp: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['printHelp'];
|
||||
onWillQuitCleanupHandler: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['onWillQuitCleanupHandler'];
|
||||
shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldRestoreWindowsOnActivateHandler'];
|
||||
restoreWindowsOnActivateHandler: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['restoreWindowsOnActivateHandler'];
|
||||
forceX11Backend: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['forceX11Backend'];
|
||||
enforceUnsupportedWaylandMode: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['enforceUnsupportedWaylandMode'];
|
||||
getDefaultSocketPath: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['getDefaultSocketPath'];
|
||||
generateDefaultConfigFile: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['generateDefaultConfigFile'];
|
||||
runStartupBootstrapRuntime: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['runStartupBootstrapRuntime'];
|
||||
applyStartupState: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['applyStartupState'];
|
||||
getStartupModeFlags: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['getStartupModeFlags'];
|
||||
requestAppQuit: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['requestAppQuit'];
|
||||
};
|
||||
constants: MainStartupRuntimeFromMainStateInput<TStartupState>['constants'];
|
||||
}
|
||||
|
||||
export interface MainStartupRuntimeFromProcessStateInput<TStartupState> {
|
||||
appState: MainStartupRuntimeCoordinatorInput<TStartupState>['appState'];
|
||||
appLifecycle: MainStartupRuntimeCoordinatorInput<TStartupState>['appLifecycle'];
|
||||
config: MainStartupRuntimeCoordinatorInput<TStartupState>['config'];
|
||||
logging: MainStartupRuntimeCoordinatorInput<TStartupState>['logging'];
|
||||
shell: MainStartupRuntimeCoordinatorInput<TStartupState>['shell'];
|
||||
runtime: {
|
||||
subtitle: SubtitleRuntime;
|
||||
startupOverlayUiAdapter: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['getOverlayUi'] extends () => infer T
|
||||
? T
|
||||
: never;
|
||||
overlayManager: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['overlayManager'];
|
||||
firstRun: Pick<FirstRunRuntime, 'ensureSetupStateInitialized' | 'openFirstRunSetupWindow'>;
|
||||
anilist: AnilistRuntime;
|
||||
jellyfin: JellyfinRuntime;
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['stats']['ensureImmersionTrackerStarted'];
|
||||
runStatsCliCommand: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['stats']['runStatsCliCommand'];
|
||||
immersion: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['stats']['immersion'];
|
||||
};
|
||||
mining: MiningRuntime;
|
||||
texthookerService: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['texthookerService'];
|
||||
yomitan: {
|
||||
loadYomitanExtension: () => Promise<unknown>;
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
openYomitanSettings: () => boolean;
|
||||
getCharacterDictionaryDisabledReason: () => string | null;
|
||||
};
|
||||
subsyncRuntime: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['subsyncRuntime'];
|
||||
dictionarySupport: DictionarySupportRuntime;
|
||||
subtitleWsService: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['subtitleWsService'];
|
||||
annotationSubtitleWsService: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['annotationSubtitleWsService'];
|
||||
};
|
||||
commands: {
|
||||
mpvRuntime: MpvRuntime;
|
||||
runHeadlessInitialCommand: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['runHeadlessInitialCommand'];
|
||||
shortcuts: Pick<
|
||||
ShortcutsRuntime,
|
||||
| 'getConfiguredShortcuts'
|
||||
| 'refreshOverlayShortcuts'
|
||||
| 'startPendingMineSentenceMultiple'
|
||||
| 'startPendingMultiCopy'
|
||||
>;
|
||||
cycleSecondarySubMode: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['cycleSecondarySubMode'];
|
||||
hasMpvWebsocketPlugin: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['hasMpvWebsocketPlugin'];
|
||||
showMpvOsd: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['showMpvOsd'];
|
||||
shouldAutoOpenFirstRunSetup: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldAutoOpenFirstRunSetup'];
|
||||
youtube: YoutubeRuntime;
|
||||
shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldEnsureTrayOnStartupForInitialArgs'];
|
||||
isHeadlessInitialCommand: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['isHeadlessInitialCommand'];
|
||||
commandNeedsOverlayStartupPrereqs: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['commandNeedsOverlayStartupPrereqs'];
|
||||
commandNeedsOverlayRuntime: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['commandNeedsOverlayRuntime'];
|
||||
handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['handleCliCommandRuntimeServiceWithContext'];
|
||||
shouldStartApp: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldStartApp'];
|
||||
parseArgs: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['parseArgs'];
|
||||
printHelp: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['printHelp'];
|
||||
onWillQuitCleanupHandler: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['onWillQuitCleanupHandler'];
|
||||
shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldRestoreWindowsOnActivateHandler'];
|
||||
restoreWindowsOnActivateHandler: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['restoreWindowsOnActivateHandler'];
|
||||
forceX11Backend: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['forceX11Backend'];
|
||||
enforceUnsupportedWaylandMode: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['enforceUnsupportedWaylandMode'];
|
||||
getDefaultSocketPath: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['getDefaultSocketPath'];
|
||||
generateDefaultConfigFile: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['generateDefaultConfigFile'];
|
||||
runStartupBootstrapRuntime: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['runStartupBootstrapRuntime'];
|
||||
applyStartupState: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['applyStartupState'];
|
||||
getStartupModeFlags: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['getStartupModeFlags'];
|
||||
requestAppQuit: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['requestAppQuit'];
|
||||
};
|
||||
constants: MainStartupRuntimeCoordinatorInput<TStartupState>['constants'];
|
||||
}
|
||||
|
||||
export function createMainStartupRuntimeCoordinator<TStartupState>(
|
||||
input: MainStartupRuntimeCoordinatorInput<TStartupState>,
|
||||
): MainStartupRuntimeBootstrap<TStartupState> {
|
||||
return createMainStartupRuntimeFromMainState<TStartupState>({
|
||||
appState: input.appState,
|
||||
appLifecycle: input.appLifecycle,
|
||||
config: input.config,
|
||||
logging: input.logging,
|
||||
shell: input.shell,
|
||||
runtime: {
|
||||
subtitle: {
|
||||
loadSubtitlePosition: () => input.runtime.subtitle.loadSubtitlePosition(),
|
||||
invalidateTokenizationCache: () => {
|
||||
input.runtime.subtitle.invalidateTokenizationCache();
|
||||
},
|
||||
refreshSubtitlePrefetchFromActiveTrack: () =>
|
||||
input.runtime.subtitle.refreshSubtitlePrefetchFromActiveTrack(),
|
||||
},
|
||||
getOverlayUi: () => input.runtime.getOverlayUi(),
|
||||
overlayManager: input.runtime.overlayManager,
|
||||
firstRun: input.runtime.firstRun,
|
||||
anilist: input.runtime.anilist,
|
||||
jellyfin: {
|
||||
startJellyfinRemoteSession: () => input.runtime.jellyfin.startJellyfinRemoteSession(),
|
||||
openJellyfinSetupWindow: () => input.runtime.jellyfin.openJellyfinSetupWindow(),
|
||||
runJellyfinCommand: (argsFromCommand) =>
|
||||
input.runtime.jellyfin.runJellyfinCommand(argsFromCommand),
|
||||
},
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => input.runtime.stats.ensureImmersionTrackerStarted(),
|
||||
runStatsCliCommand: (argsFromCommand, source) =>
|
||||
input.runtime.stats.runStatsCliCommand(argsFromCommand, source),
|
||||
},
|
||||
mining: {
|
||||
copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(),
|
||||
mineSentenceCard: () => input.runtime.mining.mineSentenceCard(),
|
||||
updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(),
|
||||
refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(),
|
||||
triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(),
|
||||
markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(),
|
||||
},
|
||||
texthookerService: input.runtime.texthookerService,
|
||||
yomitan: {
|
||||
loadYomitanExtension: () => input.runtime.yomitan.loadYomitanExtension(),
|
||||
ensureYomitanExtensionLoaded: () => input.runtime.yomitan.ensureYomitanExtensionLoaded(),
|
||||
openYomitanSettings: () => input.runtime.yomitan.openYomitanSettings(),
|
||||
},
|
||||
getCharacterDictionaryDisabledReason: () =>
|
||||
input.runtime.yomitan.getCharacterDictionaryDisabledReason(),
|
||||
subsyncRuntime: input.runtime.subsyncRuntime,
|
||||
dictionarySupport: input.runtime.dictionarySupport,
|
||||
subtitleWsService: input.runtime.subtitleWsService,
|
||||
annotationSubtitleWsService: input.runtime.annotationSubtitleWsService,
|
||||
immersion: input.runtime.stats.immersion,
|
||||
},
|
||||
commands: {
|
||||
mpvRuntime: {
|
||||
createMpvClientRuntimeService: () =>
|
||||
input.commands.mpvRuntime.createMpvClientRuntimeService(),
|
||||
createMecabTokenizerAndCheck: () =>
|
||||
input.commands.mpvRuntime.createMecabTokenizerAndCheck(),
|
||||
prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(),
|
||||
startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(),
|
||||
},
|
||||
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
|
||||
shortcuts: {
|
||||
startPendingMultiCopy: (timeoutMs) =>
|
||||
input.commands.shortcuts.startPendingMultiCopy(timeoutMs),
|
||||
startPendingMineSentenceMultiple: (timeoutMs) =>
|
||||
input.commands.shortcuts.startPendingMineSentenceMultiple(timeoutMs),
|
||||
refreshOverlayShortcuts: () => input.commands.shortcuts.refreshOverlayShortcuts(),
|
||||
getConfiguredShortcuts: () => input.commands.shortcuts.getConfiguredShortcuts(),
|
||||
},
|
||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
||||
showMpvOsd: (text) => input.commands.showMpvOsd(text),
|
||||
shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args),
|
||||
youtube: {
|
||||
runYoutubePlaybackFlow: (request) => input.commands.youtube.runYoutubePlaybackFlow(request),
|
||||
},
|
||||
shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) =>
|
||||
input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs ?? null),
|
||||
isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
|
||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
||||
input.commands.commandNeedsOverlayStartupPrereqs(args),
|
||||
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
|
||||
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
||||
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
||||
shouldStartApp: (args) => input.commands.shouldStartApp(args),
|
||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
||||
printHelp: input.commands.printHelp,
|
||||
onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivateHandler: () =>
|
||||
input.commands.shouldRestoreWindowsOnActivateHandler(),
|
||||
restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(),
|
||||
forceX11Backend: (args) => input.commands.forceX11Backend(args),
|
||||
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
|
||||
getDefaultSocketPath: () => input.commands.getDefaultSocketPath(),
|
||||
generateDefaultConfigFile: input.commands.generateDefaultConfigFile,
|
||||
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
|
||||
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
|
||||
getStartupModeFlags: input.commands.getStartupModeFlags,
|
||||
requestAppQuit: input.commands.requestAppQuit,
|
||||
},
|
||||
constants: input.constants,
|
||||
});
|
||||
}
|
||||
|
||||
export function createMainStartupRuntimeFromProcessState<TStartupState>(
|
||||
input: MainStartupRuntimeFromProcessStateInput<TStartupState>,
|
||||
) {
|
||||
return createMainStartupRuntimeCoordinator<TStartupState>({
|
||||
appState: input.appState,
|
||||
appLifecycle: input.appLifecycle,
|
||||
config: input.config,
|
||||
logging: input.logging,
|
||||
shell: input.shell,
|
||||
runtime: {
|
||||
subtitle: input.runtime.subtitle,
|
||||
getOverlayUi: () => input.runtime.startupOverlayUiAdapter,
|
||||
overlayManager: input.runtime.overlayManager,
|
||||
firstRun: input.runtime.firstRun,
|
||||
anilist: input.runtime.anilist,
|
||||
jellyfin: input.runtime.jellyfin,
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => input.runtime.stats.ensureImmersionTrackerStarted(),
|
||||
runStatsCliCommand: (argsFromCommand, source) =>
|
||||
input.runtime.stats.runStatsCliCommand(argsFromCommand, source),
|
||||
immersion: input.runtime.stats.immersion,
|
||||
},
|
||||
mining: {
|
||||
copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(),
|
||||
markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(),
|
||||
mineSentenceCard: () => input.runtime.mining.mineSentenceCard(),
|
||||
refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(),
|
||||
triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(),
|
||||
updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(),
|
||||
},
|
||||
texthookerService: input.runtime.texthookerService,
|
||||
yomitan: input.runtime.yomitan,
|
||||
subsyncRuntime: input.runtime.subsyncRuntime,
|
||||
dictionarySupport: input.runtime.dictionarySupport,
|
||||
subtitleWsService: input.runtime.subtitleWsService,
|
||||
annotationSubtitleWsService: input.runtime.annotationSubtitleWsService,
|
||||
},
|
||||
commands: {
|
||||
mpvRuntime: {
|
||||
createMpvClientRuntimeService: () =>
|
||||
input.commands.mpvRuntime.createMpvClientRuntimeService(),
|
||||
createMecabTokenizerAndCheck: () =>
|
||||
input.commands.mpvRuntime.createMecabTokenizerAndCheck(),
|
||||
prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(),
|
||||
startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(),
|
||||
},
|
||||
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
|
||||
shortcuts: input.commands.shortcuts,
|
||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
||||
showMpvOsd: (text) => input.commands.showMpvOsd(text),
|
||||
shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args),
|
||||
youtube: input.commands.youtube,
|
||||
shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) =>
|
||||
input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs ?? null),
|
||||
isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
|
||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
||||
input.commands.commandNeedsOverlayStartupPrereqs(args),
|
||||
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
|
||||
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
||||
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
||||
shouldStartApp: (args) => input.commands.shouldStartApp(args),
|
||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
||||
printHelp: input.commands.printHelp,
|
||||
onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivateHandler: () =>
|
||||
input.commands.shouldRestoreWindowsOnActivateHandler(),
|
||||
restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(),
|
||||
forceX11Backend: (args) => input.commands.forceX11Backend(args),
|
||||
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
|
||||
getDefaultSocketPath: () => input.commands.getDefaultSocketPath(),
|
||||
generateDefaultConfigFile: input.commands.generateDefaultConfigFile,
|
||||
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
|
||||
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
|
||||
getStartupModeFlags: input.commands.getStartupModeFlags,
|
||||
requestAppQuit: input.commands.requestAppQuit,
|
||||
},
|
||||
constants: input.constants,
|
||||
}).startupRuntime;
|
||||
}
|
||||
260
src/main/main-startup-runtime.test.ts
Normal file
260
src/main/main-startup-runtime.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createMainStartupRuntime } from './main-startup-runtime';
|
||||
|
||||
test('main startup runtime composes app-ready, cli, and headless runtimes', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createMainStartupRuntime<{ mode: string }>({
|
||||
appReady: {
|
||||
reload: {
|
||||
reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }),
|
||||
logInfo: () => {},
|
||||
logWarning: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
startConfigHotReload: () => {},
|
||||
refreshAnilistClientSecretState: async () => undefined,
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {},
|
||||
},
|
||||
},
|
||||
criticalConfig: {
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {},
|
||||
},
|
||||
},
|
||||
immersion: {
|
||||
getResolvedConfig: () => ({ immersionTracking: { enabled: false } }) as never,
|
||||
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
|
||||
createTrackerService: () => ({}) as never,
|
||||
setTracker: () => {},
|
||||
getMpvClient: () => null,
|
||||
seedTrackerFromCurrentMedia: () => {},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
runner: {
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
calls.push('ensureDefaultConfigBootstrap');
|
||||
},
|
||||
getSubtitlePosition: () => null,
|
||||
loadSubtitlePosition: () => {
|
||||
calls.push('loadSubtitlePosition');
|
||||
},
|
||||
getKeybindingsCount: () => 0,
|
||||
resolveKeybindings: () => {
|
||||
calls.push('resolveKeybindings');
|
||||
},
|
||||
hasMpvClient: () => false,
|
||||
createMpvClient: () => {
|
||||
calls.push('createMpvClient');
|
||||
},
|
||||
getRuntimeOptionsManager: () => null,
|
||||
initRuntimeOptionsManager: () => {
|
||||
calls.push('initRuntimeOptionsManager');
|
||||
},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
createSubtitleTimingTracker: () => {
|
||||
calls.push('createSubtitleTimingTracker');
|
||||
},
|
||||
getResolvedConfig: () => ({ ankiConnect: { enabled: false } }) as never,
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {},
|
||||
setSecondarySubMode: () => {},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 5174,
|
||||
defaultAnnotationWebsocketPort: 6678,
|
||||
defaultTexthookerPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
loadYomitanExtension: async () => {},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('ensureYomitanExtensionLoaded');
|
||||
},
|
||||
handleFirstRunSetup: async () => {},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startBackgroundWarmups');
|
||||
},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('initializeOverlayRuntime');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
},
|
||||
cli: {
|
||||
appState: {
|
||||
appState: {} as never,
|
||||
getInitialArgs: () => null,
|
||||
isBackgroundMode: () => false,
|
||||
isTexthookerOnlyMode: () => false,
|
||||
setTexthookerOnlyMode: () => {},
|
||||
hasImmersionTracker: () => false,
|
||||
getMpvClient: () => null,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
},
|
||||
config: {
|
||||
defaultConfig: { websocket: { port: 6677 }, annotationWebsocket: { port: 6678 } } as never,
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
setCliLogLevel: () => {},
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
},
|
||||
io: {
|
||||
texthookerService: {} as never,
|
||||
openExternal: async () => {},
|
||||
logBrowserOpenError: () => {},
|
||||
showMpvOsd: () => {},
|
||||
schedule: () => 0 as never,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
},
|
||||
commands: {
|
||||
initializeOverlayRuntime: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
openFirstRunSetupWindow: () => {},
|
||||
setVisibleOverlayVisible: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
mineSentenceCard: async () => {},
|
||||
startPendingMineSentenceMultiple: () => {},
|
||||
updateLastCardFromClipboard: async () => {},
|
||||
refreshKnownWordCache: async () => {},
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetupWindow: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
|
||||
generateCharacterDictionary: async () => ({
|
||||
zipPath: '/tmp/test.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 1,
|
||||
}),
|
||||
runJellyfinCommand: async () => {},
|
||||
runStatsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
stopApp: () => {
|
||||
calls.push('stopApp');
|
||||
},
|
||||
hasMainWindow: () => false,
|
||||
getMultiCopyTimeoutMs: () => 0,
|
||||
},
|
||||
startup: {
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {},
|
||||
commandNeedsOverlayStartupPrereqs: () => false,
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
calls.push('ensureOverlayStartupPrereqs');
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startupStartBackgroundWarmups');
|
||||
},
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (args) => {
|
||||
calls.push(`handle:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
},
|
||||
headless: {
|
||||
appLifecycleRuntimeRunnerMainDeps: {
|
||||
app: { on: () => {} } as never,
|
||||
platform: 'darwin',
|
||||
shouldStartApp: () => true,
|
||||
parseArgs: () => ({}) as never,
|
||||
handleCliCommand: () => {},
|
||||
printHelp: () => {},
|
||||
logNoRunningInstance: () => {},
|
||||
onReady: async () => {},
|
||||
onWillQuitCleanup: () => {},
|
||||
shouldRestoreWindowsOnActivate: () => false,
|
||||
restoreWindowsOnActivate: () => {},
|
||||
shouldQuitOnWindowAllClosed: () => false,
|
||||
},
|
||||
createAppLifecycleRuntimeRunner: () => (args) => {
|
||||
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
bootstrap: {
|
||||
argv: ['node', 'main.js'],
|
||||
parseArgs: () => ({ command: 'start' }) as never,
|
||||
setLogLevel: () => {},
|
||||
forceX11Backend: () => {},
|
||||
enforceUnsupportedWaylandMode: () => {},
|
||||
shouldStartApp: () => true,
|
||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
configDir: '/tmp/config',
|
||||
defaultConfig: {} as never,
|
||||
generateConfigTemplate: () => 'template',
|
||||
generateDefaultConfigFile: async () => 0,
|
||||
setExitCode: () => {},
|
||||
quitApp: () => {},
|
||||
logGenerateConfigError: () => {},
|
||||
startAppLifecycle: () => {},
|
||||
},
|
||||
runStartupBootstrapRuntime: (deps) => {
|
||||
deps.startAppLifecycle({ command: 'start' } as never);
|
||||
return { mode: 'started' };
|
||||
},
|
||||
applyStartupState: (state: { mode: string }) => {
|
||||
calls.push(`apply:${state.mode}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof runtime.appReady.runAppReady, 'function');
|
||||
assert.equal(typeof runtime.cliStartup.handleCliCommand, 'function');
|
||||
assert.equal(typeof runtime.headlessStartup.runAndApplyStartupState, 'function');
|
||||
assert.equal(runtime.handleCliCommand, runtime.cliStartup.handleCliCommand);
|
||||
assert.equal(runtime.handleInitialArgs, runtime.cliStartup.handleInitialArgs);
|
||||
assert.equal(
|
||||
runtime.appLifecycleRuntimeRunner,
|
||||
runtime.headlessStartup.appLifecycleRuntimeRunner,
|
||||
);
|
||||
assert.equal(runtime.runAndApplyStartupState, runtime.headlessStartup.runAndApplyStartupState);
|
||||
|
||||
runtime.appReady.ensureOverlayStartupPrereqs();
|
||||
runtime.handleCliCommand({ command: 'start' } as never);
|
||||
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'loadSubtitlePosition',
|
||||
'resolveKeybindings',
|
||||
'createMpvClient',
|
||||
'initRuntimeOptionsManager',
|
||||
'createSubtitleTimingTracker',
|
||||
'handle:start',
|
||||
'lifecycle:start',
|
||||
'apply:started',
|
||||
]);
|
||||
});
|
||||
47
src/main/main-startup-runtime.ts
Normal file
47
src/main/main-startup-runtime.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { AppReadyRuntimeInput, AppReadyRuntime } from './app-ready-runtime';
|
||||
import type { CliStartupRuntimeInput, CliStartupRuntime } from './cli-startup-runtime';
|
||||
import type {
|
||||
HeadlessStartupRuntimeInput,
|
||||
HeadlessStartupRuntime,
|
||||
} from './headless-startup-runtime';
|
||||
import { createAppReadyRuntime } from './app-ready-runtime';
|
||||
import { createCliStartupRuntime } from './cli-startup-runtime';
|
||||
import { createHeadlessStartupRuntime } from './headless-startup-runtime';
|
||||
|
||||
export interface MainStartupRuntimeInput<TStartupState> {
|
||||
appReady: AppReadyRuntimeInput;
|
||||
cli: CliStartupRuntimeInput;
|
||||
headless: HeadlessStartupRuntimeInput<TStartupState>;
|
||||
}
|
||||
|
||||
export interface MainStartupRuntime<TStartupState> {
|
||||
appReady: AppReadyRuntime;
|
||||
cliStartup: CliStartupRuntime;
|
||||
headlessStartup: HeadlessStartupRuntime<TStartupState>;
|
||||
handleCliCommand: CliStartupRuntime['handleCliCommand'];
|
||||
handleInitialArgs: CliStartupRuntime['handleInitialArgs'];
|
||||
appLifecycleRuntimeRunner: HeadlessStartupRuntime<TStartupState>['appLifecycleRuntimeRunner'];
|
||||
runAndApplyStartupState: HeadlessStartupRuntime<TStartupState>['runAndApplyStartupState'];
|
||||
}
|
||||
|
||||
export function createMainStartupRuntime<TStartupState>(
|
||||
input: MainStartupRuntimeInput<TStartupState>,
|
||||
): MainStartupRuntime<TStartupState> {
|
||||
const appReady = createAppReadyRuntime(input.appReady);
|
||||
const cliStartup = createCliStartupRuntime(input.cli);
|
||||
const headlessStartup =
|
||||
'createStartupBootstrapRuntimeDeps' in input.headless &&
|
||||
input.headless.createStartupBootstrapRuntimeDeps
|
||||
? createHeadlessStartupRuntime(input.headless)
|
||||
: createHeadlessStartupRuntime(input.headless);
|
||||
|
||||
return {
|
||||
appReady,
|
||||
cliStartup,
|
||||
headlessStartup,
|
||||
handleCliCommand: cliStartup.handleCliCommand,
|
||||
handleInitialArgs: cliStartup.handleInitialArgs,
|
||||
appLifecycleRuntimeRunner: headlessStartup.appLifecycleRuntimeRunner,
|
||||
runAndApplyStartupState: headlessStartup.runAndApplyStartupState,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { updateCurrentMediaPath } from '../core/services';
|
||||
import { updateCurrentMediaPath } from '../core/services/subtitle-position';
|
||||
|
||||
import type { SubtitlePosition } from '../types';
|
||||
|
||||
|
||||
163
src/main/mining-runtime.ts
Normal file
163
src/main/mining-runtime.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import { appendClipboardVideoToQueueRuntime } from './runtime/clipboard-queue';
|
||||
import {
|
||||
createUpdateLastCardFromClipboardHandler,
|
||||
createRefreshKnownWordCacheHandler,
|
||||
createTriggerFieldGroupingHandler,
|
||||
createMarkLastCardAsAudioCardHandler,
|
||||
createMineSentenceCardHandler,
|
||||
} from './runtime/anki-actions';
|
||||
import {
|
||||
createHandleMultiCopyDigitHandler,
|
||||
createCopyCurrentSubtitleHandler,
|
||||
createHandleMineSentenceDigitHandler,
|
||||
} from './runtime/mining-actions';
|
||||
|
||||
export interface MiningRuntimeInput<TAnkiIntegration = unknown, TMpvClient = unknown> {
|
||||
getSubtitleTimingTracker: () => SubtitleTimingTracker;
|
||||
getAnkiIntegration: () => TAnkiIntegration;
|
||||
getMpvClient: () => TMpvClient;
|
||||
readClipboardText: () => string;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
updateLastCardFromClipboardCore: (options: {
|
||||
ankiIntegration: TAnkiIntegration;
|
||||
readClipboardText: () => string;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
triggerFieldGroupingCore: (options: {
|
||||
ankiIntegration: TAnkiIntegration;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
markLastCardAsAudioCardCore: (options: {
|
||||
ankiIntegration: TAnkiIntegration;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
mineSentenceCardCore: (options: {
|
||||
ankiIntegration: TAnkiIntegration;
|
||||
mpvClient: TMpvClient;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
handleMultiCopyDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
subtitleTimingTracker: SubtitleTimingTracker;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
},
|
||||
) => void;
|
||||
copyCurrentSubtitleCore: (options: {
|
||||
subtitleTimingTracker: SubtitleTimingTracker;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => void;
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
subtitleTimingTracker: SubtitleTimingTracker;
|
||||
ankiIntegration: TAnkiIntegration;
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => void;
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}
|
||||
|
||||
export interface MiningRuntime {
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
handleMultiCopyDigit: (count: number) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
handleMineSentenceDigit: (count: number) => void;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
}
|
||||
|
||||
export function createMiningRuntime<TAnkiIntegration, TMpvClient>(
|
||||
input: MiningRuntimeInput<TAnkiIntegration, TMpvClient>,
|
||||
): MiningRuntime {
|
||||
const updateLastCardFromClipboard = createUpdateLastCardFromClipboardHandler({
|
||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
||||
readClipboardText: () => input.readClipboardText(),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
updateLastCardFromClipboardCore: (options) => input.updateLastCardFromClipboardCore(options),
|
||||
});
|
||||
|
||||
const refreshKnownWordCache = createRefreshKnownWordCacheHandler({
|
||||
getAnkiIntegration: () =>
|
||||
input.getAnkiIntegration() as { refreshKnownWordCache: () => Promise<void> } | null,
|
||||
missingIntegrationMessage: 'AnkiConnect integration not enabled',
|
||||
});
|
||||
|
||||
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
|
||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
triggerFieldGroupingCore: (options) => input.triggerFieldGroupingCore(options),
|
||||
});
|
||||
|
||||
const markLastCardAsAudioCard = createMarkLastCardAsAudioCardHandler({
|
||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
markLastCardAsAudioCardCore: (options) => input.markLastCardAsAudioCardCore(options),
|
||||
});
|
||||
|
||||
const mineSentenceCard = createMineSentenceCardHandler({
|
||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
||||
getMpvClient: () => input.getMpvClient(),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
mineSentenceCardCore: (options) => input.mineSentenceCardCore(options),
|
||||
recordCardsMined: (count, noteIds) => input.recordCardsMined(count, noteIds),
|
||||
});
|
||||
|
||||
const handleMultiCopyDigit = createHandleMultiCopyDigitHandler({
|
||||
getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(),
|
||||
writeClipboardText: (text) => input.writeClipboardText(text),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
handleMultiCopyDigitCore: (count, options) => input.handleMultiCopyDigitCore(count, options),
|
||||
});
|
||||
|
||||
const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({
|
||||
getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(),
|
||||
writeClipboardText: (text) => input.writeClipboardText(text),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
copyCurrentSubtitleCore: (options) => input.copyCurrentSubtitleCore(options),
|
||||
});
|
||||
|
||||
const handleMineSentenceDigit = createHandleMineSentenceDigitHandler({
|
||||
getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(),
|
||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
||||
getCurrentSecondarySubText: () => input.getCurrentSecondarySubText(),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
logError: (message, err) => input.logError(message, err),
|
||||
onCardsMined: (count) => input.recordCardsMined(count),
|
||||
handleMineSentenceDigitCore: (count, options) =>
|
||||
input.handleMineSentenceDigitCore(count, options),
|
||||
});
|
||||
|
||||
const appendClipboardVideoToQueue = (): { ok: boolean; message: string } =>
|
||||
appendClipboardVideoToQueueRuntime({
|
||||
getMpvClient: () => input.getMpvClient() as { connected: boolean } | null,
|
||||
readClipboardText: () => input.readClipboardText(),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
sendMpvCommand: (command) => input.sendMpvCommand(command),
|
||||
});
|
||||
|
||||
return {
|
||||
updateLastCardFromClipboard,
|
||||
refreshKnownWordCache,
|
||||
triggerFieldGrouping,
|
||||
markLastCardAsAudioCard,
|
||||
mineSentenceCard,
|
||||
handleMultiCopyDigit,
|
||||
copyCurrentSubtitle,
|
||||
handleMineSentenceDigit,
|
||||
appendClipboardVideoToQueue,
|
||||
};
|
||||
}
|
||||
277
src/main/mpv-runtime-bootstrap.ts
Normal file
277
src/main/mpv-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type { MpvRuntime, MpvRuntimeInput } from './mpv-runtime';
|
||||
import { createMpvRuntime } from './mpv-runtime';
|
||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
||||
import type { createCurrentMediaTokenizationGate } from './runtime/current-media-tokenization-gate';
|
||||
import type { createStartupOsdSequencer } from './runtime/startup-osd-sequencer';
|
||||
import type { AnilistRuntime } from './anilist-runtime';
|
||||
import type { JellyfinRuntime } from './jellyfin-runtime';
|
||||
import type { YoutubeRuntime } from './youtube-runtime';
|
||||
|
||||
export interface MpvRuntimeBootstrapInput {
|
||||
appState: MpvRuntimeInput['appState'];
|
||||
logPath: string;
|
||||
logger: MpvRuntimeInput['logger'];
|
||||
getResolvedConfig: MpvRuntimeInput['getResolvedConfig'];
|
||||
getRuntimeBooleanOption: MpvRuntimeInput['getRuntimeBooleanOption'];
|
||||
subtitle: MpvRuntimeInput['subtitle'];
|
||||
ensureYomitanExtensionLoaded: MpvRuntimeInput['ensureYomitanExtensionLoaded'];
|
||||
currentMediaTokenizationGate: MpvRuntimeInput['currentMediaTokenizationGate'];
|
||||
startupOsdSequencer: MpvRuntimeInput['startupOsdSequencer'];
|
||||
dictionarySupport: {
|
||||
ensureJlptDictionaryLookup: () => Promise<void>;
|
||||
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
||||
syncImmersionMediaState: () => void;
|
||||
updateCurrentMediaPath: (mediaPath: unknown) => void;
|
||||
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
|
||||
scheduleCharacterDictionarySync: () => void;
|
||||
};
|
||||
overlay: {
|
||||
overlayManager: {
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
};
|
||||
getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined;
|
||||
};
|
||||
lifecycle: {
|
||||
requestAppQuit: () => void;
|
||||
setQuitCheckTimer: (callback: () => void, timeoutMs: number) => void;
|
||||
restoreOverlayMpvSubtitles: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
publishDiscordPresence: () => void;
|
||||
};
|
||||
stats: MpvRuntimeInput['stats'];
|
||||
anilist: MpvRuntimeInput['anilist'];
|
||||
jellyfin: MpvRuntimeInput['jellyfin'];
|
||||
youtube: MpvRuntimeInput['youtube'];
|
||||
isCharacterDictionaryEnabled: MpvRuntimeInput['isCharacterDictionaryEnabled'];
|
||||
}
|
||||
|
||||
export interface MpvRuntimeBootstrap {
|
||||
mpvRuntime: MpvRuntime;
|
||||
}
|
||||
|
||||
export interface MpvRuntimeFromMainStateInput {
|
||||
appState: MpvRuntimeInput['appState'];
|
||||
logPath: string;
|
||||
logger: MpvRuntimeInput['logger'];
|
||||
getResolvedConfig: MpvRuntimeInput['getResolvedConfig'];
|
||||
getRuntimeBooleanOption: MpvRuntimeInput['getRuntimeBooleanOption'];
|
||||
subtitle: SubtitleRuntime;
|
||||
yomitan: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<void>;
|
||||
};
|
||||
currentMediaTokenizationGate: ReturnType<typeof createCurrentMediaTokenizationGate>;
|
||||
startupOsdSequencer: ReturnType<typeof createStartupOsdSequencer>;
|
||||
dictionarySupport: DictionarySupportRuntime;
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined;
|
||||
};
|
||||
lifecycle: {
|
||||
requestAppQuit: () => void;
|
||||
setQuitCheckTimer: (callback: () => void, timeoutMs: number) => void;
|
||||
restoreOverlayMpvSubtitles: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
publishDiscordPresence: () => void;
|
||||
};
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
};
|
||||
anilist: AnilistRuntime;
|
||||
jellyfin: JellyfinRuntime;
|
||||
youtube: YoutubeRuntime;
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
}
|
||||
|
||||
export function createMpvRuntimeBootstrap(input: MpvRuntimeBootstrapInput): MpvRuntimeBootstrap {
|
||||
const mpvRuntime = createMpvRuntime({
|
||||
appState: input.appState,
|
||||
logPath: input.logPath,
|
||||
logger: input.logger,
|
||||
getResolvedConfig: input.getResolvedConfig,
|
||||
getRuntimeBooleanOption: input.getRuntimeBooleanOption,
|
||||
subtitle: input.subtitle,
|
||||
ensureYomitanExtensionLoaded: input.ensureYomitanExtensionLoaded,
|
||||
currentMediaTokenizationGate: input.currentMediaTokenizationGate,
|
||||
startupOsdSequencer: input.startupOsdSequencer,
|
||||
dictionaries: {
|
||||
ensureJlptDictionaryLookup: () => input.dictionarySupport.ensureJlptDictionaryLookup(),
|
||||
ensureFrequencyDictionaryLookup: () =>
|
||||
input.dictionarySupport.ensureFrequencyDictionaryLookup(),
|
||||
},
|
||||
mediaRuntime: {
|
||||
syncImmersionMediaState: () => input.dictionarySupport.syncImmersionMediaState(),
|
||||
updateCurrentMediaPath: (mediaPath) => {
|
||||
input.dictionarySupport.updateCurrentMediaPath(mediaPath);
|
||||
},
|
||||
updateCurrentMediaTitle: (mediaTitle) => {
|
||||
input.dictionarySupport.updateCurrentMediaTitle(mediaTitle);
|
||||
},
|
||||
},
|
||||
characterDictionaryAutoSyncRuntime: {
|
||||
scheduleSync: () => {
|
||||
input.dictionarySupport.scheduleCharacterDictionarySync();
|
||||
},
|
||||
},
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
getVisibleOverlayVisible: () => input.overlay.overlayManager.getVisibleOverlayVisible(),
|
||||
setOverlayVisible: (visible) => {
|
||||
input.overlay.getOverlayUi()?.setOverlayVisible(visible);
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
requestAppQuit: () => input.lifecycle.requestAppQuit(),
|
||||
scheduleQuitCheck: (callback) => {
|
||||
input.lifecycle.setQuitCheckTimer(callback, 500);
|
||||
},
|
||||
restoreOverlayMpvSubtitles: () => {
|
||||
input.lifecycle.restoreOverlayMpvSubtitles();
|
||||
},
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
input.lifecycle.syncOverlayMpvSubtitleSuppression();
|
||||
},
|
||||
refreshDiscordPresence: () => {
|
||||
input.lifecycle.publishDiscordPresence();
|
||||
},
|
||||
},
|
||||
stats: input.stats,
|
||||
anilist: input.anilist,
|
||||
jellyfin: input.jellyfin,
|
||||
youtube: input.youtube,
|
||||
isCharacterDictionaryEnabled: input.isCharacterDictionaryEnabled,
|
||||
});
|
||||
|
||||
return {
|
||||
mpvRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMpvRuntimeFromMainState(
|
||||
input: MpvRuntimeFromMainStateInput,
|
||||
): MpvRuntimeBootstrap {
|
||||
return createMpvRuntimeBootstrap({
|
||||
appState: input.appState,
|
||||
logPath: input.logPath,
|
||||
logger: input.logger,
|
||||
getResolvedConfig: input.getResolvedConfig,
|
||||
getRuntimeBooleanOption: input.getRuntimeBooleanOption,
|
||||
subtitle: {
|
||||
consumeCachedSubtitle: (text) => input.subtitle.consumeCachedSubtitle(text),
|
||||
emitSubtitlePayload: (payload) => input.subtitle.emitSubtitlePayload(payload),
|
||||
onSubtitleChange: (text) => {
|
||||
input.subtitle.onSubtitleChange(text);
|
||||
},
|
||||
onCurrentMediaPathChange: (path) => {
|
||||
input.subtitle.onCurrentMediaPathChange(path);
|
||||
},
|
||||
onTimePosUpdate: (time) => {
|
||||
input.subtitle.onTimePosUpdate(time);
|
||||
},
|
||||
scheduleSubtitlePrefetchRefresh: (delayMs) =>
|
||||
input.subtitle.scheduleSubtitlePrefetchRefresh(delayMs),
|
||||
loadSubtitleSourceText: (source) => input.subtitle.loadSubtitleSourceText(source),
|
||||
setTokenizeSubtitleDeferred: (tokenize) => {
|
||||
input.subtitle.setTokenizeSubtitleDeferred(tokenize);
|
||||
},
|
||||
},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
||||
},
|
||||
currentMediaTokenizationGate: input.currentMediaTokenizationGate,
|
||||
startupOsdSequencer: input.startupOsdSequencer,
|
||||
dictionarySupport: {
|
||||
ensureJlptDictionaryLookup: () => input.dictionarySupport.ensureJlptDictionaryLookup(),
|
||||
ensureFrequencyDictionaryLookup: () =>
|
||||
input.dictionarySupport.ensureFrequencyDictionaryLookup(),
|
||||
syncImmersionMediaState: () => {
|
||||
input.dictionarySupport.syncImmersionMediaState();
|
||||
},
|
||||
updateCurrentMediaPath: (mediaPath) => {
|
||||
input.dictionarySupport.updateCurrentMediaPath(mediaPath);
|
||||
},
|
||||
updateCurrentMediaTitle: (mediaTitle) => {
|
||||
input.dictionarySupport.updateCurrentMediaTitle(mediaTitle);
|
||||
},
|
||||
scheduleCharacterDictionarySync: () => {
|
||||
input.dictionarySupport.scheduleCharacterDictionarySync();
|
||||
},
|
||||
},
|
||||
overlay: {
|
||||
overlayManager: {
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
input.overlay.broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
|
||||
},
|
||||
getOverlayUi: () => input.overlay.getOverlayUi(),
|
||||
},
|
||||
lifecycle: {
|
||||
requestAppQuit: () => input.lifecycle.requestAppQuit(),
|
||||
setQuitCheckTimer: (callback, timeoutMs) => {
|
||||
input.lifecycle.setQuitCheckTimer(callback, timeoutMs);
|
||||
},
|
||||
restoreOverlayMpvSubtitles: () => {
|
||||
input.lifecycle.restoreOverlayMpvSubtitles();
|
||||
},
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
input.lifecycle.syncOverlayMpvSubtitleSuppression();
|
||||
},
|
||||
publishDiscordPresence: () => {
|
||||
input.lifecycle.publishDiscordPresence();
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => input.stats.ensureImmersionTrackerStarted(),
|
||||
},
|
||||
anilist: {
|
||||
getCurrentAnilistMediaKey: () => input.anilist.getCurrentAnilistMediaKey(),
|
||||
resetAnilistMediaTracking: (mediaKey) => {
|
||||
input.anilist.resetAnilistMediaTracking(mediaKey);
|
||||
},
|
||||
maybeProbeAnilistDuration: (mediaKey) => {
|
||||
if (mediaKey) {
|
||||
void input.anilist.maybeProbeAnilistDuration(mediaKey);
|
||||
}
|
||||
},
|
||||
ensureAnilistMediaGuess: (mediaKey) => {
|
||||
if (mediaKey) {
|
||||
void input.anilist.ensureAnilistMediaGuess(mediaKey);
|
||||
}
|
||||
},
|
||||
maybeRunAnilistPostWatchUpdate: () => input.anilist.maybeRunAnilistPostWatchUpdate(),
|
||||
resetAnilistMediaGuessState: () => {
|
||||
input.anilist.resetAnilistMediaGuessState();
|
||||
},
|
||||
},
|
||||
jellyfin: {
|
||||
getQuitOnDisconnectArmed: () => input.jellyfin.getQuitOnDisconnectArmed(),
|
||||
reportJellyfinRemoteStopped: () => input.jellyfin.reportJellyfinRemoteStopped(),
|
||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||
input.jellyfin.reportJellyfinRemoteProgress(forceImmediate),
|
||||
startJellyfinRemoteSession: () => input.jellyfin.startJellyfinRemoteSession(),
|
||||
},
|
||||
youtube: {
|
||||
getQuitOnDisconnectArmed: () => input.youtube.getQuitOnDisconnectArmed(),
|
||||
handleMpvConnectionChange: (connected) => {
|
||||
input.youtube.handleMpvConnectionChange(connected);
|
||||
},
|
||||
handleMediaPathChange: (path) => input.youtube.handleMediaPathChange(path),
|
||||
handleSubtitleTrackChange: (sid) => {
|
||||
input.youtube.handleSubtitleTrackChange(sid);
|
||||
},
|
||||
handleSubtitleTrackListChange: (trackList) => {
|
||||
input.youtube.handleSubtitleTrackListChange(trackList);
|
||||
},
|
||||
invalidatePendingAutoplayReadyFallbacks: () =>
|
||||
input.youtube.invalidatePendingAutoplayReadyFallbacks(),
|
||||
maybeSignalPluginAutoplayReady: (subtitle, options) =>
|
||||
input.youtube.maybeSignalPluginAutoplayReady(subtitle, options),
|
||||
},
|
||||
isCharacterDictionaryEnabled: input.isCharacterDictionaryEnabled,
|
||||
});
|
||||
}
|
||||
504
src/main/mpv-runtime.ts
Normal file
504
src/main/mpv-runtime.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { MecabTokenizer } from '../mecab-tokenizer';
|
||||
import {
|
||||
MpvIpcClient,
|
||||
applyMpvSubtitleRenderMetricsPatch,
|
||||
createShiftSubtitleDelayToAdjacentCueHandler,
|
||||
createTokenizerDepsRuntime,
|
||||
cycleSecondarySubMode as cycleSecondarySubModeCore,
|
||||
sendMpvCommandRuntime,
|
||||
showMpvOsdRuntime,
|
||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||
} from '../core/services';
|
||||
import type {
|
||||
MpvSubtitleRenderMetrics,
|
||||
ResolvedConfig,
|
||||
SecondarySubMode,
|
||||
SubtitleData,
|
||||
} from '../types';
|
||||
import type { AppState } from './state';
|
||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
||||
import type { createCurrentMediaTokenizationGate } from './runtime/current-media-tokenization-gate';
|
||||
import type { createStartupOsdSequencer } from './runtime/startup-osd-sequencer';
|
||||
import { createMpvOsdRuntimeHandlers } from './runtime/mpv-osd-runtime-handlers';
|
||||
import { createCycleSecondarySubModeRuntimeHandler } from './runtime/secondary-sub-mode-runtime-handler';
|
||||
import { composeMpvRuntimeHandlers } from './runtime/composers';
|
||||
|
||||
type RuntimeOptionId =
|
||||
| 'subtitle.annotation.nPlusOne'
|
||||
| 'subtitle.annotation.jlpt'
|
||||
| 'subtitle.annotation.frequency';
|
||||
|
||||
interface MpvRuntimeLogger {
|
||||
debug: (message: string, meta?: unknown) => void;
|
||||
info: (message: string, meta?: unknown) => void;
|
||||
warn: (message: string, meta?: unknown) => void;
|
||||
error: (message: string, error?: unknown) => void;
|
||||
}
|
||||
|
||||
export interface MpvRuntimeInput {
|
||||
appState: AppState;
|
||||
logPath: string;
|
||||
logger: MpvRuntimeLogger;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getRuntimeBooleanOption: (id: RuntimeOptionId, fallback: boolean) => boolean;
|
||||
subtitle: Pick<
|
||||
SubtitleRuntime,
|
||||
| 'consumeCachedSubtitle'
|
||||
| 'emitSubtitlePayload'
|
||||
| 'onSubtitleChange'
|
||||
| 'onCurrentMediaPathChange'
|
||||
| 'onTimePosUpdate'
|
||||
| 'scheduleSubtitlePrefetchRefresh'
|
||||
| 'loadSubtitleSourceText'
|
||||
| 'setTokenizeSubtitleDeferred'
|
||||
>;
|
||||
ensureYomitanExtensionLoaded: () => Promise<void>;
|
||||
currentMediaTokenizationGate: ReturnType<typeof createCurrentMediaTokenizationGate>;
|
||||
startupOsdSequencer: ReturnType<typeof createStartupOsdSequencer>;
|
||||
dictionaries: {
|
||||
ensureJlptDictionaryLookup: () => Promise<void>;
|
||||
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
||||
};
|
||||
mediaRuntime: {
|
||||
syncImmersionMediaState: () => void;
|
||||
updateCurrentMediaPath: (mediaPath: unknown) => void;
|
||||
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
|
||||
};
|
||||
characterDictionaryAutoSyncRuntime: {
|
||||
scheduleSync: () => void;
|
||||
};
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setOverlayVisible: (visible: boolean) => void;
|
||||
};
|
||||
lifecycle: {
|
||||
requestAppQuit: () => void;
|
||||
scheduleQuitCheck: (callback: () => void) => void;
|
||||
restoreOverlayMpvSubtitles: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
};
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
};
|
||||
anilist: {
|
||||
getCurrentAnilistMediaKey: () => string | null;
|
||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||
maybeProbeAnilistDuration: (mediaKey: string | null) => void;
|
||||
ensureAnilistMediaGuess: (mediaKey: string | null) => void;
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
};
|
||||
jellyfin: {
|
||||
getQuitOnDisconnectArmed: () => boolean;
|
||||
reportJellyfinRemoteStopped: () => Promise<void>;
|
||||
reportJellyfinRemoteProgress: (forceImmediate?: boolean) => Promise<void>;
|
||||
startJellyfinRemoteSession: () => Promise<void>;
|
||||
};
|
||||
youtube: {
|
||||
getQuitOnDisconnectArmed: () => boolean;
|
||||
handleMpvConnectionChange: (connected: boolean) => void;
|
||||
handleMediaPathChange: (path: string | null) => void;
|
||||
handleSubtitleTrackChange: (sid: number | null) => void;
|
||||
handleSubtitleTrackListChange: (trackList: unknown[] | null) => void;
|
||||
invalidatePendingAutoplayReadyFallbacks: () => void;
|
||||
maybeSignalPluginAutoplayReady: (
|
||||
subtitle: { text: string; tokens: null },
|
||||
options?: { forceWhilePaused?: boolean },
|
||||
) => void;
|
||||
};
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
}
|
||||
|
||||
export interface MpvRuntime {
|
||||
createMpvClientRuntimeService: () => MpvIpcClient;
|
||||
updateMpvSubtitleRenderMetrics: (patch: Partial<MpvSubtitleRenderMetrics>) => void;
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries: () => Promise<void>;
|
||||
startTokenizationWarmups: () => Promise<void>;
|
||||
isTokenizationWarmupReady: () => boolean;
|
||||
startBackgroundWarmups: () => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
flushMpvLog: () => Promise<void>;
|
||||
cycleSecondarySubMode: () => void;
|
||||
shiftSubtitleDelayToAdjacentCue: (direction: 'next' | 'previous') => Promise<void>;
|
||||
}
|
||||
|
||||
function getActiveMediaPath(appState: AppState): string | null {
|
||||
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
|
||||
}
|
||||
|
||||
export function createMpvRuntime(input: MpvRuntimeInput): MpvRuntime {
|
||||
let backgroundWarmupsStarted = false;
|
||||
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
|
||||
|
||||
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
|
||||
appendToMpvLogMainDeps: {
|
||||
logPath: input.logPath,
|
||||
dirname: (targetPath) => path.dirname(targetPath),
|
||||
mkdir: async (targetPath, options) => {
|
||||
await fs.promises.mkdir(targetPath, options);
|
||||
},
|
||||
appendFile: async (targetPath, data, options) => {
|
||||
await fs.promises.appendFile(targetPath, data, options);
|
||||
},
|
||||
now: () => new Date(),
|
||||
},
|
||||
buildShowMpvOsdMainDeps: (appendToMpvLog) => ({
|
||||
appendToMpvLog,
|
||||
showMpvOsdRuntime: (mpvClient, text, fallbackLog) =>
|
||||
showMpvOsdRuntime(mpvClient, text, fallbackLog),
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
logInfo: (line) => input.logger.info(line),
|
||||
}),
|
||||
});
|
||||
|
||||
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
|
||||
cycleSecondarySubModeMainDeps: {
|
||||
getSecondarySubMode: () => input.appState.secondarySubMode,
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => {
|
||||
input.appState.secondarySubMode = mode;
|
||||
},
|
||||
getLastSecondarySubToggleAtMs: () => input.appState.lastSecondarySubToggleAtMs,
|
||||
setLastSecondarySubToggleAtMs: (timestampMs: number) => {
|
||||
input.appState.lastSecondarySubToggleAtMs = timestampMs;
|
||||
},
|
||||
broadcastToOverlayWindows: (channel, mode) => {
|
||||
input.overlay.broadcastToOverlayWindows(channel, mode);
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
},
|
||||
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
|
||||
});
|
||||
|
||||
const {
|
||||
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
|
||||
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
|
||||
tokenizeSubtitle,
|
||||
createMecabTokenizerAndCheck,
|
||||
prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups,
|
||||
startTokenizationWarmups,
|
||||
isTokenizationWarmupReady,
|
||||
} = composeMpvRuntimeHandlers<
|
||||
MpvIpcClient,
|
||||
ReturnType<typeof createTokenizerDepsRuntime>,
|
||||
SubtitleData
|
||||
>({
|
||||
bindMpvMainEventHandlersMainDeps: {
|
||||
appState: input.appState,
|
||||
getQuitOnDisconnectArmed: () =>
|
||||
input.jellyfin.getQuitOnDisconnectArmed() || input.youtube.getQuitOnDisconnectArmed(),
|
||||
scheduleQuitCheck: (callback) => {
|
||||
input.lifecycle.scheduleQuitCheck(callback);
|
||||
},
|
||||
quitApp: () => input.lifecycle.requestAppQuit(),
|
||||
reportJellyfinRemoteStopped: () => {
|
||||
void input.jellyfin.reportJellyfinRemoteStopped();
|
||||
},
|
||||
maybeRunAnilistPostWatchUpdate: () => input.anilist.maybeRunAnilistPostWatchUpdate(),
|
||||
logSubtitleTimingError: (message, error) => input.logger.error(message, error),
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
input.overlay.broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
getImmediateSubtitlePayload: (text) => input.subtitle.consumeCachedSubtitle(text),
|
||||
emitImmediateSubtitle: (payload) => {
|
||||
input.subtitle.emitSubtitlePayload(payload);
|
||||
},
|
||||
onSubtitleChange: (text) => {
|
||||
input.subtitle.onSubtitleChange(text);
|
||||
},
|
||||
refreshDiscordPresence: () => {
|
||||
input.lifecycle.refreshDiscordPresence();
|
||||
},
|
||||
ensureImmersionTrackerInitialized: () => {
|
||||
input.stats.ensureImmersionTrackerStarted();
|
||||
},
|
||||
tokenizeSubtitleForImmersion: async (text): Promise<SubtitleData | null> =>
|
||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
|
||||
updateCurrentMediaPath: (mediaPath) => {
|
||||
input.youtube.invalidatePendingAutoplayReadyFallbacks();
|
||||
input.currentMediaTokenizationGate.updateCurrentMediaPath(mediaPath);
|
||||
input.startupOsdSequencer.reset();
|
||||
input.subtitle.onCurrentMediaPathChange(mediaPath);
|
||||
input.youtube.handleMediaPathChange(mediaPath);
|
||||
if (mediaPath) {
|
||||
input.stats.ensureImmersionTrackerStarted();
|
||||
}
|
||||
input.mediaRuntime.updateCurrentMediaPath(mediaPath);
|
||||
},
|
||||
restoreMpvSubVisibility: () => {
|
||||
input.lifecycle.restoreOverlayMpvSubtitles();
|
||||
},
|
||||
resetSubtitleSidebarEmbeddedLayout: () => {
|
||||
sendMpvCommandRuntime(input.appState.mpvClient, [
|
||||
'set_property',
|
||||
'video-margin-ratio-right',
|
||||
0,
|
||||
]);
|
||||
sendMpvCommandRuntime(input.appState.mpvClient, ['set_property', 'video-pan-x', 0]);
|
||||
},
|
||||
getCurrentAnilistMediaKey: () => input.anilist.getCurrentAnilistMediaKey(),
|
||||
resetAnilistMediaTracking: (mediaKey) => {
|
||||
input.anilist.resetAnilistMediaTracking(mediaKey);
|
||||
},
|
||||
maybeProbeAnilistDuration: (mediaKey) => {
|
||||
if (mediaKey) {
|
||||
input.anilist.maybeProbeAnilistDuration(mediaKey);
|
||||
}
|
||||
},
|
||||
ensureAnilistMediaGuess: (mediaKey) => {
|
||||
if (mediaKey) {
|
||||
input.anilist.ensureAnilistMediaGuess(mediaKey);
|
||||
}
|
||||
},
|
||||
syncImmersionMediaState: () => {
|
||||
input.mediaRuntime.syncImmersionMediaState();
|
||||
},
|
||||
signalAutoplayReadyIfWarm: () => {
|
||||
if (!isTokenizationWarmupReady()) {
|
||||
return;
|
||||
}
|
||||
input.youtube.maybeSignalPluginAutoplayReady(
|
||||
{ text: '__warm__', tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
},
|
||||
scheduleCharacterDictionarySync: () => {
|
||||
if (!input.isCharacterDictionaryEnabled()) {
|
||||
return;
|
||||
}
|
||||
input.characterDictionaryAutoSyncRuntime.scheduleSync();
|
||||
},
|
||||
updateCurrentMediaTitle: (title) => {
|
||||
input.mediaRuntime.updateCurrentMediaTitle(title);
|
||||
},
|
||||
resetAnilistMediaGuessState: () => {
|
||||
input.anilist.resetAnilistMediaGuessState();
|
||||
},
|
||||
reportJellyfinRemoteProgress: (forceImmediate) => {
|
||||
void input.jellyfin.reportJellyfinRemoteProgress(forceImmediate);
|
||||
},
|
||||
onTimePosUpdate: (time) => {
|
||||
input.subtitle.onTimePosUpdate(time);
|
||||
},
|
||||
onSubtitleTrackChange: (sid) => {
|
||||
input.subtitle.scheduleSubtitlePrefetchRefresh();
|
||||
input.youtube.handleSubtitleTrackChange(sid);
|
||||
},
|
||||
onSubtitleTrackListChange: (trackList) => {
|
||||
input.subtitle.scheduleSubtitlePrefetchRefresh();
|
||||
input.youtube.handleSubtitleTrackListChange(trackList);
|
||||
},
|
||||
updateSubtitleRenderMetrics: (patch) => {
|
||||
updateMpvSubtitleRenderMetricsHandler(patch as Partial<MpvSubtitleRenderMetrics>);
|
||||
},
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
input.lifecycle.syncOverlayMpvSubtitleSuppression();
|
||||
},
|
||||
},
|
||||
mpvClientRuntimeServiceFactoryMainDeps: {
|
||||
createClient: MpvIpcClient,
|
||||
getSocketPath: () => input.appState.mpvSocketPath,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
isAutoStartOverlayEnabled: () => input.appState.autoStartOverlay,
|
||||
setOverlayVisible: (visible: boolean) => {
|
||||
input.overlay.setOverlayVisible(visible);
|
||||
},
|
||||
isVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
|
||||
getReconnectTimer: () => input.appState.reconnectTimer,
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
|
||||
input.appState.reconnectTimer = timer;
|
||||
},
|
||||
},
|
||||
updateMpvSubtitleRenderMetricsMainDeps: {
|
||||
getCurrentMetrics: () => input.appState.mpvSubtitleRenderMetrics,
|
||||
setCurrentMetrics: (metrics) => {
|
||||
input.appState.mpvSubtitleRenderMetrics = metrics;
|
||||
},
|
||||
applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch),
|
||||
broadcastMetrics: () => {},
|
||||
},
|
||||
tokenizer: {
|
||||
buildTokenizerDepsMainDeps: {
|
||||
getYomitanExt: () => input.appState.yomitanExt,
|
||||
getYomitanSession: () => input.appState.yomitanSession,
|
||||
getYomitanParserWindow: () => input.appState.yomitanParserWindow,
|
||||
setYomitanParserWindow: (window) => {
|
||||
input.appState.yomitanParserWindow = window;
|
||||
},
|
||||
getYomitanParserReadyPromise: () => input.appState.yomitanParserReadyPromise,
|
||||
setYomitanParserReadyPromise: (promise) => {
|
||||
input.appState.yomitanParserReadyPromise = promise;
|
||||
},
|
||||
getYomitanParserInitPromise: () => input.appState.yomitanParserInitPromise,
|
||||
setYomitanParserInitPromise: (promise) => {
|
||||
input.appState.yomitanParserInitPromise = promise;
|
||||
},
|
||||
isKnownWord: (text) => Boolean(input.appState.ankiIntegration?.isKnownWord(text)),
|
||||
recordLookup: (hit) => {
|
||||
input.stats.ensureImmersionTrackerStarted();
|
||||
input.appState.immersionTracker?.recordLookup(hit);
|
||||
},
|
||||
getKnownWordMatchMode: () =>
|
||||
input.appState.ankiIntegration?.getKnownWordMatchMode() ??
|
||||
input.getResolvedConfig().ankiConnect.knownWords.matchMode,
|
||||
getNPlusOneEnabled: () =>
|
||||
input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
input.getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
||||
),
|
||||
getMinSentenceWordsForNPlusOne: () =>
|
||||
input.getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
|
||||
getJlptLevel: (text) => input.appState.jlptLevelLookup(text),
|
||||
getJlptEnabled: () =>
|
||||
input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.jlpt',
|
||||
input.getResolvedConfig().subtitleStyle.enableJlpt,
|
||||
),
|
||||
getCharacterDictionaryEnabled: () => input.isCharacterDictionaryEnabled(),
|
||||
getNameMatchEnabled: () => input.getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||
getFrequencyDictionaryEnabled: () =>
|
||||
input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.frequency',
|
||||
input.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
),
|
||||
getFrequencyDictionaryMatchMode: () =>
|
||||
input.getResolvedConfig().subtitleStyle.frequencyDictionary.matchMode,
|
||||
getFrequencyRank: (text) => input.appState.frequencyRankLookup(text),
|
||||
getYomitanGroupDebugEnabled: () => input.appState.overlayDebugVisualizationEnabled,
|
||||
getMecabTokenizer: () => input.appState.mecabTokenizer,
|
||||
onTokenizationReady: (text) => {
|
||||
input.currentMediaTokenizationGate.markReady(getActiveMediaPath(input.appState));
|
||||
input.startupOsdSequencer.markTokenizationReady();
|
||||
input.youtube.maybeSignalPluginAutoplayReady(
|
||||
{ text, tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
},
|
||||
},
|
||||
createTokenizerRuntimeDeps: (deps) =>
|
||||
createTokenizerDepsRuntime(deps as Parameters<typeof createTokenizerDepsRuntime>[0]),
|
||||
tokenizeSubtitle: (text, deps) => tokenizeSubtitleCore(text, deps),
|
||||
createMecabTokenizerAndCheckMainDeps: {
|
||||
getMecabTokenizer: () => input.appState.mecabTokenizer,
|
||||
setMecabTokenizer: (tokenizer) => {
|
||||
input.appState.mecabTokenizer = tokenizer as MecabTokenizer | null;
|
||||
},
|
||||
createMecabTokenizer: () => new MecabTokenizer(),
|
||||
checkAvailability: async (tokenizer) => (tokenizer as MecabTokenizer).checkAvailability(),
|
||||
},
|
||||
prewarmSubtitleDictionariesMainDeps: {
|
||||
ensureJlptDictionaryLookup: () => input.dictionaries.ensureJlptDictionaryLookup(),
|
||||
ensureFrequencyDictionaryLookup: () => input.dictionaries.ensureFrequencyDictionaryLookup(),
|
||||
showMpvOsd: (message: string) => showMpvOsd(message),
|
||||
showLoadingOsd: (message: string) =>
|
||||
input.startupOsdSequencer.showAnnotationLoading(message),
|
||||
showLoadedOsd: (message: string) =>
|
||||
input.startupOsdSequencer.markAnnotationLoadingComplete(message),
|
||||
shouldShowOsdNotification: () => {
|
||||
const type = input.getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||
return type === 'osd' || type === 'both';
|
||||
},
|
||||
},
|
||||
},
|
||||
warmups: {
|
||||
launchBackgroundWarmupTaskMainDeps: {
|
||||
now: () => Date.now(),
|
||||
logDebug: (message) => input.logger.debug(message),
|
||||
logWarn: (message) => input.logger.warn(message),
|
||||
},
|
||||
startBackgroundWarmupsMainDeps: {
|
||||
getStarted: () => backgroundWarmupsStarted,
|
||||
setStarted: (started) => {
|
||||
backgroundWarmupsStarted = started;
|
||||
},
|
||||
isTexthookerOnlyMode: () => input.appState.texthookerOnlyMode,
|
||||
ensureYomitanExtensionLoaded: () => input.ensureYomitanExtensionLoaded().then(() => {}),
|
||||
shouldWarmupMecab: () => {
|
||||
const startupWarmups = input.getResolvedConfig().startupWarmups;
|
||||
if (startupWarmups.lowPowerMode) {
|
||||
return false;
|
||||
}
|
||||
if (!startupWarmups.mecab) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
input.getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
||||
) ||
|
||||
input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.jlpt',
|
||||
input.getResolvedConfig().subtitleStyle.enableJlpt,
|
||||
) ||
|
||||
input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.frequency',
|
||||
input.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
)
|
||||
);
|
||||
},
|
||||
shouldWarmupYomitanExtension: () =>
|
||||
input.getResolvedConfig().startupWarmups.yomitanExtension,
|
||||
shouldWarmupSubtitleDictionaries: () => {
|
||||
const startupWarmups = input.getResolvedConfig().startupWarmups;
|
||||
if (startupWarmups.lowPowerMode) {
|
||||
return false;
|
||||
}
|
||||
return startupWarmups.subtitleDictionaries;
|
||||
},
|
||||
shouldWarmupJellyfinRemoteSession: () => {
|
||||
const startupWarmups = input.getResolvedConfig().startupWarmups;
|
||||
if (startupWarmups.lowPowerMode) {
|
||||
return false;
|
||||
}
|
||||
return startupWarmups.jellyfinRemoteSession;
|
||||
},
|
||||
shouldAutoConnectJellyfinRemote: () => {
|
||||
const jellyfin = input.getResolvedConfig().jellyfin;
|
||||
return (
|
||||
jellyfin.enabled && jellyfin.remoteControlEnabled && jellyfin.remoteControlAutoConnect
|
||||
);
|
||||
},
|
||||
startJellyfinRemoteSession: () => input.jellyfin.startJellyfinRemoteSession(),
|
||||
logDebug: (message) => input.logger.debug(message),
|
||||
},
|
||||
},
|
||||
});
|
||||
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
||||
input.subtitle.setTokenizeSubtitleDeferred(tokenizeSubtitle);
|
||||
|
||||
const createMpvClientRuntimeService = (): MpvIpcClient => {
|
||||
const client = createMpvClientRuntimeServiceHandler();
|
||||
client.on('connection-change', ({ connected }) => {
|
||||
input.youtube.handleMpvConnectionChange(connected);
|
||||
});
|
||||
return client;
|
||||
};
|
||||
|
||||
const shiftSubtitleDelayToAdjacentCue = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
loadSubtitleSourceText: (source) => input.subtitle.loadSubtitleSourceText(source),
|
||||
sendMpvCommand: (command) => sendMpvCommandRuntime(input.appState.mpvClient, command),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
});
|
||||
|
||||
return {
|
||||
createMpvClientRuntimeService,
|
||||
updateMpvSubtitleRenderMetrics: (patch) => {
|
||||
updateMpvSubtitleRenderMetricsHandler(patch);
|
||||
},
|
||||
createMecabTokenizerAndCheck,
|
||||
prewarmSubtitleDictionaries,
|
||||
startTokenizationWarmups,
|
||||
isTokenizationWarmupReady,
|
||||
startBackgroundWarmups,
|
||||
showMpvOsd,
|
||||
flushMpvLog,
|
||||
cycleSecondarySubMode,
|
||||
shiftSubtitleDelayToAdjacentCue,
|
||||
};
|
||||
}
|
||||
63
src/main/overlay-geometry-accessors.ts
Normal file
63
src/main/overlay-geometry-accessors.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { WindowGeometry } from '../types';
|
||||
import type { OverlayGeometryRuntime } from './overlay-geometry-runtime';
|
||||
|
||||
export function createOverlayGeometryAccessors(deps: {
|
||||
getOverlayGeometryRuntime: () => OverlayGeometryRuntime<any> | null;
|
||||
getWindowTracker: () => { getGeometry?: () => WindowGeometry | null } | null;
|
||||
screen: {
|
||||
getCursorScreenPoint: () => { x: number; y: number };
|
||||
getDisplayNearestPoint: (point: { x: number; y: number }) => {
|
||||
workArea: { x: number; y: number; width: number; height: number };
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const getOverlayGeometryFallback = (): WindowGeometry => {
|
||||
const runtime = deps.getOverlayGeometryRuntime();
|
||||
if (runtime) {
|
||||
return runtime.getOverlayGeometryFallback();
|
||||
}
|
||||
|
||||
const cursorPoint = deps.screen.getCursorScreenPoint();
|
||||
const display = deps.screen.getDisplayNearestPoint(cursorPoint);
|
||||
const bounds = display.workArea;
|
||||
return {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
};
|
||||
};
|
||||
|
||||
const getCurrentOverlayGeometry = (): WindowGeometry => {
|
||||
const runtime = deps.getOverlayGeometryRuntime();
|
||||
if (runtime) {
|
||||
return runtime.getCurrentOverlayGeometry();
|
||||
}
|
||||
|
||||
const trackerGeometry = deps.getWindowTracker()?.getGeometry?.() ?? null;
|
||||
if (trackerGeometry) {
|
||||
return trackerGeometry;
|
||||
}
|
||||
|
||||
return getOverlayGeometryFallback();
|
||||
};
|
||||
|
||||
const geometryMatches = (a: WindowGeometry | null, b: WindowGeometry | null): boolean => {
|
||||
const runtime = deps.getOverlayGeometryRuntime();
|
||||
if (runtime) {
|
||||
return runtime.geometryMatches(a, b);
|
||||
}
|
||||
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
};
|
||||
|
||||
return {
|
||||
getOverlayGeometryFallback,
|
||||
getCurrentOverlayGeometry,
|
||||
geometryMatches,
|
||||
};
|
||||
}
|
||||
75
src/main/overlay-geometry-runtime.test.ts
Normal file
75
src/main/overlay-geometry-runtime.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createOverlayGeometryRuntime } from './overlay-geometry-runtime';
|
||||
|
||||
test('overlay geometry runtime prefers tracker geometry before fallback', () => {
|
||||
const overlayBounds: unknown[] = [];
|
||||
const modalBounds: unknown[] = [];
|
||||
const layerCalls: Array<[unknown, unknown]> = [];
|
||||
const levelCalls: unknown[] = [];
|
||||
|
||||
const runtime = createOverlayGeometryRuntime({
|
||||
screen: {
|
||||
getCursorScreenPoint: () => ({ x: 1, y: 2 }),
|
||||
getDisplayNearestPoint: () => ({
|
||||
workArea: { x: 10, y: 20, width: 30, height: 40 },
|
||||
}),
|
||||
},
|
||||
windowState: {
|
||||
getMainWindow: () =>
|
||||
({
|
||||
isDestroyed: () => false,
|
||||
}) as never,
|
||||
setOverlayWindowBounds: (geometry) => overlayBounds.push(geometry),
|
||||
setModalWindowBounds: (geometry) => modalBounds.push(geometry),
|
||||
getVisibleOverlayVisible: () => true,
|
||||
},
|
||||
getWindowTracker: () => ({
|
||||
getGeometry: () => ({ x: 100, y: 200, width: 300, height: 400 }),
|
||||
}),
|
||||
ensureOverlayWindowLevelCore: (window) => {
|
||||
levelCalls.push(window);
|
||||
},
|
||||
syncOverlayWindowLayer: (window, layer) => {
|
||||
layerCalls.push([window, layer]);
|
||||
},
|
||||
enforceOverlayLayerOrderCore: ({
|
||||
visibleOverlayVisible,
|
||||
mainWindow,
|
||||
ensureOverlayWindowLevel,
|
||||
}) => {
|
||||
if (visibleOverlayVisible && mainWindow) {
|
||||
ensureOverlayWindowLevel(mainWindow);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(runtime.getCurrentOverlayGeometry(), {
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
});
|
||||
assert.equal(
|
||||
runtime.geometryMatches(
|
||||
{ x: 1, y: 2, width: 3, height: 4 },
|
||||
{ x: 1, y: 2, width: 3, height: 4 },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(runtime.geometryMatches({ x: 1, y: 2, width: 3, height: 4 }, null), false);
|
||||
|
||||
runtime.applyOverlayRegions({ x: 7, y: 8, width: 9, height: 10 });
|
||||
assert.deepEqual(overlayBounds, [{ x: 7, y: 8, width: 9, height: 10 }]);
|
||||
assert.deepEqual(modalBounds, [{ x: 7, y: 8, width: 9, height: 10 }]);
|
||||
|
||||
runtime.syncPrimaryOverlayWindowLayer('visible');
|
||||
runtime.ensureOverlayWindowLevel({
|
||||
isDestroyed: () => false,
|
||||
} as never);
|
||||
runtime.enforceOverlayLayerOrder();
|
||||
|
||||
assert.equal(layerCalls.length >= 1, true);
|
||||
assert.equal(levelCalls.length >= 2, true);
|
||||
});
|
||||
135
src/main/overlay-geometry-runtime.ts
Normal file
135
src/main/overlay-geometry-runtime.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
createEnforceOverlayLayerOrderHandler,
|
||||
createEnsureOverlayWindowLevelHandler,
|
||||
createUpdateVisibleOverlayBoundsHandler,
|
||||
} from './runtime/overlay-window-layout';
|
||||
import {
|
||||
createBuildEnforceOverlayLayerOrderMainDepsHandler,
|
||||
createBuildEnsureOverlayWindowLevelMainDepsHandler,
|
||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
|
||||
} from './runtime/overlay-window-layout-main-deps';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
type BrowserWindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
type ScreenLike = {
|
||||
getCursorScreenPoint: () => { x: number; y: number };
|
||||
getDisplayNearestPoint: (point: { x: number; y: number }) => {
|
||||
workArea: { x: number; y: number; width: number; height: number };
|
||||
};
|
||||
};
|
||||
|
||||
export interface OverlayGeometryWindowState<TWindow extends BrowserWindowLike = BrowserWindowLike> {
|
||||
getMainWindow: () => TWindow | null;
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
}
|
||||
|
||||
export interface OverlayGeometryInput<TWindow extends BrowserWindowLike = BrowserWindowLike> {
|
||||
screen: ScreenLike;
|
||||
windowState: OverlayGeometryWindowState<TWindow>;
|
||||
getWindowTracker: () => { getGeometry?: () => WindowGeometry | null } | null;
|
||||
ensureOverlayWindowLevelCore: (window: TWindow) => void;
|
||||
syncOverlayWindowLayer: (window: TWindow, layer: 'visible') => void;
|
||||
enforceOverlayLayerOrderCore: (params: {
|
||||
visibleOverlayVisible: boolean;
|
||||
mainWindow: TWindow | null;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface OverlayGeometryRuntime<TWindow extends BrowserWindowLike = BrowserWindowLike> {
|
||||
getLastOverlayWindowGeometry: () => WindowGeometry | null;
|
||||
getOverlayGeometryFallback: () => WindowGeometry;
|
||||
getCurrentOverlayGeometry: () => WindowGeometry;
|
||||
geometryMatches: (a: WindowGeometry | null, b: WindowGeometry | null) => boolean;
|
||||
applyOverlayRegions: (geometry: WindowGeometry) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
}
|
||||
|
||||
export function createOverlayGeometryRuntime<TWindow extends BrowserWindowLike = BrowserWindowLike>(
|
||||
input: OverlayGeometryInput<TWindow>,
|
||||
): OverlayGeometryRuntime<TWindow> {
|
||||
let lastOverlayWindowGeometry: WindowGeometry | null = null;
|
||||
|
||||
const getOverlayGeometryFallback = (): WindowGeometry => {
|
||||
const cursorPoint = input.screen.getCursorScreenPoint();
|
||||
const display = input.screen.getDisplayNearestPoint(cursorPoint);
|
||||
const bounds = display.workArea;
|
||||
return {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
};
|
||||
};
|
||||
|
||||
const getCurrentOverlayGeometry = (): WindowGeometry => {
|
||||
if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry;
|
||||
const trackerGeometry = input.getWindowTracker()?.getGeometry?.() ?? null;
|
||||
if (trackerGeometry) return trackerGeometry;
|
||||
return getOverlayGeometryFallback();
|
||||
};
|
||||
|
||||
const geometryMatches = (a: WindowGeometry | null, b: WindowGeometry | null): boolean => {
|
||||
if (!a || !b) return false;
|
||||
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
};
|
||||
|
||||
const applyOverlayRegions = (geometry: WindowGeometry): void => {
|
||||
lastOverlayWindowGeometry = geometry;
|
||||
input.windowState.setOverlayWindowBounds(geometry);
|
||||
input.windowState.setModalWindowBounds(geometry);
|
||||
};
|
||||
|
||||
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
||||
})(),
|
||||
);
|
||||
|
||||
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
|
||||
createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
||||
ensureOverlayWindowLevelCore: (window) =>
|
||||
input.ensureOverlayWindowLevelCore(window as TWindow),
|
||||
})(),
|
||||
);
|
||||
|
||||
const syncPrimaryOverlayWindowLayer = (layer: 'visible'): void => {
|
||||
const mainWindow = input.windowState.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
input.syncOverlayWindowLayer(mainWindow, layer);
|
||||
};
|
||||
|
||||
const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
|
||||
createBuildEnforceOverlayLayerOrderMainDepsHandler({
|
||||
enforceOverlayLayerOrderCore: (params) =>
|
||||
input.enforceOverlayLayerOrderCore({
|
||||
visibleOverlayVisible: params.visibleOverlayVisible,
|
||||
mainWindow: params.mainWindow as TWindow | null,
|
||||
ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window as TWindow),
|
||||
}),
|
||||
getVisibleOverlayVisible: () => input.windowState.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => input.windowState.getMainWindow(),
|
||||
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as TWindow),
|
||||
})(),
|
||||
);
|
||||
|
||||
return {
|
||||
getLastOverlayWindowGeometry: () => lastOverlayWindowGeometry,
|
||||
getOverlayGeometryFallback,
|
||||
getCurrentOverlayGeometry,
|
||||
geometryMatches,
|
||||
applyOverlayRegions,
|
||||
updateVisibleOverlayBounds,
|
||||
ensureOverlayWindowLevel,
|
||||
syncPrimaryOverlayWindowLayer,
|
||||
enforceOverlayLayerOrder,
|
||||
};
|
||||
}
|
||||
251
src/main/overlay-ui-bootstrap-from-main-state.ts
Normal file
251
src/main/overlay-ui-bootstrap-from-main-state.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
nativeImage,
|
||||
Tray,
|
||||
type BrowserWindow,
|
||||
type MenuItemConstructorOptions,
|
||||
} from 'electron';
|
||||
|
||||
import type { AnilistRuntime } from './anilist-runtime';
|
||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
||||
import type { FirstRunRuntime } from './first-run-runtime';
|
||||
import type { JellyfinRuntime } from './jellyfin-runtime';
|
||||
import type { MpvRuntime } from './mpv-runtime';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
createOverlayWindow as createOverlayWindowCore,
|
||||
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
|
||||
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
|
||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
syncOverlayWindowLayer,
|
||||
} from '../core/services';
|
||||
import {
|
||||
buildTrayMenuTemplateRuntime,
|
||||
resolveTrayIconPathRuntime,
|
||||
} from './runtime/domains/overlay';
|
||||
import {
|
||||
createOverlayUiBootstrapRuntime,
|
||||
type OverlayUiBootstrapInput,
|
||||
type OverlayUiBootstrapRuntime,
|
||||
} from './overlay-ui-bootstrap-runtime';
|
||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
||||
import type { ShortcutsRuntimeBootstrap } from './shortcuts-runtime';
|
||||
import { createWindowTracker as createWindowTrackerCore } from '../window-trackers';
|
||||
|
||||
export interface OverlayUiBootstrapFromMainStateInput<
|
||||
TWindow extends BrowserWindow,
|
||||
TMenuItem = MenuItemConstructorOptions | MenuItem,
|
||||
> {
|
||||
appState: OverlayUiBootstrapInput<TWindow>['appState'];
|
||||
overlayManager: OverlayUiBootstrapInput<TWindow>['overlayManager'];
|
||||
overlayModalInputState: OverlayUiBootstrapInput<TWindow>['overlayModalInputState'];
|
||||
overlayModalRuntime: OverlayModalRuntime;
|
||||
overlayShortcutsRuntime: ShortcutsRuntimeBootstrap['overlayShortcutsRuntime'];
|
||||
runtimes: {
|
||||
dictionarySupport: Pick<DictionarySupportRuntime, 'createFieldGroupingCallback'>;
|
||||
firstRun: Pick<FirstRunRuntime, 'isSetupCompleted' | 'openFirstRunSetupWindow'>;
|
||||
yomitan: {
|
||||
openYomitanSettings: () => boolean;
|
||||
};
|
||||
jellyfin: Pick<JellyfinRuntime, 'openJellyfinSetupWindow'>;
|
||||
anilist: Pick<AnilistRuntime, 'openAnilistSetupWindow'>;
|
||||
shortcuts: Pick<ShortcutsRuntimeBootstrap['shortcuts'], 'registerGlobalShortcuts'>;
|
||||
mpvRuntime: Pick<MpvRuntime, 'startBackgroundWarmups'>;
|
||||
};
|
||||
electron: OverlayUiBootstrapInput<TWindow>['electron'] & {
|
||||
buildMenuFromTemplate: (template: TMenuItem[]) => unknown;
|
||||
createTray: (
|
||||
icon: ReturnType<OverlayUiBootstrapInput<TWindow>['electron']['createEmptyImage']>,
|
||||
) => Tray;
|
||||
};
|
||||
windowing: OverlayUiBootstrapInput<TWindow>['windowing'];
|
||||
actions: Omit<
|
||||
OverlayUiBootstrapInput<TWindow>['actions'],
|
||||
'registerGlobalShortcuts' | 'startBackgroundWarmups'
|
||||
>;
|
||||
trayState: OverlayUiBootstrapInput<TWindow>['trayState'];
|
||||
startup: OverlayUiBootstrapInput<TWindow>['startup'];
|
||||
}
|
||||
|
||||
export function createOverlayUiBootstrapFromMainState<TWindow extends BrowserWindow>(
|
||||
input: OverlayUiBootstrapFromMainStateInput<TWindow>,
|
||||
): OverlayUiBootstrapRuntime<TWindow> {
|
||||
return createOverlayUiBootstrapRuntime<TWindow>({
|
||||
appState: input.appState,
|
||||
overlayManager: input.overlayManager,
|
||||
overlayModalInputState: input.overlayModalInputState,
|
||||
overlayModalRuntime: input.overlayModalRuntime,
|
||||
overlayShortcutsRuntime: input.overlayShortcutsRuntime,
|
||||
dictionarySupport: {
|
||||
createFieldGroupingCallback: () =>
|
||||
input.runtimes.dictionarySupport.createFieldGroupingCallback(),
|
||||
},
|
||||
firstRun: {
|
||||
isSetupCompleted: () => input.runtimes.firstRun.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => input.runtimes.firstRun.openFirstRunSetupWindow(),
|
||||
},
|
||||
yomitan: {
|
||||
openYomitanSettings: () => {
|
||||
input.runtimes.yomitan.openYomitanSettings();
|
||||
},
|
||||
},
|
||||
jellyfin: {
|
||||
openJellyfinSetupWindow: () => input.runtimes.jellyfin.openJellyfinSetupWindow(),
|
||||
},
|
||||
anilist: {
|
||||
openAnilistSetupWindow: () => input.runtimes.anilist.openAnilistSetupWindow(),
|
||||
},
|
||||
electron: input.electron,
|
||||
windowing: input.windowing,
|
||||
actions: {
|
||||
...input.actions,
|
||||
registerGlobalShortcuts: () => input.runtimes.shortcuts.registerGlobalShortcuts(),
|
||||
startBackgroundWarmups: () => input.runtimes.mpvRuntime.startBackgroundWarmups(),
|
||||
},
|
||||
trayState: input.trayState,
|
||||
startup: input.startup,
|
||||
});
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapCoordinatorInput<TWindow extends BrowserWindow> {
|
||||
appState: OverlayUiBootstrapFromMainStateInput<TWindow>['appState'];
|
||||
overlayManager: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayManager'];
|
||||
overlayModalInputState: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayModalInputState'];
|
||||
overlayModalRuntime: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayModalRuntime'];
|
||||
overlayShortcutsRuntime: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayShortcutsRuntime'];
|
||||
runtimes: OverlayUiBootstrapFromMainStateInput<TWindow>['runtimes'];
|
||||
env: {
|
||||
screen: OverlayUiBootstrapFromMainStateInput<TWindow>['electron']['screen'];
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
dirname: string;
|
||||
platform: NodeJS.Platform;
|
||||
};
|
||||
windowing: OverlayUiBootstrapFromMainStateInput<TWindow>['windowing'];
|
||||
actions: Omit<
|
||||
OverlayUiBootstrapFromMainStateInput<TWindow>['actions'],
|
||||
| 'resolveTrayIconPathRuntime'
|
||||
| 'buildTrayMenuTemplateRuntime'
|
||||
| 'broadcastRuntimeOptionsChangedRuntime'
|
||||
| 'setOverlayDebugVisualizationEnabledRuntime'
|
||||
| 'initializeOverlayRuntimeCore'
|
||||
> &
|
||||
Pick<
|
||||
OverlayUiBootstrapFromMainStateInput<TWindow>['actions'],
|
||||
| 'resolveTrayIconPathRuntime'
|
||||
| 'buildTrayMenuTemplateRuntime'
|
||||
| 'broadcastRuntimeOptionsChangedRuntime'
|
||||
| 'setOverlayDebugVisualizationEnabledRuntime'
|
||||
| 'initializeOverlayRuntimeCore'
|
||||
>;
|
||||
trayState: OverlayUiBootstrapFromMainStateInput<TWindow>['trayState'];
|
||||
startup: OverlayUiBootstrapFromMainStateInput<TWindow>['startup'];
|
||||
}
|
||||
|
||||
export function createOverlayUiBootstrapCoordinator<TWindow extends BrowserWindow>(
|
||||
input: OverlayUiBootstrapCoordinatorInput<TWindow>,
|
||||
): OverlayUiBootstrapRuntime<TWindow> {
|
||||
return createOverlayUiBootstrapFromMainState<TWindow>({
|
||||
appState: input.appState,
|
||||
overlayManager: input.overlayManager,
|
||||
overlayModalInputState: input.overlayModalInputState,
|
||||
overlayModalRuntime: input.overlayModalRuntime,
|
||||
overlayShortcutsRuntime: input.overlayShortcutsRuntime,
|
||||
runtimes: input.runtimes,
|
||||
electron: {
|
||||
screen: input.env.screen,
|
||||
appPath: input.env.appPath,
|
||||
resourcesPath: input.env.resourcesPath,
|
||||
dirname: input.env.dirname,
|
||||
platform: input.env.platform,
|
||||
joinPath: (...parts) => path.join(...parts),
|
||||
fileExists: (candidate) => fs.existsSync(candidate),
|
||||
createImageFromPath: (iconPath) => nativeImage.createFromPath(iconPath),
|
||||
createEmptyImage: () => nativeImage.createEmpty(),
|
||||
createTray: (icon) => new Tray(icon as ConstructorParameters<typeof Tray>[0]),
|
||||
buildMenuFromTemplate: (template) =>
|
||||
Menu.buildFromTemplate(template as (MenuItemConstructorOptions | MenuItem)[]),
|
||||
},
|
||||
windowing: input.windowing,
|
||||
actions: input.actions,
|
||||
trayState: input.trayState,
|
||||
startup: input.startup,
|
||||
});
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapFromProcessStateInput<TWindow extends BrowserWindow> {
|
||||
appState: OverlayUiBootstrapCoordinatorInput<TWindow>['appState'];
|
||||
overlayManager: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayManager'];
|
||||
overlayModalInputState: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayModalInputState'];
|
||||
overlayModalRuntime: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayModalRuntime'];
|
||||
overlayShortcutsRuntime: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayShortcutsRuntime'];
|
||||
runtimes: OverlayUiBootstrapCoordinatorInput<TWindow>['runtimes'];
|
||||
env: OverlayUiBootstrapCoordinatorInput<TWindow>['env'] & {
|
||||
isDev: boolean;
|
||||
};
|
||||
actions: {
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
ensureOverlayMpvSubtitlesHidden: () => Promise<void>;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
requestAppQuit: () => void;
|
||||
};
|
||||
trayState: OverlayUiBootstrapCoordinatorInput<TWindow>['trayState'];
|
||||
startup: OverlayUiBootstrapCoordinatorInput<TWindow>['startup'];
|
||||
}
|
||||
|
||||
export function createOverlayUiBootstrapFromProcessState<TWindow extends BrowserWindow>(
|
||||
input: OverlayUiBootstrapFromProcessStateInput<TWindow>,
|
||||
): OverlayUiBootstrapRuntime<TWindow> {
|
||||
return createOverlayUiBootstrapCoordinator({
|
||||
appState: input.appState,
|
||||
overlayManager: input.overlayManager,
|
||||
overlayModalInputState: input.overlayModalInputState,
|
||||
overlayModalRuntime: input.overlayModalRuntime,
|
||||
overlayShortcutsRuntime: input.overlayShortcutsRuntime,
|
||||
runtimes: input.runtimes,
|
||||
env: input.env,
|
||||
windowing: {
|
||||
isDev: input.env.isDev,
|
||||
createOverlayWindowCore: (kind, options) =>
|
||||
createOverlayWindowCore(kind, options as never) as TWindow,
|
||||
ensureOverlayWindowLevelCore: (window) =>
|
||||
ensureOverlayWindowLevelCore(window as BrowserWindow),
|
||||
syncOverlayWindowLayer: (window, layer) =>
|
||||
syncOverlayWindowLayer(window as BrowserWindow, layer),
|
||||
enforceOverlayLayerOrderCore: (params) =>
|
||||
enforceOverlayLayerOrderCore({
|
||||
...params,
|
||||
mainWindow: params.mainWindow as BrowserWindow | null,
|
||||
ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window as TWindow),
|
||||
}),
|
||||
createWindowTrackerCore: (override, targetMpvSocketPath) =>
|
||||
createWindowTrackerCore(override, targetMpvSocketPath),
|
||||
},
|
||||
actions: {
|
||||
showMpvOsd: (message) => input.actions.showMpvOsd(message),
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.actions.showDesktopNotification(title, options),
|
||||
sendMpvCommand: (command) => input.actions.sendMpvCommand(command),
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
resolveTrayIconPathRuntime,
|
||||
buildTrayMenuTemplateRuntime,
|
||||
initializeOverlayRuntimeCore: (options) => initializeOverlayRuntimeCore(options as never),
|
||||
ensureOverlayMpvSubtitlesHidden: () => input.actions.ensureOverlayMpvSubtitlesHidden(),
|
||||
syncOverlayMpvSubtitleSuppression: () => input.actions.syncOverlayMpvSubtitleSuppression(),
|
||||
getResolvedConfig: () => input.actions.getResolvedConfig(),
|
||||
requestAppQuit: input.actions.requestAppQuit,
|
||||
},
|
||||
trayState: input.trayState,
|
||||
startup: input.startup,
|
||||
});
|
||||
}
|
||||
133
src/main/overlay-ui-bootstrap-runtime-input.test.ts
Normal file
133
src/main/overlay-ui-bootstrap-runtime-input.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
|
||||
import { createOverlayUiBootstrapRuntimeInput } from './overlay-ui-bootstrap-runtime-input';
|
||||
|
||||
test('overlay ui bootstrap runtime input builder preserves grouped wiring', () => {
|
||||
const input = createOverlayUiBootstrapRuntimeInput({
|
||||
windows: {
|
||||
state: {
|
||||
getMainWindow: () => null,
|
||||
setMainWindow: () => {},
|
||||
getModalWindow: () => null,
|
||||
setModalWindow: () => {},
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getOverlayDebugVisualizationEnabled: () => false,
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
},
|
||||
geometry: {
|
||||
getCurrentOverlayGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
|
||||
},
|
||||
modal: {
|
||||
setModalWindowBounds: () => {},
|
||||
onModalStateChange: () => {},
|
||||
},
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: (_modal: OverlayHostedModal) => {},
|
||||
notifyOverlayModalOpened: (_modal: OverlayHostedModal) => {},
|
||||
waitForModalOpen: async () => true,
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
sendToActiveOverlayWindow: () => false,
|
||||
},
|
||||
visibility: {
|
||||
service: {
|
||||
getModalActive: () => false,
|
||||
getForceMousePassthrough: () => false,
|
||||
getWindowTracker: () => null,
|
||||
getTrackerNotReadyWarningShown: () => false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
syncPrimaryOverlayWindowLayer: () => {},
|
||||
enforceOverlayLayerOrder: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: () => false,
|
||||
isWindowsPlatform: () => false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 5, y: 6, width: 7, height: 8 }),
|
||||
},
|
||||
overlayWindows: {
|
||||
createOverlayWindowCore: () => ({ isDestroyed: () => false }),
|
||||
isDev: false,
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
onRuntimeOptionsChanged: () => {},
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
isOverlayVisible: () => false,
|
||||
getYomitanSession: () => null,
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => {},
|
||||
onWindowClosed: () => {},
|
||||
},
|
||||
actions: {
|
||||
setVisibleOverlayVisibleCore: () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
overlayActions: {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getMpvClient: () => null,
|
||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
||||
},
|
||||
tray: null,
|
||||
bootstrap: {
|
||||
initializeOverlayRuntimeMainDeps: {
|
||||
appState: {
|
||||
backendOverride: null,
|
||||
windowTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
mpvClient: null,
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
runtimeOptionsManager: null,
|
||||
ankiIntegration: null,
|
||||
},
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => false,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => {},
|
||||
},
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntimeCore: () => {},
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
},
|
||||
onInitialized: () => {},
|
||||
},
|
||||
runtimeState: {
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
},
|
||||
mpvSubtitle: {
|
||||
ensureOverlayMpvSubtitlesHidden: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(input.tray, null);
|
||||
assert.equal(input.windows.windowState.getMainWindow(), null);
|
||||
assert.equal(input.windows.geometry.getCurrentOverlayGeometry().width, 3);
|
||||
assert.equal(input.windows.visibilityService.resolveFallbackBounds().height, 8);
|
||||
assert.equal(
|
||||
input.bootstrap.initializeOverlayRuntimeBootstrapDeps.isOverlayRuntimeInitialized(),
|
||||
false,
|
||||
);
|
||||
});
|
||||
87
src/main/overlay-ui-bootstrap-runtime-input.ts
Normal file
87
src/main/overlay-ui-bootstrap-runtime-input.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type {
|
||||
OverlayUiActionsInput,
|
||||
OverlayUiBootstrapInput,
|
||||
OverlayUiGeometryInput,
|
||||
OverlayUiModalInput,
|
||||
OverlayUiMpvSubtitleInput,
|
||||
OverlayUiRuntimeStateInput,
|
||||
OverlayUiTrayInput,
|
||||
OverlayUiVisibilityActionsInput,
|
||||
OverlayUiVisibilityServiceInput,
|
||||
OverlayUiWindowState,
|
||||
OverlayUiWindowsInput,
|
||||
} from './overlay-ui-runtime';
|
||||
import type { OverlayUiRuntimeGroupedInput } from './overlay-ui-runtime-input';
|
||||
|
||||
type WindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
export interface OverlayUiBootstrapRuntimeWindowsInput<TWindow extends WindowLike = WindowLike> {
|
||||
state: OverlayUiWindowState<TWindow>;
|
||||
geometry: OverlayUiGeometryInput;
|
||||
modal: OverlayUiModalInput;
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
};
|
||||
visibility: {
|
||||
service: OverlayUiVisibilityServiceInput<TWindow>;
|
||||
overlayWindows: OverlayUiWindowsInput<TWindow>;
|
||||
actions: OverlayUiVisibilityActionsInput;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapRuntimeInput<TWindow extends WindowLike = WindowLike> {
|
||||
windows: OverlayUiBootstrapRuntimeWindowsInput<TWindow>;
|
||||
overlayActions: OverlayUiActionsInput;
|
||||
tray: OverlayUiTrayInput | null;
|
||||
bootstrap: OverlayUiBootstrapInput;
|
||||
runtimeState: OverlayUiRuntimeStateInput;
|
||||
mpvSubtitle: OverlayUiMpvSubtitleInput;
|
||||
}
|
||||
|
||||
export function createOverlayUiBootstrapRuntimeInput<TWindow extends WindowLike>(
|
||||
input: OverlayUiBootstrapRuntimeInput<TWindow>,
|
||||
): OverlayUiRuntimeGroupedInput<TWindow> {
|
||||
return {
|
||||
windows: {
|
||||
windowState: input.windows.state,
|
||||
geometry: input.windows.geometry,
|
||||
modal: input.windows.modal,
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: (modal) =>
|
||||
input.windows.modalRuntime.handleOverlayModalClosed(modal),
|
||||
notifyOverlayModalOpened: (modal) =>
|
||||
input.windows.modalRuntime.notifyOverlayModalOpened(modal),
|
||||
waitForModalOpen: (modal, timeoutMs) =>
|
||||
input.windows.modalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
input.windows.modalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
||||
openRuntimeOptionsPalette: () => input.windows.modalRuntime.openRuntimeOptionsPalette(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
input.windows.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
},
|
||||
visibilityService: input.windows.visibility.service,
|
||||
overlayWindows: input.windows.visibility.overlayWindows,
|
||||
visibilityActions: input.windows.visibility.actions,
|
||||
},
|
||||
overlayActions: input.overlayActions,
|
||||
tray: input.tray,
|
||||
bootstrap: input.bootstrap,
|
||||
runtimeState: input.runtimeState,
|
||||
mpvSubtitle: input.mpvSubtitle,
|
||||
};
|
||||
}
|
||||
503
src/main/overlay-ui-bootstrap-runtime.ts
Normal file
503
src/main/overlay-ui-bootstrap-runtime.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
import type { BrowserWindow, Session } from 'electron';
|
||||
import type {
|
||||
AnkiConnectConfig,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuFieldGroupingRequestData,
|
||||
RuntimeOptionState,
|
||||
WindowGeometry,
|
||||
} from '../types';
|
||||
import type { BaseWindowTracker } from '../window-trackers';
|
||||
import {
|
||||
createOverlayGeometryRuntime,
|
||||
type OverlayGeometryRuntime,
|
||||
} from './overlay-geometry-runtime';
|
||||
import { createOverlayUiBootstrapRuntimeInput } from './overlay-ui-bootstrap-runtime-input';
|
||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
||||
import { createOverlayUiRuntime, type OverlayUiRuntime } from './overlay-ui-runtime';
|
||||
|
||||
type WindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
type OverlayWindowKind = 'visible' | 'modal';
|
||||
|
||||
type ScreenLike = {
|
||||
getCursorScreenPoint: () => { x: number; y: number };
|
||||
getDisplayNearestPoint: (point: { x: number; y: number }) => {
|
||||
workArea: { x: number; y: number; width: number; height: number };
|
||||
};
|
||||
};
|
||||
|
||||
type OverlayWindowTrackerLike = BaseWindowTracker | null;
|
||||
|
||||
type OverlayRuntimeOptionsManagerLike = {
|
||||
listOptions: () => RuntimeOptionState[];
|
||||
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||
} | null;
|
||||
|
||||
type OverlayMpvClientLike = {
|
||||
connected: boolean;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
send?: (payload: { command: string[] }) => void;
|
||||
} | null;
|
||||
|
||||
type OverlayManagerLike<TWindow extends WindowLike> = {
|
||||
getMainWindow: () => TWindow | null;
|
||||
setMainWindow: (window: TWindow | null) => void;
|
||||
getModalWindow: () => TWindow | null;
|
||||
setModalWindow: (window: TWindow | null) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
};
|
||||
|
||||
type OverlayModalInputStateLike = {
|
||||
getModalInputExclusive: () => boolean;
|
||||
handleModalInputStateChange: (active: boolean) => void;
|
||||
};
|
||||
|
||||
type OverlayShortcutsRuntimeLike = {
|
||||
syncOverlayShortcuts: () => void;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
};
|
||||
|
||||
type DictionarySupportLike = {
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
};
|
||||
|
||||
type FirstRunLike = {
|
||||
isSetupCompleted: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
};
|
||||
|
||||
type YomitanLike = {
|
||||
openYomitanSettings: () => void;
|
||||
};
|
||||
|
||||
type JellyfinLike = {
|
||||
openJellyfinSetupWindow: () => void;
|
||||
};
|
||||
|
||||
type AnilistLike = {
|
||||
openAnilistSetupWindow: () => void;
|
||||
};
|
||||
|
||||
type BootstrapTrayIconLike = {
|
||||
isEmpty: () => boolean;
|
||||
resize: (options: {
|
||||
width: number;
|
||||
height: number;
|
||||
quality?: 'best' | 'better' | 'good';
|
||||
}) => BootstrapTrayIconLike;
|
||||
setTemplateImage: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
type BootstrapTrayLike = {
|
||||
setContextMenu: (menu: any) => void;
|
||||
setToolTip: (tooltip: string) => void;
|
||||
on: (event: 'click', handler: () => void) => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
export interface OverlayUiBootstrapAppStateInput {
|
||||
backendOverride: string | null;
|
||||
windowTracker: OverlayWindowTrackerLike;
|
||||
subtitleTimingTracker: unknown;
|
||||
mpvClient: OverlayMpvClientLike;
|
||||
mpvSocketPath: string;
|
||||
runtimeOptionsManager: OverlayRuntimeOptionsManagerLike;
|
||||
ankiIntegration: unknown;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
overlayDebugVisualizationEnabled: boolean;
|
||||
statsOverlayVisible: boolean;
|
||||
trackerNotReadyWarningShown: boolean;
|
||||
yomitanSession: Session | null;
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapElectronInput<
|
||||
TWindow extends WindowLike,
|
||||
TMenuItem = unknown,
|
||||
TMenu = unknown,
|
||||
> {
|
||||
screen: ScreenLike;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
dirname: string;
|
||||
platform: NodeJS.Platform;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
fileExists: (candidate: string) => boolean;
|
||||
createImageFromPath: (iconPath: string) => BootstrapTrayIconLike;
|
||||
createEmptyImage: () => BootstrapTrayIconLike;
|
||||
createTray: (icon: BootstrapTrayIconLike) => BootstrapTrayLike;
|
||||
buildMenuFromTemplate: (template: TMenuItem[]) => TMenu;
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapInput<TWindow extends WindowLike> {
|
||||
appState: OverlayUiBootstrapAppStateInput;
|
||||
overlayManager: OverlayManagerLike<TWindow>;
|
||||
overlayModalInputState: OverlayModalInputStateLike;
|
||||
overlayModalRuntime: OverlayModalRuntime;
|
||||
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
|
||||
dictionarySupport: DictionarySupportLike;
|
||||
firstRun: FirstRunLike;
|
||||
yomitan: YomitanLike;
|
||||
jellyfin: JellyfinLike;
|
||||
anilist: AnilistLike;
|
||||
electron: OverlayUiBootstrapElectronInput<TWindow>;
|
||||
windowing: {
|
||||
isDev: boolean;
|
||||
createOverlayWindowCore: (
|
||||
kind: OverlayWindowKind,
|
||||
options: {
|
||||
isDev: boolean;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
onRuntimeOptionsChanged: () => void;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
yomitanSession?: Electron.Session | null;
|
||||
},
|
||||
) => TWindow;
|
||||
ensureOverlayWindowLevelCore: (window: TWindow) => void;
|
||||
syncOverlayWindowLayer: (window: TWindow, layer: 'visible') => void;
|
||||
enforceOverlayLayerOrderCore: (params: {
|
||||
visibleOverlayVisible: boolean;
|
||||
mainWindow: TWindow | null;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
}) => void;
|
||||
createWindowTrackerCore: (
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) => BaseWindowTracker | null;
|
||||
};
|
||||
actions: {
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
broadcastRuntimeOptionsChangedRuntime: (
|
||||
getRuntimeOptionsState: () => RuntimeOptionState[],
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
|
||||
) => void;
|
||||
setOverlayDebugVisualizationEnabledRuntime: (
|
||||
currentEnabled: boolean,
|
||||
nextEnabled: boolean,
|
||||
setCurrentEnabled: (enabled: boolean) => void,
|
||||
) => void;
|
||||
resolveTrayIconPathRuntime: (options: {
|
||||
platform: string;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
dirname: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
fileExists: (path: string) => boolean;
|
||||
}) => string | null;
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
quitApp: () => void;
|
||||
}) => unknown[];
|
||||
initializeOverlayRuntimeCore: (options: unknown) => void;
|
||||
ensureOverlayMpvSubtitlesHidden: () => Promise<void> | void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
requestAppQuit: () => void;
|
||||
};
|
||||
trayState: {
|
||||
getTray: () => BootstrapTrayLike | null;
|
||||
setTray: (tray: BootstrapTrayLike | null) => void;
|
||||
trayTooltip: string;
|
||||
logWarn: (message: string) => void;
|
||||
};
|
||||
startup: {
|
||||
shouldSkipHeadlessOverlayBootstrap: () => boolean;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
onInitialized?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapRuntime<TWindow extends WindowLike> {
|
||||
overlayGeometry: OverlayGeometryRuntime<TWindow>;
|
||||
overlayUi: OverlayUiRuntime<TWindow>;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
}
|
||||
|
||||
export function createOverlayUiBootstrapRuntime<TWindow extends WindowLike>(
|
||||
input: OverlayUiBootstrapInput<TWindow>,
|
||||
): OverlayUiBootstrapRuntime<TWindow> {
|
||||
const overlayGeometry = createOverlayGeometryRuntime<TWindow>({
|
||||
screen: input.electron.screen,
|
||||
windowState: {
|
||||
getMainWindow: () => input.overlayManager.getMainWindow(),
|
||||
setOverlayWindowBounds: (geometry) => input.overlayManager.setOverlayWindowBounds(geometry),
|
||||
setModalWindowBounds: (geometry) => input.overlayManager.setModalWindowBounds(geometry),
|
||||
getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(),
|
||||
},
|
||||
getWindowTracker: () => input.appState.windowTracker,
|
||||
ensureOverlayWindowLevelCore: (window) => input.windowing.ensureOverlayWindowLevelCore(window),
|
||||
syncOverlayWindowLayer: (window, layer) =>
|
||||
input.windowing.syncOverlayWindowLayer(window, layer),
|
||||
enforceOverlayLayerOrderCore: (params) => input.windowing.enforceOverlayLayerOrderCore(params),
|
||||
});
|
||||
|
||||
let overlayUi: OverlayUiRuntime<TWindow> | undefined;
|
||||
|
||||
overlayUi = createOverlayUiRuntime(
|
||||
createOverlayUiBootstrapRuntimeInput<TWindow>({
|
||||
windows: {
|
||||
state: {
|
||||
getMainWindow: () => input.overlayManager.getMainWindow(),
|
||||
setMainWindow: (window) => input.overlayManager.setMainWindow(window),
|
||||
getModalWindow: () => input.overlayManager.getModalWindow(),
|
||||
setModalWindow: (window) => input.overlayManager.setModalWindow(window),
|
||||
getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(),
|
||||
setVisibleOverlayVisible: (visible) =>
|
||||
input.overlayManager.setVisibleOverlayVisible(visible),
|
||||
getOverlayDebugVisualizationEnabled: () =>
|
||||
input.appState.overlayDebugVisualizationEnabled,
|
||||
setOverlayDebugVisualizationEnabled: (enabled) => {
|
||||
input.appState.overlayDebugVisualizationEnabled = enabled;
|
||||
},
|
||||
},
|
||||
geometry: {
|
||||
getCurrentOverlayGeometry: () => overlayGeometry.getCurrentOverlayGeometry(),
|
||||
},
|
||||
modal: {
|
||||
setModalWindowBounds: (geometry) => input.overlayManager.setModalWindowBounds(geometry),
|
||||
onModalStateChange: (active) => {
|
||||
input.overlayModalInputState.handleModalInputStateChange(active);
|
||||
},
|
||||
},
|
||||
modalRuntime: input.overlayModalRuntime as never,
|
||||
visibility: {
|
||||
service: {
|
||||
getModalActive: () => input.overlayModalInputState.getModalInputExclusive(),
|
||||
getForceMousePassthrough: () => input.appState.statsOverlayVisible,
|
||||
getWindowTracker: () => input.appState.windowTracker,
|
||||
getTrackerNotReadyWarningShown: () => input.appState.trackerNotReadyWarningShown,
|
||||
setTrackerNotReadyWarningShown: (shown) => {
|
||||
input.appState.trackerNotReadyWarningShown = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: (geometry) =>
|
||||
overlayGeometry.updateVisibleOverlayBounds(geometry),
|
||||
ensureOverlayWindowLevel: (window) => overlayGeometry.ensureOverlayWindowLevel(window),
|
||||
syncPrimaryOverlayWindowLayer: (layer) =>
|
||||
overlayGeometry.syncPrimaryOverlayWindowLayer(layer),
|
||||
enforceOverlayLayerOrder: () => overlayGeometry.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => input.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
isMacOSPlatform: () => input.electron.platform === 'darwin',
|
||||
isWindowsPlatform: () => input.electron.platform === 'win32',
|
||||
showOverlayLoadingOsd: (message) => input.actions.showMpvOsd(message),
|
||||
resolveFallbackBounds: () => overlayGeometry.getOverlayGeometryFallback(),
|
||||
},
|
||||
overlayWindows: {
|
||||
createOverlayWindowCore: (kind, options) =>
|
||||
input.windowing.createOverlayWindowCore(kind, options),
|
||||
isDev: input.windowing.isDev,
|
||||
ensureOverlayWindowLevel: (window) => overlayGeometry.ensureOverlayWindowLevel(window),
|
||||
onRuntimeOptionsChanged: () => {
|
||||
overlayUi?.broadcastRuntimeOptionsChanged();
|
||||
},
|
||||
setOverlayDebugVisualizationEnabled: (enabled) => {
|
||||
overlayUi?.setOverlayDebugVisualizationEnabled(enabled);
|
||||
},
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible' ? input.overlayManager.getVisibleOverlayVisible() : false,
|
||||
getYomitanSession: () => input.appState.yomitanSession,
|
||||
tryHandleOverlayShortcutLocalFallback: (overlayInput) =>
|
||||
input.overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(overlayInput),
|
||||
forwardTabToMpv: () => input.actions.sendMpvCommand(['keypress', 'TAB']),
|
||||
onWindowClosed: (windowKind) => {
|
||||
if (windowKind === 'visible') {
|
||||
input.overlayManager.setMainWindow(null);
|
||||
return;
|
||||
}
|
||||
input.overlayManager.setModalWindow(null);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setVisibleOverlayVisibleCore: ({
|
||||
visible,
|
||||
setVisibleOverlayVisibleState,
|
||||
updateVisibleOverlayVisibility,
|
||||
}) => {
|
||||
setVisibleOverlayVisibleState(visible);
|
||||
updateVisibleOverlayVisibility();
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
overlayActions: {
|
||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
broadcastRuntimeOptionsChangedRuntime: (
|
||||
getRuntimeOptionsState,
|
||||
broadcastToOverlayWindows,
|
||||
) =>
|
||||
input.actions.broadcastRuntimeOptionsChangedRuntime(
|
||||
getRuntimeOptionsState,
|
||||
broadcastToOverlayWindows,
|
||||
),
|
||||
broadcastToOverlayWindows: (channel, ...args) =>
|
||||
input.overlayManager.broadcastToOverlayWindows(channel, ...args),
|
||||
setOverlayDebugVisualizationEnabledRuntime: (
|
||||
currentEnabled,
|
||||
nextEnabled,
|
||||
setCurrentEnabled,
|
||||
) =>
|
||||
input.actions.setOverlayDebugVisualizationEnabledRuntime(
|
||||
currentEnabled,
|
||||
nextEnabled,
|
||||
setCurrentEnabled,
|
||||
),
|
||||
},
|
||||
tray: {
|
||||
resolveTrayIconPathDeps: {
|
||||
resolveTrayIconPathRuntime: input.actions.resolveTrayIconPathRuntime,
|
||||
platform: input.electron.platform,
|
||||
resourcesPath: input.electron.resourcesPath,
|
||||
appPath: input.electron.appPath,
|
||||
dirname: input.electron.dirname,
|
||||
joinPath: (...parts) => input.electron.joinPath(...parts),
|
||||
fileExists: (candidate) => input.electron.fileExists(candidate),
|
||||
},
|
||||
buildTrayMenuTemplateDeps: {
|
||||
buildTrayMenuTemplateRuntime: input.actions.buildTrayMenuTemplateRuntime,
|
||||
initializeOverlayRuntime: () => {
|
||||
overlayUi?.initializeOverlayRuntime();
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
overlayUi?.setVisibleOverlayVisible(visible);
|
||||
},
|
||||
showFirstRunSetup: () => !input.firstRun.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => input.firstRun.openFirstRunSetupWindow(),
|
||||
showWindowsMpvLauncherSetup: () => input.electron.platform === 'win32',
|
||||
openYomitanSettings: () => input.yomitan.openYomitanSettings(),
|
||||
openRuntimeOptionsPalette: () => {
|
||||
overlayUi?.openRuntimeOptionsPalette();
|
||||
},
|
||||
openJellyfinSetupWindow: () => input.jellyfin.openJellyfinSetupWindow(),
|
||||
openAnilistSetupWindow: () => input.anilist.openAnilistSetupWindow(),
|
||||
quitApp: () => input.actions.requestAppQuit(),
|
||||
},
|
||||
ensureTrayDeps: {
|
||||
getTray: () => input.trayState.getTray(),
|
||||
setTray: (tray) => input.trayState.setTray(tray),
|
||||
createImageFromPath: (iconPath) => input.electron.createImageFromPath(iconPath),
|
||||
createEmptyImage: () => input.electron.createEmptyImage(),
|
||||
createTray: (icon) => input.electron.createTray(icon),
|
||||
trayTooltip: input.trayState.trayTooltip,
|
||||
platform: input.electron.platform,
|
||||
logWarn: (message) => input.trayState.logWarn(message),
|
||||
initializeOverlayRuntime: () => {
|
||||
overlayUi?.initializeOverlayRuntime();
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
overlayUi?.setVisibleOverlayVisible(visible);
|
||||
},
|
||||
},
|
||||
destroyTrayDeps: {
|
||||
getTray: () => input.trayState.getTray(),
|
||||
setTray: (tray) => input.trayState.setTray(tray),
|
||||
},
|
||||
buildMenuFromTemplate: (template) => input.electron.buildMenuFromTemplate(template),
|
||||
},
|
||||
bootstrap: {
|
||||
initializeOverlayRuntimeMainDeps: {
|
||||
appState: input.appState,
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(),
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
overlayUi?.updateVisibleOverlayVisibility();
|
||||
},
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => input.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
},
|
||||
createMainWindow: () => {
|
||||
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
|
||||
return;
|
||||
}
|
||||
overlayUi?.createMainWindow();
|
||||
},
|
||||
registerGlobalShortcuts: () => {
|
||||
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
|
||||
return;
|
||||
}
|
||||
input.actions.registerGlobalShortcuts();
|
||||
},
|
||||
createWindowTracker: (override, targetMpvSocketPath) => {
|
||||
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
|
||||
return null;
|
||||
}
|
||||
return input.windowing.createWindowTrackerCore(
|
||||
override as string | null | undefined,
|
||||
targetMpvSocketPath as string | null | undefined,
|
||||
);
|
||||
},
|
||||
updateVisibleOverlayBounds: (geometry) =>
|
||||
overlayGeometry.updateVisibleOverlayBounds(geometry),
|
||||
getOverlayWindows: () => input.overlayManager.getOverlayWindows(),
|
||||
getResolvedConfig: () => input.actions.getResolvedConfig(),
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.actions.showDesktopNotification(title, options),
|
||||
createFieldGroupingCallback: () => input.dictionarySupport.createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => input.startup.getKnownWordCacheStatePath(),
|
||||
shouldStartAnkiIntegration: () => !input.startup.shouldSkipHeadlessOverlayBootstrap(),
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
initializeOverlayRuntimeCore: (options) =>
|
||||
input.actions.initializeOverlayRuntimeCore(options),
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
input.appState.overlayRuntimeInitialized = initialized;
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
|
||||
return;
|
||||
}
|
||||
input.actions.startBackgroundWarmups();
|
||||
},
|
||||
},
|
||||
onInitialized: input.startup.onInitialized,
|
||||
},
|
||||
runtimeState: {
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
input.appState.overlayRuntimeInitialized = initialized;
|
||||
},
|
||||
},
|
||||
mpvSubtitle: {
|
||||
ensureOverlayMpvSubtitlesHidden: () => input.actions.ensureOverlayMpvSubtitlesHidden(),
|
||||
syncOverlayMpvSubtitleSuppression: () => input.actions.syncOverlayMpvSubtitleSuppression(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
overlayGeometry,
|
||||
overlayUi,
|
||||
syncOverlayVisibilityForModal: () => {
|
||||
overlayUi.updateVisibleOverlayVisibility();
|
||||
},
|
||||
};
|
||||
}
|
||||
92
src/main/overlay-ui-runtime-input.ts
Normal file
92
src/main/overlay-ui-runtime-input.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import type {
|
||||
OverlayUiActionsInput,
|
||||
OverlayUiBootstrapInput,
|
||||
OverlayUiGeometryInput,
|
||||
OverlayUiModalInput,
|
||||
OverlayUiMpvSubtitleInput,
|
||||
OverlayUiRuntimeInput,
|
||||
OverlayUiRuntimeStateInput,
|
||||
OverlayUiTrayInput,
|
||||
OverlayUiVisibilityActionsInput,
|
||||
OverlayUiVisibilityServiceInput,
|
||||
OverlayUiWindowState,
|
||||
OverlayUiWindowsInput,
|
||||
} from './overlay-ui-runtime';
|
||||
|
||||
type WindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
export interface OverlayUiRuntimeWindowsInput<TWindow extends WindowLike = WindowLike> {
|
||||
windowState: OverlayUiWindowState<TWindow>;
|
||||
geometry: OverlayUiGeometryInput;
|
||||
modal: OverlayUiModalInput;
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
};
|
||||
visibilityService: OverlayUiVisibilityServiceInput<TWindow>;
|
||||
overlayWindows: OverlayUiWindowsInput<TWindow>;
|
||||
visibilityActions: OverlayUiVisibilityActionsInput;
|
||||
}
|
||||
|
||||
export interface OverlayUiRuntimeGroupedInput<TWindow extends WindowLike = WindowLike> {
|
||||
windows: OverlayUiRuntimeWindowsInput<TWindow>;
|
||||
overlayActions: OverlayUiActionsInput;
|
||||
tray: OverlayUiTrayInput | null;
|
||||
bootstrap: OverlayUiBootstrapInput;
|
||||
runtimeState: OverlayUiRuntimeStateInput;
|
||||
mpvSubtitle: OverlayUiMpvSubtitleInput;
|
||||
}
|
||||
|
||||
export type OverlayUiRuntimeInputLike<TWindow extends WindowLike = WindowLike> =
|
||||
| OverlayUiRuntimeInput<TWindow>
|
||||
| OverlayUiRuntimeGroupedInput<TWindow>;
|
||||
|
||||
export function normalizeOverlayUiRuntimeInput<TWindow extends WindowLike>(
|
||||
input: OverlayUiRuntimeInputLike<TWindow>,
|
||||
): OverlayUiRuntimeInput<TWindow> {
|
||||
if (!('windows' in input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return {
|
||||
windowState: input.windows.windowState,
|
||||
geometry: input.windows.geometry,
|
||||
modal: input.windows.modal,
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: (modal) =>
|
||||
input.windows.modalRuntime.handleOverlayModalClosed(modal),
|
||||
notifyOverlayModalOpened: (modal) =>
|
||||
input.windows.modalRuntime.notifyOverlayModalOpened(modal),
|
||||
waitForModalOpen: (modal, timeoutMs) =>
|
||||
input.windows.modalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
input.windows.modalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
||||
openRuntimeOptionsPalette: () => input.windows.modalRuntime.openRuntimeOptionsPalette(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
input.windows.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
},
|
||||
visibilityService: input.windows.visibilityService,
|
||||
overlayWindows: input.windows.overlayWindows,
|
||||
visibilityActions: input.windows.visibilityActions,
|
||||
overlayActions: input.overlayActions,
|
||||
tray: input.tray,
|
||||
bootstrap: input.bootstrap,
|
||||
runtimeState: input.runtimeState,
|
||||
mpvSubtitle: input.mpvSubtitle,
|
||||
};
|
||||
}
|
||||
608
src/main/overlay-ui-runtime.test.ts
Normal file
608
src/main/overlay-ui-runtime.test.ts
Normal file
@@ -0,0 +1,608 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
|
||||
import { createOverlayUiRuntime } from './overlay-ui-runtime';
|
||||
|
||||
type MockWindow = {
|
||||
destroyed: boolean;
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
function createWindow(): MockWindow {
|
||||
return {
|
||||
destroyed: false,
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('overlay ui runtime lazy-creates main window for toggle visibility actions', async () => {
|
||||
const calls: string[] = [];
|
||||
let mainWindow: MockWindow | null = null;
|
||||
const createdWindow = createWindow();
|
||||
let visibleOverlayVisible = false;
|
||||
|
||||
const overlayUi = createOverlayUiRuntime({
|
||||
windows: {
|
||||
windowState: {
|
||||
getMainWindow: () => mainWindow,
|
||||
setMainWindow: (window) => {
|
||||
mainWindow = window;
|
||||
},
|
||||
getModalWindow: () => null,
|
||||
setModalWindow: () => {},
|
||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlayVisible = visible;
|
||||
},
|
||||
getOverlayDebugVisualizationEnabled: () => false,
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
},
|
||||
geometry: {
|
||||
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
modal: {
|
||||
onModalStateChange: () => {},
|
||||
},
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: () => {},
|
||||
notifyOverlayModalOpened: () => {},
|
||||
waitForModalOpen: async () => false,
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
sendToActiveOverlayWindow: () => false,
|
||||
},
|
||||
visibilityService: {
|
||||
getModalActive: () => false,
|
||||
getForceMousePassthrough: () => false,
|
||||
getWindowTracker: () => null,
|
||||
getTrackerNotReadyWarningShown: () => false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
syncPrimaryOverlayWindowLayer: () => {},
|
||||
enforceOverlayLayerOrder: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: () => false,
|
||||
isWindowsPlatform: () => false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
overlayWindows: {
|
||||
createOverlayWindowCore: () => createdWindow,
|
||||
isDev: false,
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
onRuntimeOptionsChanged: () => {},
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
isOverlayVisible: () => visibleOverlayVisible,
|
||||
getYomitanSession: () => null,
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => {},
|
||||
onWindowClosed: () => {},
|
||||
},
|
||||
visibilityActions: {
|
||||
setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => {
|
||||
calls.push(`setVisible:${visible}`);
|
||||
setVisibleOverlayVisibleState(visible);
|
||||
},
|
||||
},
|
||||
},
|
||||
overlayActions: {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getMpvClient: () => null,
|
||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
||||
},
|
||||
tray: null,
|
||||
bootstrap: {
|
||||
initializeOverlayRuntimeMainDeps: {
|
||||
appState: {
|
||||
backendOverride: null,
|
||||
windowTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
mpvClient: null,
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
runtimeOptionsManager: null,
|
||||
ankiIntegration: null,
|
||||
},
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => {},
|
||||
},
|
||||
createMainWindow: () => {
|
||||
calls.push('bootstrapCreateMainWindow');
|
||||
},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
initializeOverlayRuntimeCore: () => {},
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
},
|
||||
onInitialized: () => {},
|
||||
},
|
||||
runtimeState: {
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
},
|
||||
mpvSubtitle: {
|
||||
ensureOverlayMpvSubtitlesHidden: async () => {
|
||||
calls.push('hideMpvSubs');
|
||||
},
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
calls.push('syncMpvSubs');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
overlayUi.toggleVisibleOverlay();
|
||||
|
||||
assert.equal(mainWindow, createdWindow);
|
||||
assert.deepEqual(calls, ['hideMpvSubs', 'setVisible:true', 'syncMpvSubs']);
|
||||
});
|
||||
|
||||
test('overlay ui runtime initializes overlay runtime before visible action when needed', async () => {
|
||||
const calls: string[] = [];
|
||||
let visibleOverlayVisible = false;
|
||||
let overlayRuntimeInitialized = false;
|
||||
|
||||
const overlayUi = createOverlayUiRuntime({
|
||||
windowState: {
|
||||
getMainWindow: () => null,
|
||||
setMainWindow: () => {},
|
||||
getModalWindow: () => null,
|
||||
setModalWindow: () => {},
|
||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlayVisible = visible;
|
||||
},
|
||||
getOverlayDebugVisualizationEnabled: () => false,
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
},
|
||||
geometry: {
|
||||
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
modal: {
|
||||
onModalStateChange: () => {},
|
||||
},
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: () => {},
|
||||
notifyOverlayModalOpened: () => {},
|
||||
waitForModalOpen: async () => false,
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
sendToActiveOverlayWindow: () => false,
|
||||
},
|
||||
visibilityService: {
|
||||
getModalActive: () => false,
|
||||
getForceMousePassthrough: () => false,
|
||||
getWindowTracker: () => null,
|
||||
getTrackerNotReadyWarningShown: () => false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
syncPrimaryOverlayWindowLayer: () => {},
|
||||
enforceOverlayLayerOrder: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: () => false,
|
||||
isWindowsPlatform: () => false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
overlayWindows: {
|
||||
createOverlayWindowCore: () => createWindow(),
|
||||
isDev: false,
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
onRuntimeOptionsChanged: () => {},
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
isOverlayVisible: () => visibleOverlayVisible,
|
||||
getYomitanSession: () => null,
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => {},
|
||||
onWindowClosed: () => {},
|
||||
},
|
||||
visibilityActions: {
|
||||
setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => {
|
||||
calls.push(`setVisible:${visible}`);
|
||||
setVisibleOverlayVisibleState(visible);
|
||||
},
|
||||
},
|
||||
overlayActions: {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getMpvClient: () => null,
|
||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
||||
},
|
||||
tray: null,
|
||||
bootstrap: {
|
||||
initializeOverlayRuntimeMainDeps: {
|
||||
appState: {
|
||||
backendOverride: null,
|
||||
windowTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
mpvClient: null,
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
runtimeOptionsManager: null,
|
||||
ankiIntegration: null,
|
||||
},
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => {},
|
||||
},
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
|
||||
initializeOverlayRuntimeCore: () => {
|
||||
calls.push('initializeOverlayRuntimeCore');
|
||||
},
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
overlayRuntimeInitialized = initialized;
|
||||
calls.push(`setInitialized:${initialized}`);
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startBackgroundWarmups');
|
||||
},
|
||||
},
|
||||
onInitialized: () => {
|
||||
calls.push('onInitialized');
|
||||
},
|
||||
},
|
||||
runtimeState: {
|
||||
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
overlayRuntimeInitialized = initialized;
|
||||
},
|
||||
},
|
||||
mpvSubtitle: {
|
||||
ensureOverlayMpvSubtitlesHidden: async () => {
|
||||
calls.push('hideMpvSubs');
|
||||
},
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
calls.push('syncMpvSubs');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
overlayUi.setVisibleOverlayVisible(true);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'setInitialized:true',
|
||||
'initializeOverlayRuntimeCore',
|
||||
'startBackgroundWarmups',
|
||||
'onInitialized',
|
||||
'syncMpvSubs',
|
||||
'hideMpvSubs',
|
||||
'setVisible:true',
|
||||
'syncMpvSubs',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay ui runtime initializes overlay runtime before overlay visibility action when needed', async () => {
|
||||
const calls: string[] = [];
|
||||
let overlayRuntimeInitialized = false;
|
||||
|
||||
const overlayUi = createOverlayUiRuntime({
|
||||
windowState: {
|
||||
getMainWindow: () => null,
|
||||
setMainWindow: () => {},
|
||||
getModalWindow: () => null,
|
||||
setModalWindow: () => {},
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getOverlayDebugVisualizationEnabled: () => false,
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
},
|
||||
geometry: {
|
||||
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
modal: {
|
||||
onModalStateChange: () => {},
|
||||
},
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: () => {},
|
||||
notifyOverlayModalOpened: () => {},
|
||||
waitForModalOpen: async () => false,
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
sendToActiveOverlayWindow: () => false,
|
||||
},
|
||||
visibilityService: {
|
||||
getModalActive: () => false,
|
||||
getForceMousePassthrough: () => false,
|
||||
getWindowTracker: () => null,
|
||||
getTrackerNotReadyWarningShown: () => false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
syncPrimaryOverlayWindowLayer: () => {},
|
||||
enforceOverlayLayerOrder: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: () => false,
|
||||
isWindowsPlatform: () => false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
overlayWindows: {
|
||||
createOverlayWindowCore: () => createWindow(),
|
||||
isDev: false,
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
onRuntimeOptionsChanged: () => {},
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
isOverlayVisible: () => false,
|
||||
getYomitanSession: () => null,
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => {},
|
||||
onWindowClosed: () => {},
|
||||
},
|
||||
visibilityActions: {
|
||||
setVisibleOverlayVisibleCore: ({ visible }) => {
|
||||
calls.push(`setVisible:${visible}`);
|
||||
},
|
||||
},
|
||||
overlayActions: {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getMpvClient: () => null,
|
||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
||||
},
|
||||
tray: null,
|
||||
bootstrap: {
|
||||
initializeOverlayRuntimeMainDeps: {
|
||||
appState: {
|
||||
backendOverride: null,
|
||||
windowTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
mpvClient: null,
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
runtimeOptionsManager: null,
|
||||
ankiIntegration: null,
|
||||
},
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => false,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => {},
|
||||
},
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
|
||||
initializeOverlayRuntimeCore: () => {
|
||||
calls.push('initializeOverlayRuntimeCore');
|
||||
},
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
overlayRuntimeInitialized = initialized;
|
||||
calls.push(`setInitialized:${initialized}`);
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startBackgroundWarmups');
|
||||
},
|
||||
},
|
||||
onInitialized: () => {
|
||||
calls.push('onInitialized');
|
||||
},
|
||||
},
|
||||
runtimeState: {
|
||||
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
overlayRuntimeInitialized = initialized;
|
||||
},
|
||||
},
|
||||
mpvSubtitle: {
|
||||
ensureOverlayMpvSubtitlesHidden: async () => {
|
||||
calls.push('hideMpvSubs');
|
||||
},
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
calls.push('syncMpvSubs');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
overlayUi.setOverlayVisible(true);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'setInitialized:true',
|
||||
'initializeOverlayRuntimeCore',
|
||||
'startBackgroundWarmups',
|
||||
'onInitialized',
|
||||
'syncMpvSubs',
|
||||
'hideMpvSubs',
|
||||
'setVisible:true',
|
||||
'syncMpvSubs',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay ui runtime delegates modal actions to injected modal runtime', async () => {
|
||||
const calls: string[] = [];
|
||||
const restoreOnClose = new Set<OverlayHostedModal>();
|
||||
|
||||
const overlayUi = createOverlayUiRuntime({
|
||||
windowState: {
|
||||
getMainWindow: () => null,
|
||||
setMainWindow: () => {},
|
||||
getModalWindow: () => null,
|
||||
setModalWindow: () => {},
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getOverlayDebugVisualizationEnabled: () => false,
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
},
|
||||
geometry: {
|
||||
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
modal: {
|
||||
onModalStateChange: () => {},
|
||||
},
|
||||
visibilityService: {
|
||||
getModalActive: () => false,
|
||||
getForceMousePassthrough: () => false,
|
||||
getWindowTracker: () => null,
|
||||
getTrackerNotReadyWarningShown: () => false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
syncPrimaryOverlayWindowLayer: () => {},
|
||||
enforceOverlayLayerOrder: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: () => false,
|
||||
isWindowsPlatform: () => false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
overlayWindows: {
|
||||
createOverlayWindowCore: () => createWindow(),
|
||||
isDev: false,
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
onRuntimeOptionsChanged: () => {},
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
isOverlayVisible: () => false,
|
||||
getYomitanSession: () => null,
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => {},
|
||||
onWindowClosed: () => {},
|
||||
},
|
||||
visibilityActions: {
|
||||
setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => {
|
||||
setVisibleOverlayVisibleState(visible);
|
||||
},
|
||||
},
|
||||
overlayActions: {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getMpvClient: () => null,
|
||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
||||
},
|
||||
modalRuntime: {
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
|
||||
calls.push(`send:${channel}:${String(payload)}`);
|
||||
if (runtimeOptions?.restoreOnModalClose) {
|
||||
restoreOnClose.add(runtimeOptions.restoreOnModalClose);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
openRuntimeOptionsPalette: () => {
|
||||
calls.push('openRuntimeOptionsPalette');
|
||||
},
|
||||
handleOverlayModalClosed: (modal) => {
|
||||
calls.push(`closed:${modal}`);
|
||||
},
|
||||
notifyOverlayModalOpened: (modal) => {
|
||||
calls.push(`opened:${modal}`);
|
||||
},
|
||||
waitForModalOpen: async (modal, timeoutMs) => {
|
||||
calls.push(`wait:${modal}:${timeoutMs}`);
|
||||
return true;
|
||||
},
|
||||
getRestoreVisibleOverlayOnModalClose: () => restoreOnClose,
|
||||
},
|
||||
tray: null,
|
||||
bootstrap: {
|
||||
initializeOverlayRuntimeMainDeps: {
|
||||
appState: {
|
||||
backendOverride: null,
|
||||
windowTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
mpvClient: null,
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
runtimeOptionsManager: null,
|
||||
ankiIntegration: null,
|
||||
},
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => false,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => {},
|
||||
},
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
initializeOverlayRuntimeCore: () => {},
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
},
|
||||
},
|
||||
runtimeState: {
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
},
|
||||
mpvSubtitle: {
|
||||
ensureOverlayMpvSubtitlesHidden: async () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
overlayUi.sendToActiveOverlayWindow('jimaku:open', 'payload', {
|
||||
restoreOnModalClose: 'jimaku',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
overlayUi.openRuntimeOptionsPalette();
|
||||
overlayUi.notifyOverlayModalOpened('runtime-options');
|
||||
overlayUi.handleOverlayModalClosed('runtime-options');
|
||||
assert.equal(await overlayUi.waitForModalOpen('youtube-track-picker', 50), true);
|
||||
assert.equal(overlayUi.getRestoreVisibleOverlayOnModalClose(), restoreOnClose);
|
||||
assert.deepEqual(calls, [
|
||||
'send:jimaku:open:payload',
|
||||
'openRuntimeOptionsPalette',
|
||||
'opened:runtime-options',
|
||||
'closed:runtime-options',
|
||||
'wait:youtube-track-picker:50',
|
||||
]);
|
||||
});
|
||||
409
src/main/overlay-ui-runtime.ts
Normal file
409
src/main/overlay-ui-runtime.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import type { Session } from 'electron';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { RuntimeOptionState, WindowGeometry } from '../types';
|
||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
||||
import {
|
||||
normalizeOverlayUiRuntimeInput,
|
||||
type OverlayUiRuntimeInputLike,
|
||||
} from './overlay-ui-runtime-input';
|
||||
import {
|
||||
createOverlayVisibilityRuntimeBridge,
|
||||
type OverlayUiVisibilityBridgeWindowLike,
|
||||
} from './overlay-ui-visibility';
|
||||
import { createOverlayVisibilityRuntime } from './runtime/overlay-visibility-runtime';
|
||||
import { createOverlayWindowRuntimeHandlers } from './runtime/overlay-window-runtime-handlers';
|
||||
import { createTrayRuntimeHandlers } from './runtime/tray-runtime-handlers';
|
||||
import { createOverlayRuntimeBootstrapHandlers } from './runtime/overlay-runtime-bootstrap-handlers';
|
||||
import { composeOverlayVisibilityRuntime } from './runtime/composers/overlay-visibility-runtime-composer';
|
||||
import {
|
||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler,
|
||||
createBuildGetRuntimeOptionsStateMainDepsHandler,
|
||||
createBuildOpenRuntimeOptionsPaletteMainDepsHandler,
|
||||
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler,
|
||||
createBuildSendToActiveOverlayWindowMainDepsHandler,
|
||||
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler,
|
||||
} from './runtime/overlay-runtime-main-actions-main-deps';
|
||||
import { createGetRuntimeOptionsStateHandler } from './runtime/overlay-runtime-main-actions';
|
||||
|
||||
type OverlayWindowKind = 'visible' | 'modal';
|
||||
|
||||
type WindowLike = OverlayUiVisibilityBridgeWindowLike;
|
||||
|
||||
type RuntimeOptionsManagerLike = {
|
||||
listOptions: () => RuntimeOptionState[];
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
};
|
||||
|
||||
type TrayHandlersDeps = Parameters<typeof createTrayRuntimeHandlers>[0];
|
||||
type BootstrapHandlersDeps = Parameters<typeof createOverlayRuntimeBootstrapHandlers>[0];
|
||||
|
||||
type OverlayWindowCreateOptions<TWindow extends WindowLike> = {
|
||||
isDev: boolean;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
onRuntimeOptionsChanged: () => void;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
yomitanSession?: Electron.Session | null;
|
||||
};
|
||||
|
||||
export interface OverlayUiWindowState<TWindow extends WindowLike = WindowLike> {
|
||||
getMainWindow: () => TWindow | null;
|
||||
setMainWindow: (window: TWindow | null) => void;
|
||||
getModalWindow: () => TWindow | null;
|
||||
setModalWindow: (window: TWindow | null) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getOverlayDebugVisualizationEnabled: () => boolean;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiGeometryInput {
|
||||
getCurrentOverlayGeometry: () => WindowGeometry;
|
||||
}
|
||||
|
||||
export interface OverlayUiModalInput {
|
||||
setModalWindowBounds?: (geometry: WindowGeometry) => void;
|
||||
onModalStateChange?: (active: boolean) => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiVisibilityServiceInput<TWindow extends WindowLike = WindowLike> {
|
||||
getModalActive: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getWindowTracker: () => unknown;
|
||||
getTrackerNotReadyWarningShown: () => boolean;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
isMacOSPlatform: () => boolean;
|
||||
isWindowsPlatform: () => boolean;
|
||||
showOverlayLoadingOsd: (message: string) => void;
|
||||
resolveFallbackBounds: () => WindowGeometry;
|
||||
}
|
||||
|
||||
export interface OverlayUiWindowsInput<TWindow extends WindowLike = WindowLike> {
|
||||
createOverlayWindowCore: (
|
||||
kind: OverlayWindowKind,
|
||||
options: OverlayWindowCreateOptions<TWindow>,
|
||||
) => TWindow;
|
||||
isDev: boolean;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
onRuntimeOptionsChanged: () => void;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
getYomitanSession: () => Session | null;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiVisibilityActionsInput {
|
||||
setVisibleOverlayVisibleCore: (options: {
|
||||
visible: boolean;
|
||||
setVisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiActionsInput {
|
||||
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
|
||||
broadcastRuntimeOptionsChangedRuntime: (
|
||||
getRuntimeOptionsState: () => RuntimeOptionState[],
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
|
||||
) => void;
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
||||
setOverlayDebugVisualizationEnabledRuntime: (
|
||||
currentEnabled: boolean,
|
||||
nextEnabled: boolean,
|
||||
setCurrentEnabled: (enabled: boolean) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiTrayInput {
|
||||
resolveTrayIconPathDeps: TrayHandlersDeps['resolveTrayIconPathDeps'];
|
||||
buildTrayMenuTemplateDeps: TrayHandlersDeps['buildTrayMenuTemplateDeps'];
|
||||
ensureTrayDeps: TrayHandlersDeps['ensureTrayDeps'];
|
||||
destroyTrayDeps: TrayHandlersDeps['destroyTrayDeps'];
|
||||
buildMenuFromTemplate: TrayHandlersDeps['buildMenuFromTemplate'];
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapInput {
|
||||
initializeOverlayRuntimeMainDeps: BootstrapHandlersDeps['initializeOverlayRuntimeMainDeps'];
|
||||
initializeOverlayRuntimeBootstrapDeps: BootstrapHandlersDeps['initializeOverlayRuntimeBootstrapDeps'];
|
||||
onInitialized?: () => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiRuntimeStateInput {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
setOverlayRuntimeInitialized: (initialized: boolean) => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiMpvSubtitleInput {
|
||||
ensureOverlayMpvSubtitlesHidden: () => Promise<void> | void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiRuntimeInput<TWindow extends WindowLike = WindowLike> {
|
||||
windowState: OverlayUiWindowState<TWindow>;
|
||||
geometry: OverlayUiGeometryInput;
|
||||
modal: OverlayUiModalInput;
|
||||
modalRuntime: OverlayModalRuntime;
|
||||
visibilityService: OverlayUiVisibilityServiceInput<TWindow>;
|
||||
overlayWindows: OverlayUiWindowsInput<TWindow>;
|
||||
visibilityActions: OverlayUiVisibilityActionsInput;
|
||||
overlayActions: OverlayUiActionsInput;
|
||||
tray: OverlayUiTrayInput | null;
|
||||
bootstrap: OverlayUiBootstrapInput;
|
||||
runtimeState: OverlayUiRuntimeStateInput;
|
||||
mpvSubtitle: OverlayUiMpvSubtitleInput;
|
||||
}
|
||||
|
||||
export interface OverlayUiRuntime<TWindow extends WindowLike = WindowLike> {
|
||||
createMainWindow: () => TWindow;
|
||||
createModalWindow: () => TWindow;
|
||||
ensureTray: () => void;
|
||||
destroyTray: () => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
setOverlayVisible: (visible: boolean) => void;
|
||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
broadcastRuntimeOptionsChanged: () => void;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
}
|
||||
|
||||
export function createOverlayUiRuntime<TWindow extends WindowLike>(
|
||||
input: OverlayUiRuntimeInputLike<TWindow>,
|
||||
): OverlayUiRuntime<TWindow> {
|
||||
const runtimeInput = normalizeOverlayUiRuntimeInput(input);
|
||||
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeBridge({
|
||||
getMainWindow: () => runtimeInput.windowState.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => runtimeInput.windowState.getVisibleOverlayVisible(),
|
||||
getModalActive: () => runtimeInput.visibilityService.getModalActive(),
|
||||
getForceMousePassthrough: () => runtimeInput.visibilityService.getForceMousePassthrough(),
|
||||
getWindowTracker: () => runtimeInput.visibilityService.getWindowTracker(),
|
||||
getTrackerNotReadyWarningShown: () =>
|
||||
runtimeInput.visibilityService.getTrackerNotReadyWarningShown(),
|
||||
setTrackerNotReadyWarningShown: (shown) =>
|
||||
runtimeInput.visibilityService.setTrackerNotReadyWarningShown(shown),
|
||||
updateVisibleOverlayBounds: (geometry) =>
|
||||
runtimeInput.visibilityService.updateVisibleOverlayBounds(geometry),
|
||||
ensureOverlayWindowLevel: (window) =>
|
||||
runtimeInput.visibilityService.ensureOverlayWindowLevel(window),
|
||||
syncPrimaryOverlayWindowLayer: (layer) =>
|
||||
runtimeInput.visibilityService.syncPrimaryOverlayWindowLayer(layer),
|
||||
enforceOverlayLayerOrder: () => runtimeInput.visibilityService.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => runtimeInput.visibilityService.syncOverlayShortcuts(),
|
||||
isMacOSPlatform: () => runtimeInput.visibilityService.isMacOSPlatform(),
|
||||
isWindowsPlatform: () => runtimeInput.visibilityService.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message) =>
|
||||
runtimeInput.visibilityService.showOverlayLoadingOsd(message),
|
||||
});
|
||||
|
||||
const overlayWindowHandlers = createOverlayWindowRuntimeHandlers<TWindow>({
|
||||
createOverlayWindowDeps: {
|
||||
createOverlayWindowCore: (kind, options) =>
|
||||
runtimeInput.overlayWindows.createOverlayWindowCore(kind, options),
|
||||
isDev: runtimeInput.overlayWindows.isDev,
|
||||
ensureOverlayWindowLevel: (window) =>
|
||||
runtimeInput.overlayWindows.ensureOverlayWindowLevel(window),
|
||||
onRuntimeOptionsChanged: () => runtimeInput.overlayWindows.onRuntimeOptionsChanged(),
|
||||
setOverlayDebugVisualizationEnabled: (enabled) =>
|
||||
runtimeInput.overlayWindows.setOverlayDebugVisualizationEnabled(enabled),
|
||||
isOverlayVisible: (windowKind) => runtimeInput.overlayWindows.isOverlayVisible(windowKind),
|
||||
getYomitanSession: () => runtimeInput.overlayWindows.getYomitanSession(),
|
||||
tryHandleOverlayShortcutLocalFallback: (overlayInput) =>
|
||||
runtimeInput.overlayWindows.tryHandleOverlayShortcutLocalFallback(overlayInput),
|
||||
forwardTabToMpv: () => runtimeInput.overlayWindows.forwardTabToMpv(),
|
||||
onWindowClosed: (windowKind) => runtimeInput.overlayWindows.onWindowClosed(windowKind),
|
||||
},
|
||||
setMainWindow: (window) => runtimeInput.windowState.setMainWindow(window),
|
||||
setModalWindow: (window) => runtimeInput.windowState.setModalWindow(window),
|
||||
});
|
||||
|
||||
const visibilityActions = createOverlayVisibilityRuntime({
|
||||
setVisibleOverlayVisibleDeps: {
|
||||
setVisibleOverlayVisibleCore: (options) =>
|
||||
runtimeInput.visibilityActions.setVisibleOverlayVisibleCore(options),
|
||||
setVisibleOverlayVisibleState: (visible) =>
|
||||
runtimeInput.windowState.setVisibleOverlayVisible(visible),
|
||||
updateVisibleOverlayVisibility: () =>
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
},
|
||||
getVisibleOverlayVisible: () => runtimeInput.windowState.getVisibleOverlayVisible(),
|
||||
});
|
||||
|
||||
const getRuntimeOptionsState = createGetRuntimeOptionsStateHandler(
|
||||
createBuildGetRuntimeOptionsStateMainDepsHandler({
|
||||
getRuntimeOptionsManager: () => runtimeInput.overlayActions.getRuntimeOptionsManager(),
|
||||
})(),
|
||||
);
|
||||
|
||||
const overlayActions = composeOverlayVisibilityRuntime({
|
||||
overlayVisibilityRuntime,
|
||||
restorePreviousSecondarySubVisibilityMainDeps:
|
||||
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({
|
||||
getMpvClient: () => runtimeInput.overlayActions.getMpvClient(),
|
||||
})(),
|
||||
broadcastRuntimeOptionsChangedMainDeps:
|
||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
|
||||
broadcastRuntimeOptionsChangedRuntime: (getState, broadcast) =>
|
||||
runtimeInput.overlayActions.broadcastRuntimeOptionsChangedRuntime(getState, broadcast),
|
||||
getRuntimeOptionsState: () => getRuntimeOptionsState(),
|
||||
broadcastToOverlayWindows: (channel, ...args) =>
|
||||
runtimeInput.overlayActions.broadcastToOverlayWindows(channel, ...args),
|
||||
})(),
|
||||
sendToActiveOverlayWindowMainDeps: createBuildSendToActiveOverlayWindowMainDepsHandler({
|
||||
sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) =>
|
||||
runtimeInput.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
})(),
|
||||
setOverlayDebugVisualizationEnabledMainDeps:
|
||||
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler({
|
||||
setOverlayDebugVisualizationEnabledRuntime: (currentEnabled, nextEnabled, setCurrent) =>
|
||||
runtimeInput.overlayActions.setOverlayDebugVisualizationEnabledRuntime(
|
||||
currentEnabled,
|
||||
nextEnabled,
|
||||
setCurrent,
|
||||
),
|
||||
getCurrentEnabled: () => runtimeInput.windowState.getOverlayDebugVisualizationEnabled(),
|
||||
setCurrentEnabled: (enabled) =>
|
||||
runtimeInput.windowState.setOverlayDebugVisualizationEnabled(enabled),
|
||||
})(),
|
||||
openRuntimeOptionsPaletteMainDeps: createBuildOpenRuntimeOptionsPaletteMainDepsHandler({
|
||||
openRuntimeOptionsPaletteRuntime: () => runtimeInput.modalRuntime.openRuntimeOptionsPalette(),
|
||||
})(),
|
||||
});
|
||||
|
||||
const trayHandlers = runtimeInput.tray
|
||||
? createTrayRuntimeHandlers({
|
||||
resolveTrayIconPathDeps: runtimeInput.tray.resolveTrayIconPathDeps,
|
||||
buildTrayMenuTemplateDeps: runtimeInput.tray.buildTrayMenuTemplateDeps,
|
||||
ensureTrayDeps: runtimeInput.tray.ensureTrayDeps,
|
||||
destroyTrayDeps: runtimeInput.tray.destroyTrayDeps,
|
||||
buildMenuFromTemplate: (template) => runtimeInput.tray!.buildMenuFromTemplate(template),
|
||||
})
|
||||
: null;
|
||||
|
||||
const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
createOverlayRuntimeBootstrapHandlers({
|
||||
initializeOverlayRuntimeMainDeps: runtimeInput.bootstrap.initializeOverlayRuntimeMainDeps,
|
||||
initializeOverlayRuntimeBootstrapDeps:
|
||||
runtimeInput.bootstrap.initializeOverlayRuntimeBootstrapDeps,
|
||||
});
|
||||
|
||||
function createMainWindow(): TWindow {
|
||||
return overlayWindowHandlers.createMainWindow();
|
||||
}
|
||||
|
||||
function createModalWindow(): TWindow {
|
||||
const existingWindow = runtimeInput.windowState.getModalWindow();
|
||||
if (existingWindow && !existingWindow.isDestroyed()) {
|
||||
return existingWindow;
|
||||
}
|
||||
const window = overlayWindowHandlers.createModalWindow();
|
||||
runtimeInput.modal.setModalWindowBounds?.(runtimeInput.geometry.getCurrentOverlayGeometry());
|
||||
return window;
|
||||
}
|
||||
|
||||
function initializeOverlayRuntime(): void {
|
||||
initializeOverlayRuntimeHandler();
|
||||
runtimeInput.bootstrap.onInitialized?.();
|
||||
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function ensureOverlayWindowsReadyForVisibilityActions(): void {
|
||||
if (!runtimeInput.runtimeState.isOverlayRuntimeInitialized()) {
|
||||
initializeOverlayRuntime();
|
||||
return;
|
||||
}
|
||||
|
||||
const mainWindow = runtimeInput.windowState.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
createMainWindow();
|
||||
}
|
||||
}
|
||||
|
||||
function setVisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (visible) {
|
||||
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
visibilityActions.setVisibleOverlayVisible(visible);
|
||||
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!runtimeInput.windowState.getVisibleOverlayVisible()) {
|
||||
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
visibilityActions.toggleVisibleOverlay();
|
||||
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (visible) {
|
||||
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
visibilityActions.setOverlayVisible(visible);
|
||||
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
return {
|
||||
createMainWindow,
|
||||
createModalWindow,
|
||||
ensureTray: () => {
|
||||
trayHandlers?.ensureTray();
|
||||
},
|
||||
destroyTray: () => {
|
||||
trayHandlers?.destroyTray();
|
||||
},
|
||||
initializeOverlayRuntime,
|
||||
ensureOverlayWindowsReadyForVisibilityActions,
|
||||
setVisibleOverlayVisible,
|
||||
toggleVisibleOverlay,
|
||||
setOverlayVisible,
|
||||
handleOverlayModalClosed: (modal) => runtimeInput.modalRuntime.handleOverlayModalClosed(modal),
|
||||
notifyOverlayModalOpened: (modal) => runtimeInput.modalRuntime.notifyOverlayModalOpened(modal),
|
||||
waitForModalOpen: (modal, timeoutMs) =>
|
||||
runtimeInput.modalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
runtimeInput.modalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
||||
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
overlayActions.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
openRuntimeOptionsPalette: () => overlayActions.openRuntimeOptionsPalette(),
|
||||
broadcastRuntimeOptionsChanged: () => overlayActions.broadcastRuntimeOptionsChanged(),
|
||||
setOverlayDebugVisualizationEnabled: (enabled) =>
|
||||
overlayActions.setOverlayDebugVisualizationEnabled(enabled),
|
||||
restorePreviousSecondarySubVisibility: () =>
|
||||
overlayActions.restorePreviousSecondarySubVisibility(),
|
||||
};
|
||||
}
|
||||
128
src/main/overlay-ui-visibility.ts
Normal file
128
src/main/overlay-ui-visibility.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
export type OverlayUiVisibilityBridgeWindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
hide?: () => void;
|
||||
show?: () => void;
|
||||
focus?: () => void;
|
||||
setIgnoreMouseEvents?: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
};
|
||||
|
||||
export interface OverlayUiVisibilityBridgeInput<
|
||||
TWindow extends OverlayUiVisibilityBridgeWindowLike = OverlayUiVisibilityBridgeWindowLike,
|
||||
> {
|
||||
getMainWindow: () => TWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getModalActive: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getWindowTracker: () => unknown;
|
||||
getTrackerNotReadyWarningShown: () => boolean;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
isMacOSPlatform: () => boolean;
|
||||
isWindowsPlatform: () => boolean;
|
||||
showOverlayLoadingOsd: (message: string) => void;
|
||||
}
|
||||
|
||||
export function createOverlayVisibilityRuntimeBridge<
|
||||
TWindow extends OverlayUiVisibilityBridgeWindowLike,
|
||||
>(input: OverlayUiVisibilityBridgeInput<TWindow>) {
|
||||
let lastOverlayLoadingOsdAtMs: number | null = null;
|
||||
|
||||
return {
|
||||
updateVisibleOverlayVisibility(): void {
|
||||
const mainWindow = input.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.getModalActive()) {
|
||||
mainWindow.hide?.();
|
||||
input.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
const showPassiveVisibleOverlay = (): void => {
|
||||
const forceMousePassthrough = input.getForceMousePassthrough() === true;
|
||||
if (input.isWindowsPlatform() || forceMousePassthrough) {
|
||||
mainWindow.setIgnoreMouseEvents?.(true, { forward: true });
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents?.(false);
|
||||
}
|
||||
input.ensureOverlayWindowLevel(mainWindow);
|
||||
mainWindow.show?.();
|
||||
if (!input.isWindowsPlatform() && !input.isMacOSPlatform() && !forceMousePassthrough) {
|
||||
mainWindow.focus?.();
|
||||
}
|
||||
};
|
||||
|
||||
const maybeShowOverlayLoadingOsd = (): void => {
|
||||
if (!input.isMacOSPlatform()) {
|
||||
return;
|
||||
}
|
||||
if (lastOverlayLoadingOsdAtMs !== null && Date.now() - lastOverlayLoadingOsdAtMs < 30_000) {
|
||||
return;
|
||||
}
|
||||
input.showOverlayLoadingOsd('Overlay loading...');
|
||||
lastOverlayLoadingOsdAtMs = Date.now();
|
||||
};
|
||||
|
||||
if (!input.getVisibleOverlayVisible()) {
|
||||
input.setTrackerNotReadyWarningShown(false);
|
||||
lastOverlayLoadingOsdAtMs = null;
|
||||
mainWindow.hide?.();
|
||||
input.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
const windowTracker = input.getWindowTracker() as {
|
||||
isTracking: () => boolean;
|
||||
getGeometry: () => WindowGeometry | null;
|
||||
} | null;
|
||||
|
||||
if (windowTracker && windowTracker.isTracking()) {
|
||||
input.setTrackerNotReadyWarningShown(false);
|
||||
const geometry = windowTracker.getGeometry();
|
||||
if (geometry) {
|
||||
input.updateVisibleOverlayBounds(geometry);
|
||||
}
|
||||
input.syncPrimaryOverlayWindowLayer('visible');
|
||||
showPassiveVisibleOverlay();
|
||||
input.enforceOverlayLayerOrder();
|
||||
input.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!windowTracker) {
|
||||
if (input.isMacOSPlatform() || input.isWindowsPlatform()) {
|
||||
if (!input.getTrackerNotReadyWarningShown()) {
|
||||
input.setTrackerNotReadyWarningShown(true);
|
||||
maybeShowOverlayLoadingOsd();
|
||||
}
|
||||
mainWindow.hide?.();
|
||||
input.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
input.setTrackerNotReadyWarningShown(false);
|
||||
input.syncPrimaryOverlayWindowLayer('visible');
|
||||
showPassiveVisibleOverlay();
|
||||
input.enforceOverlayLayerOrder();
|
||||
input.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!input.getTrackerNotReadyWarningShown()) {
|
||||
input.setTrackerNotReadyWarningShown(true);
|
||||
maybeShowOverlayLoadingOsd();
|
||||
}
|
||||
|
||||
mainWindow.hide?.();
|
||||
input.syncOverlayShortcuts();
|
||||
},
|
||||
};
|
||||
}
|
||||
41
src/main/runtime-option-helpers.ts
Normal file
41
src/main/runtime-option-helpers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ResolvedConfig } from '../types';
|
||||
|
||||
export function getRuntimeBooleanOption(
|
||||
getOptionValue: (
|
||||
id:
|
||||
| 'subtitle.annotation.nPlusOne'
|
||||
| 'subtitle.annotation.jlpt'
|
||||
| 'subtitle.annotation.frequency',
|
||||
) => unknown,
|
||||
id: 'subtitle.annotation.nPlusOne' | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency',
|
||||
fallback: boolean,
|
||||
): boolean {
|
||||
const value = getOptionValue(id);
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
export function shouldInitializeMecabForAnnotations(input: {
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getRuntimeBooleanOption: (
|
||||
id:
|
||||
| 'subtitle.annotation.nPlusOne'
|
||||
| 'subtitle.annotation.jlpt'
|
||||
| 'subtitle.annotation.frequency',
|
||||
fallback: boolean,
|
||||
) => boolean;
|
||||
}): boolean {
|
||||
const config = input.getResolvedConfig();
|
||||
const nPlusOneEnabled = input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
config.ankiConnect.knownWords.highlightEnabled,
|
||||
);
|
||||
const jlptEnabled = input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.jlpt',
|
||||
config.subtitleStyle.enableJlpt,
|
||||
);
|
||||
const frequencyEnabled = input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.frequency',
|
||||
config.subtitleStyle.frequencyDictionary.enabled,
|
||||
);
|
||||
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAutoplayReadyGate } from './autoplay-ready-gate';
|
||||
|
||||
test('autoplay ready gate suppresses duplicate media signals for the same media', async () => {
|
||||
test('autoplay ready gate suppresses duplicate media signals unless forced while paused', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
|
||||
@@ -31,6 +31,7 @@ test('autoplay ready gate suppresses duplicate media signals for the same media'
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const firstScheduled = scheduled.shift();
|
||||
@@ -95,49 +96,3 @@ test('autoplay ready gate retry loop does not re-signal plugin readiness', async
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let playbackPaused = true;
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => playbackPaused,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => playbackPaused,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) {
|
||||
playbackPaused = false;
|
||||
}
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
playbackPaused = true;
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕その2', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
commands.filter(
|
||||
(command) =>
|
||||
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,6 +44,8 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
deps.getCurrentVideoPath()?.trim() ||
|
||||
'__unknown__';
|
||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||
const allowDuplicateWhilePaused =
|
||||
options?.forceWhilePaused === true && deps.getPlaybackPaused() !== false;
|
||||
const releaseRetryDelayMs = 200;
|
||||
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
||||
forceWhilePaused: options?.forceWhilePaused === true,
|
||||
@@ -102,13 +104,19 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
})();
|
||||
};
|
||||
|
||||
if (duplicateMediaSignal) {
|
||||
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!duplicateMediaSignal) {
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
deps.signalPluginAutoplayReady();
|
||||
attemptRelease(playbackGeneration, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
deps.signalPluginAutoplayReady();
|
||||
attemptRelease(playbackGeneration, 0);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { createDiscordPresenceService } from '../../core/services/discord-presence';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import { createDiscordRpcClient } from './discord-rpc-client.js';
|
||||
|
||||
type DiscordPresenceServiceLike = {
|
||||
publish: (snapshot: {
|
||||
mediaTitle: string | null;
|
||||
@@ -72,3 +76,64 @@ export function createDiscordPresenceRuntime(deps: DiscordPresenceRuntimeDeps) {
|
||||
publishDiscordPresence,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDiscordPresenceRuntimeFromMainState(input: {
|
||||
appId: string;
|
||||
appState: {
|
||||
discordPresenceService: ReturnType<typeof createDiscordPresenceService> | null;
|
||||
mpvClient: MpvClientLike | null;
|
||||
currentMediaTitle: string | null;
|
||||
currentMediaPath: string | null;
|
||||
currentSubText: string;
|
||||
playbackPaused: boolean | null;
|
||||
};
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getFallbackMediaDurationSec: () => number | null;
|
||||
logger: {
|
||||
debug: (message: string, meta?: unknown) => void;
|
||||
};
|
||||
}) {
|
||||
const sessionStartedAtMs = Date.now();
|
||||
let mediaDurationSec: number | null = null;
|
||||
const stopCurrentDiscordPresenceService = async (): Promise<void> => {
|
||||
await input.appState.discordPresenceService?.stop?.();
|
||||
input.appState.discordPresenceService = null;
|
||||
};
|
||||
|
||||
const discordPresenceRuntime = createDiscordPresenceRuntime({
|
||||
getDiscordPresenceService: () => input.appState.discordPresenceService,
|
||||
isDiscordPresenceEnabled: () => input.getResolvedConfig().discordPresence.enabled === true,
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
|
||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
||||
getCurrentSubtitleText: () => input.appState.currentSubText,
|
||||
getPlaybackPaused: () => input.appState.playbackPaused,
|
||||
getFallbackMediaDurationSec: () => input.getFallbackMediaDurationSec(),
|
||||
getSessionStartedAtMs: () => sessionStartedAtMs,
|
||||
getMediaDurationSec: () => mediaDurationSec,
|
||||
setMediaDurationSec: (next) => {
|
||||
mediaDurationSec = next;
|
||||
},
|
||||
});
|
||||
|
||||
const initializeDiscordPresenceService = async (): Promise<void> => {
|
||||
if (input.getResolvedConfig().discordPresence.enabled !== true) {
|
||||
await stopCurrentDiscordPresenceService();
|
||||
return;
|
||||
}
|
||||
|
||||
await stopCurrentDiscordPresenceService();
|
||||
input.appState.discordPresenceService = createDiscordPresenceService({
|
||||
config: input.getResolvedConfig().discordPresence,
|
||||
createClient: () => createDiscordRpcClient(input.appId),
|
||||
logDebug: (message, meta) => input.logger.debug(message, meta),
|
||||
});
|
||||
await input.appState.discordPresenceService.start();
|
||||
discordPresenceRuntime.publishDiscordPresence();
|
||||
};
|
||||
|
||||
return {
|
||||
discordPresenceRuntime,
|
||||
initializeDiscordPresenceService,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ test('setup service auto-completes legacy installs with config and dictionaries'
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 2,
|
||||
detectPluginInstalled: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -106,18 +106,17 @@ test('setup service auto-completes legacy installs with config and dictionaries'
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service requires mpv plugin install before finish', async () => {
|
||||
test('setup service requires explicit finish for incomplete installs and supports plugin skip/install', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
let dictionaryCount = 0;
|
||||
let pluginInstalled = false;
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => dictionaryCount,
|
||||
detectPluginInstalled: () => pluginInstalled,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -131,11 +130,13 @@ test('setup service requires mpv plugin install before finish', async () => {
|
||||
assert.equal(initial.state.status, 'incomplete');
|
||||
assert.equal(initial.canFinish, false);
|
||||
|
||||
const skipped = await service.skipPluginInstall();
|
||||
assert.equal(skipped.state.pluginInstallStatus, 'skipped');
|
||||
|
||||
const installed = await service.installMpvPlugin();
|
||||
assert.equal(installed.state.pluginInstallStatus, 'installed');
|
||||
assert.equal(installed.pluginInstallPathSummary, '/tmp/mpv');
|
||||
|
||||
pluginInstalled = true;
|
||||
dictionaryCount = 1;
|
||||
const refreshed = await service.refreshStatus();
|
||||
assert.equal(refreshed.canFinish, true);
|
||||
@@ -157,7 +158,7 @@ test('setup service allows completion without internal dictionaries when externa
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -189,7 +190,7 @@ test('setup service does not probe internal dictionaries when external yomitan i
|
||||
throw new Error('should not probe internal dictionaries in external mode');
|
||||
},
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -217,7 +218,7 @@ test('setup service reopens when external-yomitan completion later has no extern
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -234,7 +235,7 @@ test('setup service reopens when external-yomitan completion later has no extern
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
isExternalYomitanConfigured: () => false,
|
||||
detectPluginInstalled: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -251,48 +252,6 @@ test('setup service reopens when external-yomitan completion later has no extern
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service reopens when a completed setup no longer has the mpv plugin installed', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
const completedService = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 2,
|
||||
detectPluginInstalled: () => true,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
message: 'ok',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
await completedService.ensureSetupStateInitialized();
|
||||
await completedService.markSetupCompleted();
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 2,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
message: 'ok',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const snapshot = await service.ensureSetupStateInitialized();
|
||||
assert.equal(snapshot.state.status, 'incomplete');
|
||||
assert.equal(snapshot.canFinish, false);
|
||||
assert.equal(snapshot.pluginStatus, 'required');
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
@@ -303,7 +262,7 @@ test('setup service keeps completed when external-yomitan completion later has i
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -320,7 +279,7 @@ test('setup service keeps completed when external-yomitan completion later has i
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 2,
|
||||
isExternalYomitanConfigured: () => false,
|
||||
detectPluginInstalled: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -345,7 +304,7 @@ test('setup service marks cancelled when popup closes before completion', async
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
detectPluginInstalled: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -372,7 +331,7 @@ test('setup service reflects detected Windows mpv shortcuts before preferences a
|
||||
platform: 'win32',
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
detectPluginInstalled: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -405,7 +364,7 @@ test('setup service persists Windows mpv shortcut preferences and status with on
|
||||
platform: 'win32',
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
detectPluginInstalled: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface SetupStatusSnapshot {
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
externalYomitanConfigured: boolean;
|
||||
pluginStatus: 'installed' | 'required' | 'failed';
|
||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||
pluginInstallPathSummary: string | null;
|
||||
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
|
||||
message: string | null;
|
||||
@@ -48,6 +48,7 @@ export interface FirstRunSetupService {
|
||||
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
|
||||
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
|
||||
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
|
||||
skipPluginInstall: () => Promise<SetupStatusSnapshot>;
|
||||
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
|
||||
configureWindowsMpvShortcuts: (preferences: {
|
||||
startMenuEnabled: boolean;
|
||||
@@ -107,8 +108,9 @@ function getPluginStatus(
|
||||
pluginInstalled: boolean,
|
||||
): SetupStatusSnapshot['pluginStatus'] {
|
||||
if (pluginInstalled) return 'installed';
|
||||
if (state.pluginInstallStatus === 'skipped') return 'skipped';
|
||||
if (state.pluginInstallStatus === 'failed') return 'failed';
|
||||
return 'required';
|
||||
return 'optional';
|
||||
}
|
||||
|
||||
function getWindowsMpvShortcutStatus(
|
||||
@@ -149,24 +151,6 @@ function isYomitanSetupSatisfied(options: {
|
||||
return options.externalYomitanConfigured || options.dictionaryCount >= 1;
|
||||
}
|
||||
|
||||
export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
externalYomitanConfigured: boolean;
|
||||
pluginStatus: SetupStatusSnapshot['pluginStatus'];
|
||||
}): string | null {
|
||||
if (!snapshot.configReady) {
|
||||
return 'Create or provide the config file before finishing setup.';
|
||||
}
|
||||
if (snapshot.pluginStatus !== 'installed') {
|
||||
return 'Install the mpv plugin before finishing setup.';
|
||||
}
|
||||
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
|
||||
return 'Install at least one Yomitan dictionary before finishing setup.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveYomitanSetupStatus(deps: {
|
||||
configFilePaths: { jsoncPath: string; jsonPath: string };
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
@@ -246,13 +230,11 @@ export function createFirstRunSetupService(deps: {
|
||||
return {
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
canFinish:
|
||||
pluginInstalled &&
|
||||
isYomitanSetupSatisfied({
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
}),
|
||||
canFinish: isYomitanSetupSatisfied({
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
}),
|
||||
externalYomitanConfigured,
|
||||
pluginStatus: getPluginStatus(state, pluginInstalled),
|
||||
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
||||
@@ -290,20 +272,24 @@ export function createFirstRunSetupService(deps: {
|
||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const pluginInstalled = await deps.detectPluginInstalled();
|
||||
const canFinish =
|
||||
pluginInstalled &&
|
||||
isYomitanSetupSatisfied({
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
});
|
||||
if (isSetupCompleted(state) && canFinish) {
|
||||
const yomitanSetupSatisfied = isYomitanSetupSatisfied({
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
});
|
||||
if (
|
||||
isSetupCompleted(state) &&
|
||||
!(
|
||||
state.yomitanSetupMode === 'external' &&
|
||||
!externalYomitanConfigured &&
|
||||
!yomitanSetupSatisfied
|
||||
)
|
||||
) {
|
||||
completed = true;
|
||||
return refreshWithState(state);
|
||||
}
|
||||
|
||||
if (canFinish) {
|
||||
if (yomitanSetupSatisfied) {
|
||||
const completedState = writeState({
|
||||
...state,
|
||||
status: 'completed',
|
||||
@@ -361,6 +347,8 @@ export function createFirstRunSetupService(deps: {
|
||||
}),
|
||||
);
|
||||
},
|
||||
skipPluginInstall: async () =>
|
||||
refreshWithState(writeState({ ...readState(), pluginInstallStatus: 'skipped' })),
|
||||
installMpvPlugin: async () => {
|
||||
const result = await deps.installPlugin();
|
||||
return refreshWithState(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user