Compare commits

..

25 Commits

Author SHA1 Message Date
efcacded66 Factor out mock date helper in tracker tests
- reuse a shared `withMockDate` helper for date-sensitive query tests
- make monthly rollup assertions key off `videoId` instead of row order
2026-03-28 00:26:19 -07:00
615625d215 fix: use explicit super args for MockDate constructors 2026-03-28 00:09:25 -07:00
90a9147363 fix: remove strict spread usage in Date mocks 2026-03-28 00:08:13 -07:00
8f6877db12 fix: resolve CI type failures in boot and immersion query tests 2026-03-28 00:07:11 -07:00
8e5cb5f885 Fix verification and immersion-tracker grouping
- isolate verifier artifacts and lease handling
- switch weekly/monthly tracker cutoffs to calendar boundaries
- tighten boot lifecycle and zip writer tests
2026-03-28 00:01:17 -07:00
1408ad652a fix(ci): normalize Windows shortcut paths for cross-platform tests 2026-03-28 00:00:49 -07:00
d5b746bd1d Restrict docs analytics and build coverage input
- limit Plausible init to docs.subminer.moe
- build Yomitan before src coverage lane
2026-03-27 23:52:07 -07:00
6139d14cd1 Switch plausible endpoint and harden coverage lane parsing
- update docs-site tracking to use the Plausible capture endpoint
- tighten coverage lane argument and LCOV parsing checks
- make script entrypoint use CommonJS main guard
2026-03-27 23:41:23 -07:00
9caf25bedb test: stabilize bun coverage reporting 2026-03-27 23:37:29 -07:00
23b2360ac4 refactor: split main boot phases 2026-03-27 23:37:29 -07:00
742a0dabe5 chore(backlog): update task notes and changelog fragment 2026-03-27 23:37:29 -07:00
4c03e34caf refactor(main): extract remaining inline runtime logic from main 2026-03-27 23:37:29 -07:00
4d95de51a0 fix: address CodeRabbit review feedback 2026-03-27 03:05:32 -07:00
ed32f985c6 test: increase launcher test timeout for CI stability 2026-03-27 02:24:56 -07:00
854179b9c1 Add backlog tasks and launcher time helper tests
- Track follow-up cleanup work in Backlog.md
- Replace Date.now usage with shared nowMs helper
- Add launcher args/parser and core regression tests
2026-03-27 02:01:36 -07:00
a3ddfa0641 refactor: compose startup and setup window wiring 2026-03-27 01:14:58 -07:00
49a582b4fc refactor: migrate shared type imports 2026-03-27 00:33:52 -07:00
a92631bf52 chore: update backlog task records 2026-03-27 00:33:48 -07:00
ac857e932e refactor: split immersion tracker query modules 2026-03-27 00:33:43 -07:00
8c633f7e48 fix: add stats server node fallback 2026-03-27 00:28:05 -07:00
d2cfa1b871 feat: add repo-local subminer workflow plugin 2026-03-27 00:27:13 -07:00
3fe63a6afa refactor: use bun serve for stats server 2026-03-26 23:18:43 -07:00
5dd8bb7fbf refactor: split shared type entrypoints 2026-03-26 23:17:04 -07:00
5b06579e65 refactor: split character dictionary runtime modules 2026-03-26 23:16:47 -07:00
416942ff2d chore(backlog): add mining workflow milestone and tasks 2026-03-26 22:32:11 -07:00
104 changed files with 1944 additions and 2570 deletions

1
.gitignore vendored
View File

@@ -9,7 +9,6 @@ out/
dist/
release/
build/yomitan/
coverage/
# Launcher build artifact (produced by make build-launcher)
/subminer

View File

@@ -20,7 +20,7 @@ Priority keys:
| ID | Pri | Status | Area | Title |
| ------ | --- | ------ | -------------- | --------------------------------------------------- |
| SM-013 | P1 | done | review-followup | Address PR #36 CodeRabbit action items |
| SM-013 | P1 | doing | review-followup | Address PR #36 CodeRabbit action items |
## Ready
@@ -241,7 +241,7 @@ Done:
Title: Address PR #36 CodeRabbit action items
Priority: P1
Status: done
Status: doing
Scope:
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
@@ -251,16 +251,7 @@ Scope:
- `src/core/services/immersion-tracker/maintenance.ts`
- `src/main/boot/services.ts`
- `src/main/character-dictionary-runtime/zip.test.ts`
Acceptance:
Acceptance:
- fix valid open CodeRabbit findings on PR #36
- add focused regression coverage for behavior changes where practical
- verify touched tests plus typecheck stay green
Done:
- hardened `--artifact-dir` validation in the verification script
- fixed trend aggregation rounding and monthly ratio bucketing
- preserved unwatched anime episodes in episode queries
- restored seconds-based aggregate timestamps in shared maintenance
- fixed the startup refactor compile break by making the predicates local at the call site
- verified with `bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts` and `bun run typecheck`

View File

@@ -1,26 +1,5 @@
# Changelog
## v0.10.0 (2026-03-29)
### Changed
- Integrations: Replaced the deprecated Discord Rich Presence wrapper with the maintained `@xhayper/discord-rpc` package.
### Fixed
- Stats: Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
- Stats: Stats server now falls back to a Node `http` listener in Electron/runtime paths that do not expose Bun.
- Overlay: Fixed the macOS visible-overlay toggle path so manual hides stay hidden and the plugin uses the explicit visible-overlay toggle command.
- Subtitle Sidebar: Restored macOS mpv passthrough while the overlay subtitle sidebar is open so clicks outside the sidebar can refocus mpv and keep native keybindings working.
### Internal
- Release: Added a maintained source coverage lane that shards Bun coverage one test file at a time and merges LCOV output into `coverage/test-src/lcov.info`.
- Release: CI and release quality-gate now upload the merged source-lane LCOV artifact for inspection.
- Runtime: Extracted remaining inline runtime logic from `src/main.ts` into dedicated runtime modules and composer helpers.
- Runtime: Added focused regression tests for the extracted runtime/composer boundaries.
- Runtime: Updated task tracking notes to mark TASK-238.6 complete and confirm follow-on boot-phase split can be deferred.
- Runtime: Split `src/main.ts` boot wiring into dedicated `src/main/boot/services.ts`, `src/main/boot/runtimes.ts`, and `src/main/boot/handlers.ts` modules.
- Runtime: Added focused tests for the new boot-phase seams and kept the startup/typecheck/build verification lanes green.
- Runtime: Updated internal architecture/task docs to record the boot-phase split and new ownership boundary.
## v0.9.3 (2026-03-25)
### Changed

View File

@@ -1,35 +0,0 @@
---
id: TASK-243
title: 'Assess and address PR #36 latest CodeRabbit review round'
status: Done
assignee: []
created_date: '2026-03-29 07:39'
updated_date: '2026-03-29 07:41'
labels:
- code-review
- pr-36
dependencies: []
references:
- 'https://github.com/ksyasuda/SubMiner/pull/36'
priority: high
ordinal: 3600
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Inspect the latest CodeRabbit review round on PR #36, verify each actionable comment against the current branch, implement the confirmed fixes, and verify the touched paths.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Confirmed review comments are implemented or explicitly deferred with rationale.
- [ ] #2 Touched paths are verified with the smallest sufficient test/build lane.
- [ ] #3 Current PR feedback is reduced to resolved or intentionally deferred suggestions.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Addressed the confirmed latest CodeRabbit review items on PR #36. `scripts/run-coverage-lane.ts` now uses the Bun-style `import.meta.main` entrypoint check with a local ts-ignore to preserve the repo's CommonJS typecheck settings. `src/core/services/immersion-tracker/maintenance.ts` no longer shadows the imported `nowMs` helper in retention functions. `src/main.ts` now centralizes the startup-mode predicates behind a shared helper and releases `resolvedSource.cleanup` on the cached-subtitle fast path so materialized sources do not leak.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,35 +0,0 @@
---
id: TASK-244
title: 'Assess and address PR #36 latest CodeRabbit review round 2'
status: Done
assignee: []
created_date: '2026-03-29 08:09'
updated_date: '2026-03-29 08:10'
labels:
- code-review
- pr-36
dependencies: []
references:
- 'https://github.com/ksyasuda/SubMiner/pull/36'
priority: high
ordinal: 3610
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Inspect the newest CodeRabbit review round on PR #36, verify the actionable comment against the current branch, implement the confirmed fix, and verify the touched path.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 The actionable review comment is implemented or explicitly deferred with rationale.
- [ ] #2 Touched path is verified with the smallest sufficient test lane.
- [ ] #3 Current PR feedback is reduced to resolved or intentionally deferred suggestions.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Addressed the actionable latest CodeRabbit comment on PR #36. `src/core/services/immersion-tracker/maintenance.ts` now skips retention deletions when a window is disabled with `Infinity`, so `toDbMs(...)` is only called for finite retention values. Added a regression test in `maintenance.test.ts` that verifies disabled retention windows preserve session events, telemetry, and sessions while returning zero deletions.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,35 +0,0 @@
---
id: TASK-242
title: Fix stats server Bun fallback in coverage lane
status: Done
assignee: []
created_date: '2026-03-29 07:31'
updated_date: '2026-03-29 07:37'
labels:
- ci
- bug
milestone: cleanup
dependencies: []
references:
- 'PR #36'
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Coverage CI fails when `startStatsServer` reaches the Bun server seam under the maintained source lane. Add a runtime fallback that works when `Bun.serve` is unavailable and keep the stats-server startup path testable.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `bun run test:coverage:src` passes in GitHub CI
- [x] #2 `startStatsServer` uses `Bun.serve` when present and a Node server fallback otherwise
- [x] #3 Regression coverage exists for the fallback startup path
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed the CI failure in the coverage lane by replacing the Bun-only stats server path with a Bun-or-node/http startup fallback and by normalizing setup window options so undefined BrowserWindow fields are omitted. Verified the exact coverage lane under Bun 1.3.5 and confirmed the GitHub Actions run for PR #36 completed successfully.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,68 +0,0 @@
---
id: TASK-245
title: Cut minor release v0.10.0 for docs and release prep
status: Done
assignee:
- '@codex'
created_date: '2026-03-29 08:10'
updated_date: '2026-03-29 08:13'
labels:
- release
- docs
- minor
dependencies: []
references:
- /home/sudacode/projects/japanese/SubMiner/package.json
- /home/sudacode/projects/japanese/SubMiner/README.md
- /home/sudacode/projects/japanese/SubMiner/docs/RELEASING.md
- /home/sudacode/projects/japanese/SubMiner/docs/README.md
- /home/sudacode/projects/japanese/SubMiner/docs-site/changelog.md
- /home/sudacode/projects/japanese/SubMiner/CHANGELOG.md
- /home/sudacode/projects/japanese/SubMiner/release/release-notes.md
priority: high
ordinal: 54850
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Prepare the next 0-ver minor release cut as `v0.10.0`, keeping release-facing docs, backlog, and changelog artifacts aligned, then run the release-prep verification gate.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Repository version metadata is updated to `0.10.0`.
- [x] #2 Release-facing docs and public changelog surfaces are aligned for the `v0.10.0` cut.
- [x] #3 `CHANGELOG.md` and `release/release-notes.md` contain the committed `v0.10.0` section and any consumed fragments are removed.
- [x] #4 Release-prep verification passes for changelog, config example, typecheck, tests, and build.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed:
- Bumped `package.json` from `0.9.3` to `0.10.0`.
- Ran `bun run changelog:build --version 0.10.0 --date 2026-03-29`, which generated `CHANGELOG.md` and `release/release-notes.md` and removed the queued `changes/*.md` fragments.
- Updated `docs-site/changelog.md` with the public-facing `v0.10.0` summary.
Verification:
- `bun run changelog:lint`
- `bun run changelog:check --version 0.10.0`
- `bun run verify:config-example`
- `bun run typecheck`
- `bunx bun@1.3.5 run test:fast`
- `bunx bun@1.3.5 run test:env`
- `bunx bun@1.3.5 run build`
- `bunx bun@1.3.5 run docs:test`
- `bunx bun@1.3.5 run docs:build`
Notes:
- The local `bun` binary is `1.3.11`, which tripped Bun's nested `node:test` handling in `test:fast`; rerunning with the repo-pinned `bun@1.3.5` cleared the issue.
- No README content change was necessary for this cut.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Prepared the `v0.10.0` release cut locally. Bumped `package.json`, generated committed root changelog and release notes, updated the public docs changelog summary, and verified the release gate with the repo-pinned Bun `1.3.5` runtime. The release prep is green and ready for tagging/publishing when desired.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,55 +0,0 @@
---
id: TASK-246
title: Migrate Discord Rich Presence to maintained RPC wrapper
status: Done
assignee: []
created_date: '2026-03-29 08:17'
updated_date: '2026-03-29 08:22'
labels:
- dependency
- discord
- presence
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the deprecated Discord Rich Presence wrapper with a maintained JavaScript alternative while preserving the current IPC-based presence behavior in the Electron main process.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The app no longer depends on `discord-rpc`
- [x] #2 Discord Rich Presence still logs in and publishes activity updates from the main process
- [x] #3 Existing Discord presence tests continue to pass or are updated to cover the new client API
- [x] #4 The change is documented in the release notes or changelog fragment
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed:
- Swapped the app's Discord RPC dependency from `discord-rpc` to `@xhayper/discord-rpc`.
- Extracted the client adapter into `src/main/runtime/discord-rpc-client.ts` so the main process can keep using a small wrapper around the maintained library.
- Added `src/main/runtime/discord-rpc-client.test.ts` to verify the adapter forwards login/activity/clear/destroy calls through `client.user`.
- Documented the dependency swap in `CHANGELOG.md`, `release/release-notes.md`, and `docs-site/changelog.md`.
Verification:
- `bunx bun@1.3.5 test src/main/runtime/discord-rpc-client.test.ts src/core/services/discord-presence.test.ts`
- `bunx bun@1.3.5 run changelog:lint`
- `bunx bun@1.3.5 run changelog:check --version 0.10.0`
- `bunx bun@1.3.5 run docs:test`
- `bunx bun@1.3.5 run docs:build`
Notes:
- The existing release prep artifacts for v0.10.0 were kept intact and updated in place.
- No README change was needed for this dependency swap.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Replaced the deprecated `discord-rpc` dependency with the maintained `@xhayper/discord-rpc` wrapper while preserving the main-process rich presence flow. Added a focused runtime wrapper test, kept the existing Discord presence service tests green, and documented the dependency swap in the release notes and changelog.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,60 +0,0 @@
---
id: TASK-247
title: Strip inline subtitle markup from subtitle sidebar cues
status: Done
assignee:
- codex
created_date: '2026-03-29 10:01'
updated_date: '2026-03-29 10:10'
labels: []
dependencies: []
references:
- src/core/services/subtitle-cue-parser.ts
- src/renderer/modals/subtitle-sidebar.ts
- src/core/services/subtitle-cue-parser.test.ts
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Subtitle sidebar should display readable subtitle text when loaded subtitle files include inline markup such as HTML-like font tags. Parsed cue text currently preserves markup, causing raw tags to appear in the sidebar instead of clean subtitle content.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Subtitle sidebar cue text omits inline subtitle markup such as HTML-like font tags while preserving visible subtitle content.
- [x] #2 Parsed subtitle cues used by the sidebar keep timing order and expected line-break behavior after markup sanitization.
- [x] #3 Regression tests cover markup-bearing subtitle cue parsing so raw tags do not reappear in the sidebar.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add regression tests in src/core/services/subtitle-cue-parser.test.ts for subtitle cues containing HTML-like font tags, including multi-line content.
2. Verify the new parser test fails against current behavior to confirm the bug is covered.
3. Update src/core/services/subtitle-cue-parser.ts to sanitize inline subtitle markup while preserving visible text and expected newline handling.
4. Re-run focused parser tests, then run broader verification commands required for handoff as practical.
5. Update task notes/acceptance criteria based on verified results and finalize the task record.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
User approved implementation on 2026-03-29.
Implemented parser-level subtitle cue sanitization for HTML-like tags so loaded sidebar cues render readable text while preserving cue line breaks.
Added regression coverage for SRT and ASS cue parsing with <font ...> markup.
Verification: bun test src/core/services/subtitle-cue-parser.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; bun run test:smoke:dist.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Sanitized parsed subtitle cue text in src/core/services/subtitle-cue-parser.ts so HTML-like inline markup such as <font ...> is removed before cues reach the subtitle sidebar. The sanitizer is shared across SRT/VTT-style parsing and ASS parsing, while existing cue timing and line-break semantics remain intact.
Added regression tests in src/core/services/subtitle-cue-parser.test.ts covering markup-bearing SRT lines and ASS dialogue lines with \N breaks, and verified the original failure before implementing the fix.
Tests run: bun test src/core/services/subtitle-cue-parser.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; bun run test:smoke:dist.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,69 +0,0 @@
---
id: TASK-248
title: Fix macOS visible overlay toggle getting immediately restored
status: Done
assignee: []
created_date: '2026-03-29 10:03'
updated_date: '2026-03-29 22:14'
labels: []
dependencies: []
references:
- /Users/sudacode/projects/japanese/SubMiner/plugin/subminer/process.lua
- /Users/sudacode/projects/japanese/SubMiner/plugin/subminer/ui.lua
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/cli-command.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/overlay-visibility-runtime.ts
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix the visible overlay toggle path on macOS so the user can reliably hide the overlay after it has been shown. The current behavior can ignore the toggle or hide the overlay briefly before it is restored immediately.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Pressing the visible-overlay toggle hides the overlay when it is currently shown on macOS.
- [x] #2 A manual hide is not immediately undone by startup or readiness flows.
- [x] #3 The mpv/plugin toggle path matches the intended visible-overlay toggle behavior.
- [x] #4 Regression tests cover the failing toggle path.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce the toggle/re-show logic from code paths around mpv plugin control commands and auto-play readiness.
2. Add regression coverage for manual toggle-off staying hidden through readiness completion.
3. Patch the plugin/control path so manual visible-overlay toggles are not undone by readiness auto-show.
4. Run targeted tests, then the relevant verification lane.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: the mpv plugin readiness callback (`subminer-autoplay-ready`) could re-issue `--show-visible-overlay` after a manual toggle/hide. Initial fix only suppressed the next readiness restore, but repeated readiness callbacks in the same media session could still re-show the overlay. The plugin toggle path also still used legacy `--toggle` instead of the explicit visible-overlay command.
Implemented a session-scoped suppression flag in the Lua plugin so a manual hide/toggle during the pause-until-ready window blocks readiness auto-show for the rest of the current auto-start session, then resets on the next auto-start session.
Added Lua regression coverage for both behaviors: manual toggle-off stays hidden through readiness completion, repeated readiness callbacks in the same session stay suppressed, and `subminer-toggle` emits `--toggle-visible-overlay` rather than legacy `--toggle`.
Follow-up investigation found a second issue in `src/core/services/cli-command.ts`: pure visible-overlay toggle commands still ran the MPV connect/start path (`connectMpvClient`) because `--toggle` and `--toggle-visible-overlay` were classified as start-like commands. That side effect could retrigger startup visibility work even after the plugin-side fix.
Updated CLI command handling so only `--start` reconnects MPV. Pure toggle/show/hide overlay commands still initialize overlay runtime when needed, but they no longer restart/reconnect the MPV control path.
Renderer/modal follow-ups: restored focused-overlay mpv y-chord proxy in `src/renderer/handlers/keyboard.ts`, added a modal-close guard in `src/main/overlay-runtime.ts` so modal teardown does not re-show a manually hidden overlay, and added a duplicate-toggle debounce in `src/main/runtime/overlay-visibility-actions.ts` to ignore near-simultaneous toggle requests inside the main process.
2026-03-29: added regression for repeated subminer-autoplay-ready signals after manual y-t hide. Root cause: Lua plugin suppression only blocked the first ready-time restore, so later ready callbacks in the same media session could re-show the visible overlay. Updated plugin suppression to remain active for the full current auto-start session and reset on the next auto-start trigger.
2026-03-29: live mpv log showed repeated `subminer-autoplay-ready` script messages from Electron during paused startup, each triggering plugin `--show-visible-overlay` and immediate re-show. Fixed `src/main/runtime/autoplay-ready-gate.ts` so plugin readiness is signaled once per media while paused retry loops only re-issue `pause=false` instead of re-signaling readiness.
2026-03-29: Added window-level guard for stray visible-overlay re-show on macOS. `src/core/services/overlay-window.ts` now immediately re-hides the visible overlay window on `show` if overlay state is false, covering native/Electron re-show paths that bypass normal visibility actions. Regression: `src/core/services/overlay-window.test.ts`. Verified with full gate and rebuilt unsigned mac bundle.
2026-03-29: added a blur-path guard for the visible overlay window. `src/core/services/overlay-window.ts` now skips topmost restacking when a visible-overlay blur fires after overlay state already flipped off, covering a macOS hide-in-flight path that could immediately reassert the window. Regression coverage added in `src/core/services/overlay-window.test.ts`; verified with targeted overlay tests, full gate, and rebuilt unsigned mac bundle.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Confirmed with user that macOS `y-t` now works. Cleaned the patch set down to the remaining justified fixes: explicit visible-overlay plugin toggle/suppression, pure-toggle CLI no longer reconnects MPV, autoplay-ready signaling only fires once per media, and the final visible-overlay blur guard that stops macOS restacking after a manual hide. Full gate passed again before commit `c939c580` (`fix: stabilize macOS visible overlay toggle`).
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,37 +0,0 @@
---
id: TASK-249
title: Fix AniList token persistence on setup login
status: Done
assignee: []
created_date: '2026-03-29 10:08'
updated_date: '2026-03-29 19:42'
labels:
- anilist
- bug
dependencies: []
documentation:
- src/main/runtime/anilist-setup.ts
- src/core/services/anilist/anilist-token-store.ts
- src/main/runtime/anilist-token-refresh.ts
- docs-site/anilist-integration.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
AniList setup can appear successful but the token is not persisted across restarts. Investigate the setup callback and token store path so the app either saves the token reliably or surfaces persistence failure instead of reopening setup on every launch.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 AniList setup login persists a usable token across app restarts when safeStorage works
- [ ] #2 If token persistence fails the setup flow reports the failure instead of pretending login succeeded
- [ ] #3 Regression coverage exists for the callback/save path and the refresh path that reopens setup when no token is available
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Pinned installed mpv plugin configs to the current SubMiner binary so standalone mpv launches reuse the same app identity that saved AniList tokens. Added startup self-heal for existing blank binary_path configs, install-time binary_path writes for fresh plugin installs, regression tests for both paths, and docs updates describing the new behavior.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,72 +0,0 @@
---
id: TASK-250
title: Restore macOS mpv passthrough while overlay subtitle sidebar is open
status: Done
assignee:
- '@codex'
created_date: '2026-03-29 10:10'
updated_date: '2026-03-29 10:23'
labels:
- bug
- macos
- subtitle-sidebar
- overlay
- mpv
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/renderer/overlay-mouse-ignore.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/handlers/keyboard.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.test.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/renderer/overlay-mouse-ignore.test.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When the overlay-layout subtitle sidebar is open on macOS, users should still be able to click through outside the sidebar and return keyboard focus to mpv so native mpv keybindings continue to work. The sidebar should stay interactive when hovered or focused, but it must not make the whole visible overlay behave like a blocking modal.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Opening the overlay-layout subtitle sidebar does not keep the entire visible overlay mouse-interactive outside sidebar hover or focus.
- [x] #2 With the subtitle sidebar open, clicking outside the sidebar can refocus mpv so native mpv keybindings continue to work.
- [x] #3 Focused regression coverage exists for overlay-layout sidebar passthrough behavior on mouse-ignore state changes.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add renderer regression coverage for overlay-layout subtitle sidebar passthrough so open-but-unhovered sidebar no longer holds global mouse interaction.
2. Update overlay mouse-ignore gating to keep the subtitle sidebar interactive only while hovered or otherwise actively interacting, instead of treating overlay layout as a blocking modal.
3. Run focused renderer tests for subtitle sidebar and mouse-ignore behavior, then update task notes/criteria with the verified outcome.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Confirmed the regression only affects the default overlay-layout subtitle sidebar: open sidebar state was treated as a blocking overlay modal, which prevented click-through outside the sidebar and stranded native mpv keybindings until focus was manually recovered.
Added a failing regression in src/renderer/modals/subtitle-sidebar.test.ts for overlay-layout passthrough before changing the gate.
Verification: bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts; bun run typecheck
User reported the first renderer-only fix did not resolve the macOS issue in practice. Reopening investigation to trace visible-overlay window focus and hit-testing outside the renderer mouse-ignore gate.
Follow-up root cause: sidebar hover handlers were attached to the full-screen `.subtitle-sidebar-modal` shell instead of the actual sidebar panel. On the transparent visible overlay that shell spans the viewport, so sidebar-active state could persist outside the panel and keep the overlay interactive longer than intended.
Updated the sidebar modal to track hover/focus on `subtitleSidebarContent` and derive sidebar interaction state from panel hover or focus-within before recomputing mouse passthrough.
Verification refresh: bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts; bun run typecheck
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Restored overlay subtitle sidebar passthrough in two layers. First, the visible overlay mouse-ignore gate no longer treats the subtitle sidebar as a global blocking modal. Second, the sidebar panel now tracks interaction on the real sidebar content instead of the full-screen modal shell, and keeps itself active only while the panel is hovered or focused. Added regressions for overlay-layout passthrough and focus-within behavior. Verification: `bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts` and `bun run typecheck`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,32 +0,0 @@
---
id: TASK-251
title: 'Docs: add subtitle sidebar and Jimaku integration pages'
status: Done
assignee: []
created_date: '2026-03-29 22:36'
updated_date: '2026-03-29 22:38'
labels:
- docs
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Track the docs-site update that adds a dedicated subtitle sidebar page, links Jimaku integration from the homepage/config docs, and refreshes the docs-site theme styling used by those pages.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 docs-site nav includes a Subtitle Sidebar entry
- [x] #2 Subtitle Sidebar page documents layout, shortcut, and config options
- [x] #3 Jimaku integration page and configuration docs link to the new docs page
- [x] #4 Changelog fragment exists for the user-visible docs release note
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added the subtitle sidebar docs page and nav entry, linked Jimaku integration from the homepage/config docs, refreshed docs-site styling tokens, and recorded the release note fragment. Verified with `bun run changelog:lint`, `bun run docs:test`, `bun run docs:build`, and `bun run build`. Full repo test gate still has pre-existing failures in `bun run test:fast` and `bun run test:env` unrelated to these docs changes.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -7,9 +7,9 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@xhayper/discord-rpc": "^1.3.3",
"axios": "^1.13.5",
"commander": "^14.0.3",
"discord-rpc": "^4.0.1",
"hono": "^4.12.7",
"jsonc-parser": "^3.3.1",
"libsql": "^0.5.22",
@@ -37,12 +37,6 @@
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.24.1" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="],
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
@@ -149,10 +143,6 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="],
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
@@ -181,10 +171,6 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
@@ -223,6 +209,8 @@
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
@@ -305,7 +293,7 @@
"dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
"discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
"discord-rpc": ["discord-rpc@4.0.1", "", { "dependencies": { "node-fetch": "^2.6.1", "ws": "^7.3.1" }, "optionalDependencies": { "register-scheme": "github:devsnek/node-register-scheme" } }, "sha512-HOvHpbq5STRZJjQIBzwoKnQ0jHplbEWFWlPDwXXKm/bILh4nzjcg7mNqll0UY7RsjFoaXA7e/oYb/4lvpda2zA=="],
"dmg-builder": ["dmg-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-DaWI+p4DOqiFVZFMovdGYammBOyJAiHHFWUTQ0Z7gNc0twfdIN0LvyJ+vFsgZEDR1fjgbpCj690IVtbYIsZObQ=="],
@@ -371,6 +359,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
@@ -487,8 +477,6 @@
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
"make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="],
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
@@ -535,6 +523,8 @@
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="],
"nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
@@ -597,6 +587,8 @@
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"register-scheme": ["register-scheme@github:devsnek/node-register-scheme#e7cc9a6", { "dependencies": { "bindings": "^1.3.0", "node-addon-api": "^1.3.0" } }, "devsnek-node-register-scheme-e7cc9a6", "sha512-VwUWN3aKIg/yn7T8axW20Y1+4wGALIQectBmkmwSJfLrCycpVepGP/+KHjXSL/Ga8N1SmewL49kESgIhW7HbWg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
@@ -681,16 +673,14 @@
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
"truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="],
@@ -709,6 +699,10 @@
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -775,6 +769,8 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"discord-rpc/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"electron/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],

View File

@@ -0,0 +1,5 @@
type: internal
area: release
- Added a maintained source coverage lane that shards Bun coverage one test file at a time and merges LCOV output into `coverage/test-src/lcov.info`.
- CI and release quality-gate now upload the merged source-lane LCOV artifact for inspection.

View File

@@ -0,0 +1,5 @@
type: fixed
area: stats
- Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
- Stats server now falls back to a Node `http` listener in Electron/runtime paths that do not expose Bun.

View File

@@ -0,0 +1,6 @@
type: internal
area: runtime
- Extracted remaining inline runtime logic from `src/main.ts` into dedicated runtime modules and composer helpers.
- Added focused regression tests for the extracted runtime/composer boundaries.
- Updated task tracking notes to mark TASK-238.6 complete and confirm follow-on boot-phase split can be deferred.

View File

@@ -0,0 +1,6 @@
type: internal
area: runtime
- Split `src/main.ts` boot wiring into dedicated `src/main/boot/services.ts`, `src/main/boot/runtimes.ts`, and `src/main/boot/handlers.ts` modules.
- Added focused tests for the new boot-phase seams and kept the startup/typecheck/build verification lanes green.
- Updated internal architecture/task docs to record the boot-phase split and new ownership boundary.

View File

@@ -1,6 +0,0 @@
type: docs
area: docs-site
- Added a dedicated Subtitle Sidebar guide and linked it from the homepage and configuration docs.
- Linked Jimaku integration from the homepage to its dedicated docs page.
- Refreshed docs-site theme tokens and hover/selection styling for the updated pages.

View File

@@ -1,5 +0,0 @@
type: fixed
area: main
- Resolve the YouTube playback socket path lazily so startup honors CLI and config overrides.
- Add regression coverage for the lazy socket-path lookup during Windows mpv startup.

View File

@@ -498,7 +498,6 @@
// ==========================================
"discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session.

View File

@@ -74,9 +74,7 @@ export default {
{ text: 'Configuration', link: '/configuration' },
{ text: 'Keyboard Shortcuts', link: '/shortcuts' },
{ text: 'Subtitle Annotations', link: '/subtitle-annotations' },
{ text: 'Subtitle Sidebar', link: '/subtitle-sidebar' },
{ text: 'Immersion Tracking', link: '/immersion-tracking' },
{ text: 'JLPT Vocabulary Bundle', link: '/jlpt-vocab-bundle' },
{ text: 'Troubleshooting', link: '/troubleshooting' },
],
},

View File

@@ -34,25 +34,6 @@
system-ui,
sans-serif;
--tui-transition: 180ms ease;
/* Theme-specific values — overridden in .dark below */
--tui-nav-bg: color-mix(in srgb, var(--vp-c-bg-alt) 88%, transparent);
--tui-table-hover-bg: color-mix(in srgb, var(--vp-c-bg-soft) 80%, transparent);
--tui-link-underline: color-mix(in srgb, var(--vp-c-brand-1) 40%, transparent);
--tui-selection-bg: hsla(267, 83%, 45%, 0.14);
--tui-hero-glow: hsla(267, 83%, 45%, 0.05);
--tui-step-hover-bg: var(--vp-c-bg-alt);
--tui-step-hover-glow: color-mix(in srgb, var(--vp-c-brand-1) 30%, transparent);
}
.dark {
--tui-nav-bg: hsla(232, 23%, 18%, 0.82);
--tui-table-hover-bg: hsla(232, 23%, 18%, 0.4);
--tui-link-underline: hsla(267, 83%, 80%, 0.3);
--tui-selection-bg: hsla(267, 83%, 80%, 0.22);
--tui-hero-glow: hsla(267, 83%, 80%, 0.06);
--tui-step-hover-bg: hsla(232, 23%, 18%, 0.6);
--tui-step-hover-glow: hsla(267, 83%, 80%, 0.3);
}
:root {
@@ -67,7 +48,7 @@
/* === Selection === */
::selection {
background: var(--tui-selection-bg);
background: hsla(267, 83%, 80%, 0.22);
color: var(--vp-c-text-1);
}
@@ -121,7 +102,7 @@ button,
}
.VPNav .VPNavBar:not(.has-sidebar) {
background: var(--tui-nav-bg);
background: hsla(232, 23%, 18%, 0.82);
}
.VPNav .VPNavBar.has-sidebar .content {
@@ -264,13 +245,13 @@ button,
}
.vp-doc table tr:hover td {
background: var(--tui-table-hover-bg);
background: hsla(232, 23%, 18%, 0.4);
}
/* === Links === */
.vp-doc a {
text-decoration: none;
border-bottom: 1px solid var(--tui-link-underline);
border-bottom: 1px solid hsla(267, 83%, 80%, 0.3);
transition: border-color var(--tui-transition), color var(--tui-transition);
}
@@ -672,7 +653,7 @@ body {
height: 400px;
background: radial-gradient(
ellipse at center,
var(--tui-hero-glow) 0%,
hsla(267, 83%, 80%, 0.06) 0%,
transparent 70%
);
pointer-events: none;

View File

@@ -1,12 +1,5 @@
# Changelog
## v0.10.0 (2026-03-29)
- Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
- Added a Node `http` fallback for Electron/runtime paths that do not expose Bun, so stats keeps working there too.
- Updated Discord Rich Presence to the maintained `@xhayper/discord-rpc` wrapper.
- Fixed the macOS visible-overlay toggle path so manual hides stay hidden and the plugin uses the explicit visible-overlay toggle command.
- Restored macOS mpv passthrough while the overlay subtitle sidebar is open so clicks outside the sidebar can refocus mpv and keep native keybindings working.
## v0.9.3 (2026-03-25)
- Moved YouTube primary subtitle language defaults to `youtube.primarySubLanguages`.
- Removed the placeholder YouTube subtitle retime step; downloaded primary subtitle tracks are now used directly.

View File

@@ -390,8 +390,6 @@ The sidebar is only available when the active subtitle source has been parsed in
`embedded` layout is intended to act like a split-pane view: it reserves player space with a right-side video margin and keeps interaction in both the player area and sidebar. If you see unexpected offset behavior in your environment, switch back to `overlay` to isolate sidebar placement.
For full details on layout modes, behavior, and the keyboard shortcut, see the [Subtitle Sidebar](/subtitle-sidebar) page.
`jlptColors` keys are:
| Key | Default | Description |
@@ -1199,38 +1197,30 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine
{
"discordPresence": {
"enabled": true,
"presenceStyle": "default",
"updateIntervalMs": 3000,
"debounceMs": 750
}
}
```
| Option | Values | Description |
| ------------------ | ------------------------------------------------- | ---------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) |
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
| Option | Values | Description |
| ------------------ | --------------- | ---------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
Setup steps:
1. Set `discordPresence.enabled` to `true`.
2. Optionally set `discordPresence.presenceStyle` to choose a card text preset.
3. Restart SubMiner.
2. Restart SubMiner.
#### Presence style presets
SubMiner uses a fixed official activity card style for all users:
While playing media, the **Details** line always shows the current media title and **State** shows `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss`. The preset controls what appears when idle and the tooltip text on images.
| Preset | Idle details | Small image text | Vibe |
| ------------ | ----------------------------------- | ------------------ | --------------------------------------- |
| **`default`**| `Sentence Mining` | `日本語学習中` | Clean, bilingual flair |
| `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke |
| `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese |
| `minimal` | `SubMiner` | *(none)* | Bare essentials, no small image overlay |
All presets use the `subminer-logo` large image with `SubMiner` tooltip. No activity button is shown by default.
- Details: current media title while playing (fallback: `Mining and crafting (Anki cards)` when idle/disconnected)
- State: `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss` (fallback: `Idle`)
- Large image key/text: `subminer-logo` / `SubMiner`
- Small image key/text: `study` / `Sentence Mining`
- No activity button by default
Troubleshooting:

View File

@@ -67,7 +67,7 @@ features:
alt: Subtitle download icon
title: Subtitle Download & Sync
details: Search and pull subtitles from Jimaku, then auto-sync timing with alass or ffsubsync — all from the overlay.
link: /jimaku-integration
link: /configuration#jimaku
linkText: Jimaku integration
- icon:
src: /assets/tokenization.svg
@@ -223,12 +223,12 @@ const demoAssetVersion = '20260223-2';
}
.workflow-step:hover {
background: var(--tui-step-hover-bg);
background: hsla(232, 23%, 18%, 0.6);
}
.workflow-step:hover .step-number {
color: var(--vp-c-brand-1);
text-shadow: 0 0 12px var(--tui-step-hover-glow);
text-shadow: 0 0 12px hsla(267, 83%, 80%, 0.3);
}
.workflow-connector {

View File

@@ -172,7 +172,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`, 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.
- If you use the mpv plugin, 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.
@@ -201,7 +201,6 @@ mpv must be launched with `--input-ipc-server=/tmp/subminer-socket` for SubMiner
:::
On Windows, the packaged plugin config is rewritten to `socket_path=\\.\pipe\subminer-socket`.
First-run setup also pins `binary_path` to the current app binary so mpv launches the same SubMiner build that installed the plugin.
```bash
# Option 1: install from release assets bundle

View File

@@ -131,6 +131,6 @@ Verify mpv is running and connected via IPC. SubMiner loads the subtitle by issu
## Related
- [Configuration Reference](/configuration#jimaku) — full config options
- [Configuration Reference](/configuration#jimaku) — full config section
- [Mining Workflow](/mining-workflow#jimaku-subtitle-search) — how Jimaku fits into the sentence mining loop
- [Troubleshooting](/troubleshooting#jimaku) — additional error guidance

View File

@@ -498,7 +498,6 @@
// ==========================================
"discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session.

View File

@@ -1,71 +0,0 @@
# Subtitle Sidebar
The subtitle sidebar displays the full parsed cue list for the active subtitle file as a scrollable panel alongside mpv. It lets you review past and upcoming lines, click any cue to seek directly to that moment, and follow along without depending on the transient overlay subtitles.
The sidebar is opt-in and disabled by default. Enable it under `subtitleSidebar.enabled` in your config.
## How It Works
When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open:
- The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`).
- Clicking any cue seeks mpv to that timestamp.
- The sidebar stays synchronized with the overlay — media transitions and subtitle source changes update both simultaneously.
The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar.
## Layout Modes
Two layout modes are available via `subtitleSidebar.layout`:
**`overlay`** (default) — The sidebar floats over mpv as a panel. It does not affect the player window size or position.
**`embedded`** — Reserves space on the right side of the player and shifts the video area to mimic a split-pane layout. Useful if you want the cue list visible without it covering the video. If you see unexpected positioning in your environment, switch back to `overlay` to isolate the issue.
## Configuration
Enable and configure the sidebar under `subtitleSidebar` in your config file:
```json
{
"subtitleSidebar": {
"enabled": false,
"autoOpen": false,
"layout": "overlay",
"toggleKey": "Backslash",
"pauseVideoOnHover": false,
"autoScroll": true,
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
"fontSize": 16
}
}
```
| Option | Type | Default | Description |
| --------------------------- | ------- | ------------ | -------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | `false` | Enable subtitle sidebar support |
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
| `pauseVideoOnHover` | boolean | `false` | Pause playback while hovering the cue list |
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
| `backgroundColor` | string | — | Sidebar shell background color |
| `textColor` | string | — | Default cue text color |
| `fontFamily` | string | — | CSS `font-family` applied to cue text |
| `fontSize` | number | `16` | Base cue font size in CSS pixels |
| `timestampColor` | string | — | Cue timestamp color |
| `activeLineColor` | string | — | Active cue text color |
| `activeLineBackgroundColor` | string | — | Active cue background color |
| `hoverLineBackgroundColor` | string | — | Hovered cue background color |
Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like a solid overlay.
## Keyboard Shortcut
| Key | Action | Config key |
| --- | ----------------------- | ------------------------------ |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
The toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. See [Keyboard Shortcuts](/shortcuts) for the full shortcut reference.

View File

@@ -1,6 +1,6 @@
{
"name": "subminer",
"version": "0.10.0",
"version": "0.9.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -100,9 +100,9 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@xhayper/discord-rpc": "^1.3.3",
"axios": "^1.13.5",
"commander": "^14.0.3",
"discord-rpc": "^4.0.1",
"hono": "^4.12.7",
"jsonc-parser": "^3.3.1",
"libsql": "^0.5.22",

View File

@@ -153,9 +153,6 @@ function M.create(ctx)
local function notify_auto_play_ready()
release_auto_play_ready_gate("tokenization-ready")
if state.suppress_ready_overlay_restore then
return
end
if state.overlay_running and resolve_visible_overlay_startup() then
run_control_command_async("show-visible-overlay", {
socket_path = opts.socket_path,
@@ -290,9 +287,6 @@ function M.create(ctx)
local function start_overlay(overrides)
overrides = overrides or {}
if overrides.auto_start_trigger == true then
state.suppress_ready_overlay_restore = false
end
if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
@@ -439,7 +433,6 @@ function M.create(ctx)
subminer_log("error", "binary", "SubMiner binary not found")
return
end
state.suppress_ready_overlay_restore = true
run_control_command_async("hide-visible-overlay", nil, function(ok, result)
if ok then
@@ -463,9 +456,8 @@ function M.create(ctx)
show_osd("Error: binary not found")
return
end
state.suppress_ready_overlay_restore = true
run_control_command_async("toggle-visible-overlay", nil, function(ok)
run_control_command_async("toggle", nil, function(ok)
if not ok then
subminer_log("warn", "process", "Toggle command failed")
show_osd("Toggle failed")

View File

@@ -32,7 +32,6 @@ function M.new()
auto_play_ready_gate_armed = false,
auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = nil,
suppress_ready_overlay_restore = false,
}
end

View File

@@ -1,8 +1,7 @@
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import test from 'node:test';
import { mergeLcovReports, resolveCoverageDir } from './run-coverage-lane';
import { mergeLcovReports } from './run-coverage-lane';
test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => {
const merged = mergeLcovReports([
@@ -60,15 +59,3 @@ test('mergeLcovReports keeps distinct source files as separate records', () => {
assert.match(merged, /SF:src\/a\.ts[\s\S]*end_of_record/);
assert.match(merged, /SF:src\/b\.ts[\s\S]*end_of_record/);
});
test('resolveCoverageDir keeps coverage output inside the repository', () => {
const repoRoot = resolve('/tmp', 'subminer-repo-root');
assert.equal(resolveCoverageDir(repoRoot, []), resolve(repoRoot, 'coverage'));
assert.equal(
resolveCoverageDir(repoRoot, ['--coverage-dir', 'coverage/test-src']),
resolve(repoRoot, 'coverage/test-src'),
);
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '../escape']));
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
});

View File

@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { join, relative, resolve } from 'node:path';
type LaneConfig = {
roots: string[];
@@ -85,15 +85,6 @@ function parseCoverageDirArg(argv: string[]): string {
return 'coverage';
}
export function resolveCoverageDir(repoRootDir: string, argv: string[]): string {
const candidate = resolve(repoRootDir, parseCoverageDirArg(argv));
const rel = relative(repoRootDir, candidate);
if (isAbsolute(rel) || rel.startsWith('..')) {
throw new Error(`--coverage-dir must be within repository: ${candidate}`);
}
return candidate;
}
function parseLcovReport(report: string): LcovRecord[] {
const records: LcovRecord[] = [];
let current: LcovRecord | null = null;
@@ -260,7 +251,7 @@ function runCoverageLane(): number {
return 1;
}
const coverageDir = resolveCoverageDir(repoRoot, process.argv.slice(3));
const coverageDir = resolve(repoRoot, parseCoverageDirArg(process.argv.slice(3)));
const shardRoot = join(coverageDir, '.shards');
mkdirSync(coverageDir, { recursive: true });
rmSync(shardRoot, { recursive: true, force: true });
@@ -269,43 +260,39 @@ function runCoverageLane(): number {
const files = getLaneFiles(laneName);
const reports: string[] = [];
try {
for (const [index, file] of files.entries()) {
const shardDir = join(shardRoot, `${String(index + 1).padStart(3, '0')}`);
const result = spawnSync(
'bun',
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
{
cwd: repoRoot,
stdio: 'inherit',
},
);
for (const [index, file] of files.entries()) {
const shardDir = join(shardRoot, `${String(index + 1).padStart(3, '0')}`);
const result = spawnSync(
'bun',
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
{
cwd: repoRoot,
stdio: 'inherit',
},
);
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
return result.status ?? 1;
}
const lcovPath = join(shardDir, 'lcov.info');
if (!existsSync(lcovPath)) {
process.stdout.write(`Skipping empty coverage shard for ${file}\n`);
continue;
}
reports.push(readFileSync(lcovPath, 'utf8'));
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
return result.status ?? 1;
}
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
process.stdout.write(`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`);
return 0;
} finally {
rmSync(shardRoot, { recursive: true, force: true });
const lcovPath = join(shardDir, 'lcov.info');
if (!existsSync(lcovPath)) {
process.stdout.write(`Skipping empty coverage shard for ${file}\n`);
continue;
}
reports.push(readFileSync(lcovPath, 'utf8'));
}
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
rmSync(shardRoot, { recursive: true, force: true });
process.stdout.write(`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`);
return 0;
}
// @ts-ignore Bun entrypoint detection; TS config for scripts still targets CommonJS.
if (import.meta.main) {
if (require.main === module) {
process.exit(runCoverageLane());
}

View File

@@ -822,92 +822,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 = "yes",
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 manual toggle-off ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1,
"manual toggle should use explicit visible-overlay toggle command"
)
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual toggle-off before readiness should suppress ready-time visible overlay restore"
)
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 = "yes",
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 repeated ready restore suppression scenario: " .. tostring(err)
)
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]()
recorded.script_messages["subminer-autoplay-ready"]()
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual toggle-off should suppress repeated ready-time visible overlay restores for the same session"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "no",
},
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for manual toggle command scenario: " .. tostring(err))
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1,
"script-message toggle should issue explicit visible-overlay toggle command"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle") == 0,
"script-message toggle should not issue legacy generic toggle command"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",

View File

@@ -129,7 +129,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
},
discordPresence: {
enabled: false,
presenceStyle: 'default' as const,
updateIntervalMs: 3_000,
debounceMs: 750,
},

View File

@@ -323,13 +323,6 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.discordPresence.enabled,
description: 'Enable optional Discord Rich Presence updates.',
},
{
path: 'discordPresence.presenceStyle',
kind: 'string',
defaultValue: defaultConfig.discordPresence.presenceStyle,
description:
'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".',
},
{
path: 'discordPresence.updateIntervalMs',
kind: 'number',

View File

@@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import * as electron from 'electron';
import { ensureDirForFile } from '../../../shared/fs-utils';
interface PersistedTokenPayload {
encryptedToken?: string;
@@ -21,8 +21,15 @@ export interface SafeStorageLike {
getSelectedStorageBackend?: () => string;
}
function ensureDirectory(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function writePayload(filePath: string, payload: PersistedTokenPayload): void {
ensureDirForFile(filePath);
ensureDirectory(filePath);
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
}

View File

@@ -1,5 +1,5 @@
import * as fs from 'fs';
import { ensureDirForFile } from '../../../shared/fs-utils';
import * as path from 'path';
const INITIAL_BACKOFF_MS = 30_000;
const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000;
@@ -35,6 +35,13 @@ export interface AnilistUpdateQueue {
getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
}
function ensureDir(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function clampBackoffMs(attemptCount: number): number {
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
return Math.min(MAX_BACKOFF_MS, computed);
@@ -53,7 +60,7 @@ export function createAnilistUpdateQueue(
const persist = () => {
try {
ensureDirForFile(filePath);
ensureDir(filePath);
const payload: AnilistRetryQueuePayload = { pending, deadLetter };
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
} catch (error) {

View File

@@ -443,23 +443,13 @@ test('handleCliCommand still runs non-start actions on second-instance', () => {
);
});
test('handleCliCommand does not connect MPV for pure toggle on second-instance', () => {
test('handleCliCommand connects MPV for toggle on second-instance', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ toggle: true }), 'second-instance', deps);
assert.ok(calls.includes('toggleVisibleOverlay'));
assert.equal(
calls.some((value) => value === 'connectMpvClient'),
false,
);
});
test('handleCliCommand does not connect MPV for explicit visible-overlay toggle', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ toggleVisibleOverlay: true }), 'second-instance', deps);
assert.ok(calls.includes('toggleVisibleOverlay'));
assert.equal(
calls.some((value) => value === 'connectMpvClient'),
false,
true,
);
});

View File

@@ -271,7 +271,7 @@ export function handleCliCommand(
const reuseSecondInstanceStart =
source === 'second-instance' && args.start && deps.isOverlayRuntimeInitialized();
const shouldConnectMpv = args.start;
const shouldStart = args.start || args.toggle || args.toggleVisibleOverlay;
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
@@ -302,7 +302,7 @@ export function handleCliCommand(
deps.initializeOverlayRuntime();
}
if (shouldConnectMpv && deps.hasMpvClient()) {
if (shouldStart && deps.hasMpvClient()) {
const socketPath = deps.getMpvSocketPath();
deps.setMpvClientSocketPath(socketPath);
deps.connectMpvClient();

View File

@@ -10,7 +10,6 @@ import {
const baseConfig = {
enabled: true,
presenceStyle: 'default' as const,
updateIntervalMs: 10_000,
debounceMs: 200,
} as const;
@@ -28,67 +27,24 @@ const baseSnapshot: DiscordPresenceSnapshot = {
sessionStartedAtMs: BASE_SESSION_STARTED_AT_MS,
};
test('buildDiscordPresenceActivity maps polished payload fields (default style)', () => {
test('buildDiscordPresenceActivity maps polished payload fields', () => {
const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot);
assert.equal(payload.details, 'Sousou no Frieren E01');
assert.equal(payload.state, 'Playing 01:35 / 24:10');
assert.equal(payload.largeImageKey, 'subminer-logo');
assert.equal(payload.smallImageKey, 'study');
assert.equal(payload.smallImageText, '日本語学習中');
assert.equal(payload.buttons, undefined);
assert.equal(payload.startTimestamp, Math.floor(BASE_SESSION_STARTED_AT_MS / 1000));
});
test('buildDiscordPresenceActivity falls back to idle with default style', () => {
test('buildDiscordPresenceActivity falls back to idle when disconnected', () => {
const payload = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
connected: false,
mediaPath: null,
});
assert.equal(payload.state, 'Idle');
assert.equal(payload.details, 'Sentence Mining');
});
test('buildDiscordPresenceActivity uses meme style fallback', () => {
const memeConfig = { ...baseConfig, presenceStyle: 'meme' as const };
const payload = buildDiscordPresenceActivity(memeConfig, {
...baseSnapshot,
connected: false,
mediaPath: null,
});
assert.equal(payload.details, 'Mining and crafting (Anki cards)');
assert.equal(payload.smallImageText, 'Sentence Mining');
});
test('buildDiscordPresenceActivity uses japanese style', () => {
const jpConfig = { ...baseConfig, presenceStyle: 'japanese' as const };
const payload = buildDiscordPresenceActivity(jpConfig, {
...baseSnapshot,
connected: false,
mediaPath: null,
});
assert.equal(payload.details, '文の採掘中');
assert.equal(payload.smallImageText, 'イマージョン学習');
});
test('buildDiscordPresenceActivity uses minimal style', () => {
const minConfig = { ...baseConfig, presenceStyle: 'minimal' as const };
const payload = buildDiscordPresenceActivity(minConfig, {
...baseSnapshot,
connected: false,
mediaPath: null,
});
assert.equal(payload.details, 'SubMiner');
assert.equal(payload.smallImageKey, undefined);
assert.equal(payload.smallImageText, undefined);
});
test('buildDiscordPresenceActivity shows media title regardless of style', () => {
for (const presenceStyle of ['default', 'meme', 'japanese', 'minimal'] as const) {
const payload = buildDiscordPresenceActivity({ ...baseConfig, presenceStyle }, baseSnapshot);
assert.equal(payload.details, 'Sousou no Frieren E01');
assert.equal(payload.state, 'Playing 01:35 / 24:10');
}
});
test('service deduplicates identical updates and sends changed timeline', async () => {

View File

@@ -1,4 +1,3 @@
import type { DiscordPresenceStylePreset } from '../../types/integrations';
import type { ResolvedConfig } from '../../types';
export interface DiscordPresenceSnapshot {
@@ -34,58 +33,15 @@ type DiscordClient = {
type TimeoutLike = ReturnType<typeof setTimeout>;
interface PresenceStyleDefinition {
fallbackDetails: string;
largeImageKey: string;
largeImageText: string;
smallImageKey: string;
smallImageText: string;
buttonLabel: string;
buttonUrl: string;
}
const PRESENCE_STYLES: Record<DiscordPresenceStylePreset, PresenceStyleDefinition> = {
default: {
fallbackDetails: 'Sentence Mining',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: '日本語学習中',
buttonLabel: '',
buttonUrl: '',
},
meme: {
fallbackDetails: 'Mining and crafting (Anki cards)',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: 'Sentence Mining',
buttonLabel: '',
buttonUrl: '',
},
japanese: {
fallbackDetails: '文の採掘中',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: 'イマージョン学習',
buttonLabel: '',
buttonUrl: '',
},
minimal: {
fallbackDetails: 'SubMiner',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: '',
smallImageText: '',
buttonLabel: '',
buttonUrl: '',
},
};
function resolvePresenceStyle(preset: DiscordPresenceStylePreset | undefined): PresenceStyleDefinition {
return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default;
}
const DISCORD_PRESENCE_STYLE = {
fallbackDetails: 'Mining and crafting (Anki cards)',
largeImageKey: 'subminer-logo',
largeImageText: 'SubMiner',
smallImageKey: 'study',
smallImageText: 'Sentence Mining',
buttonLabel: '',
buttonUrl: '',
} as const;
function trimField(value: string, maxLength = 128): string {
if (value.length <= maxLength) return value;
@@ -123,16 +79,15 @@ function formatClock(totalSeconds: number | null | undefined): string {
}
export function buildDiscordPresenceActivity(
config: DiscordPresenceConfig,
_config: DiscordPresenceConfig,
snapshot: DiscordPresenceSnapshot,
): DiscordActivityPayload {
const style = resolvePresenceStyle(config.presenceStyle);
const status = buildStatus(snapshot);
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
const details =
snapshot.connected && snapshot.mediaPath
? trimField(title)
: style.fallbackDetails;
: DISCORD_PRESENCE_STYLE.fallbackDetails;
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
const state =
snapshot.connected && snapshot.mediaPath
@@ -145,26 +100,26 @@ export function buildDiscordPresenceActivity(
startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000),
};
if (style.largeImageKey.trim().length > 0) {
activity.largeImageKey = style.largeImageKey.trim();
if (DISCORD_PRESENCE_STYLE.largeImageKey.trim().length > 0) {
activity.largeImageKey = DISCORD_PRESENCE_STYLE.largeImageKey.trim();
}
if (style.largeImageText.trim().length > 0) {
activity.largeImageText = trimField(style.largeImageText.trim());
if (DISCORD_PRESENCE_STYLE.largeImageText.trim().length > 0) {
activity.largeImageText = trimField(DISCORD_PRESENCE_STYLE.largeImageText.trim());
}
if (style.smallImageKey.trim().length > 0) {
activity.smallImageKey = style.smallImageKey.trim();
if (DISCORD_PRESENCE_STYLE.smallImageKey.trim().length > 0) {
activity.smallImageKey = DISCORD_PRESENCE_STYLE.smallImageKey.trim();
}
if (style.smallImageText.trim().length > 0) {
activity.smallImageText = trimField(style.smallImageText.trim());
if (DISCORD_PRESENCE_STYLE.smallImageText.trim().length > 0) {
activity.smallImageText = trimField(DISCORD_PRESENCE_STYLE.smallImageText.trim());
}
if (
style.buttonLabel.trim().length > 0 &&
/^https?:\/\//.test(style.buttonUrl.trim())
DISCORD_PRESENCE_STYLE.buttonLabel.trim().length > 0 &&
/^https?:\/\//.test(DISCORD_PRESENCE_STYLE.buttonUrl.trim())
) {
activity.buttons = [
{
label: trimField(style.buttonLabel.trim(), 32),
url: style.buttonUrl.trim(),
label: trimField(DISCORD_PRESENCE_STYLE.buttonLabel.trim(), 32),
url: DISCORD_PRESENCE_STYLE.buttonUrl.trim(),
},
];
}

View File

@@ -85,12 +85,14 @@ function withMockDate<T>(fixedDate: Date, run: (realDate: typeof Date) => T): T
const realDate = Date;
const fixedDateMs = fixedDate.getTime();
type MockDateArgs = [any, any, any, any, any, any, any];
class MockDate extends Date {
constructor(...args: any[]) {
constructor(...args: MockDateArgs) {
if (args.length === 0) {
super(fixedDateMs);
} else {
super(...(args as [any?, any?, any?, any?, any?, any?, any?]));
super(...args);
}
}
@@ -1516,11 +1518,11 @@ test('getMonthlyRollups derives rate metrics from stored monthly totals', () =>
const rows = getMonthlyRollups(db, 1);
assert.equal(rows.length, 2);
const rowsByVideoId = new Map(rows.map((row) => [row.videoId, row]));
assert.equal(rowsByVideoId.get(1)?.cardsPerHour, 30);
assert.equal(rowsByVideoId.get(1)?.tokensPerMin, 3);
assert.equal(rowsByVideoId.get(1)?.lookupHitRate ?? null, null);
assert.equal(rowsByVideoId.get(2)?.cardsPerHour ?? null, null);
assert.equal(rowsByVideoId.get(2)?.tokensPerMin ?? null, null);
assert.equal(rowsByVideoId.get(2)?.cardsPerHour, 30);
assert.equal(rowsByVideoId.get(2)?.tokensPerMin, 3);
assert.equal(rowsByVideoId.get(2)?.lookupHitRate ?? null, null);
assert.equal(rowsByVideoId.get(1)?.cardsPerHour ?? null, null);
assert.equal(rowsByVideoId.get(1)?.tokensPerMin ?? null, null);
} finally {
db.close();
cleanupDbPath(dbPath);

View File

@@ -82,65 +82,6 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
}
});
test('pruneRawRetention skips disabled retention windows', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const nowMs = 1_000_000_000;
db.exec(`
INSERT INTO imm_videos (
video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
1, 'local:/tmp/video.mkv', 'Video', 1, 0, ${nowMs}, ${nowMs}
);
INSERT INTO imm_sessions (
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
1, 'session-1', 1, ${nowMs - 1_000}, ${nowMs - 500}, 2, ${nowMs}, ${nowMs}
);
INSERT INTO imm_session_telemetry (
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
1, ${nowMs - 2_000}, 0, 0, ${nowMs}, ${nowMs}
);
INSERT INTO imm_session_events (
session_id, event_type, ts_ms, payload_json, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
1, 1, ${nowMs - 3_000}, '{}', ${nowMs}, ${nowMs}
);
`);
const result = pruneRawRetention(db, nowMs, {
eventsRetentionMs: Number.POSITIVE_INFINITY,
telemetryRetentionMs: Number.POSITIVE_INFINITY,
sessionsRetentionMs: Number.POSITIVE_INFINITY,
});
const remainingSessionEvents = db
.prepare('SELECT COUNT(*) AS count FROM imm_session_events')
.get() as { count: number };
const remainingTelemetry = db
.prepare('SELECT COUNT(*) AS count FROM imm_session_telemetry')
.get() as { count: number };
const remainingSessions = db
.prepare('SELECT COUNT(*) AS count FROM imm_sessions')
.get() as { count: number };
assert.equal(result.deletedSessionEvents, 0);
assert.equal(result.deletedTelemetryRows, 0);
assert.equal(result.deletedEndedSessions, 0);
assert.equal(remainingSessionEvents.count, 1);
assert.equal(remainingTelemetry.count, 1);
assert.equal(remainingSessions.count, 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('toMonthKey floors negative timestamps into the prior UTC month', () => {
assert.equal(toMonthKey(-1), 196912);
assert.equal(toMonthKey(-86_400_000), 196912);

View File

@@ -1,6 +1,9 @@
import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
import { toDbMs } from './query-shared';
function toDbMs(ms: number | bigint): bigint {
return BigInt(Math.trunc(Number(ms)));
}
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
const DAILY_MS = 86_400_000;
@@ -46,34 +49,32 @@ export function toMonthKey(timestampMs: number): number {
export function pruneRawRetention(
db: DatabaseSync,
currentMs: number,
nowMs: number,
policy: {
eventsRetentionMs: number;
telemetryRetentionMs: number;
sessionsRetentionMs: number;
},
): RawRetentionResult {
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
? (
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(
toDbMs(currentMs - policy.eventsRetentionMs),
) as { changes: number }
).changes
: 0;
const deletedTelemetryRows = Number.isFinite(policy.telemetryRetentionMs)
? (
db
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
.run(toDbMs(currentMs - policy.telemetryRetentionMs)) as { changes: number }
).changes
: 0;
const deletedEndedSessions = Number.isFinite(policy.sessionsRetentionMs)
? (
db
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
.run(toDbMs(currentMs - policy.sessionsRetentionMs)) as { changes: number }
).changes
: 0;
const eventCutoff = nowMs - policy.eventsRetentionMs;
const telemetryCutoff = nowMs - policy.telemetryRetentionMs;
const sessionsCutoff = nowMs - policy.sessionsRetentionMs;
const deletedSessionEvents = (
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(toDbMs(eventCutoff)) as {
changes: number;
}
).changes;
const deletedTelemetryRows = (
db
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
.run(toDbMs(telemetryCutoff)) as { changes: number }
).changes;
const deletedEndedSessions = (
db
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
.run(toDbMs(sessionsCutoff)) as { changes: number }
).changes;
return {
deletedSessionEvents,
@@ -84,7 +85,7 @@ export function pruneRawRetention(
export function pruneRollupRetention(
db: DatabaseSync,
currentMs: number,
nowMs: number,
policy: {
dailyRollupRetentionMs: number;
monthlyRollupRetentionMs: number;
@@ -94,7 +95,7 @@ export function pruneRollupRetention(
? (
db
.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`)
.run(Math.floor((currentMs - policy.dailyRollupRetentionMs) / DAILY_MS)) as {
.run(Math.floor((nowMs - policy.dailyRollupRetentionMs) / DAILY_MS)) as {
changes: number;
}
).changes
@@ -103,7 +104,7 @@ export function pruneRollupRetention(
? (
db
.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`)
.run(toMonthKey(currentMs - policy.monthlyRollupRetentionMs)) as {
.run(toMonthKey(nowMs - policy.monthlyRollupRetentionMs)) as {
changes: number;
}
).changes
@@ -157,32 +158,29 @@ function upsertDailyRollupsForGroups(
CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS rollup_day,
s.video_id AS video_id,
COUNT(DISTINCT s.session_id) AS total_sessions,
COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) / 60000.0 AS total_active_min,
COALESCE(SUM(COALESCE(sm.max_lines, s.lines_seen)), 0) AS total_lines_seen,
COALESCE(SUM(COALESCE(sm.max_tokens, s.tokens_seen)), 0) AS total_tokens_seen,
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
COALESCE(SUM(sm.max_active_ms), 0) / 60000.0 AS total_active_min,
COALESCE(SUM(sm.max_lines), 0) AS total_lines_seen,
COALESCE(SUM(sm.max_tokens), 0) AS total_tokens_seen,
COALESCE(SUM(sm.max_cards), 0) AS total_cards,
CASE
WHEN COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) > 0
THEN (COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) * 60.0)
/ (COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) / 60000.0)
WHEN COALESCE(SUM(sm.max_active_ms), 0) > 0
THEN (COALESCE(SUM(sm.max_cards), 0) * 60.0) / (COALESCE(SUM(sm.max_active_ms), 0) / 60000.0)
ELSE NULL
END AS cards_per_hour,
CASE
WHEN COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) > 0
THEN COALESCE(SUM(COALESCE(sm.max_tokens, s.tokens_seen)), 0)
/ (COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) / 60000.0)
WHEN COALESCE(SUM(sm.max_active_ms), 0) > 0
THEN COALESCE(SUM(sm.max_tokens), 0) / (COALESCE(SUM(sm.max_active_ms), 0) / 60000.0)
ELSE NULL
END AS tokens_per_min,
CASE
WHEN COALESCE(SUM(COALESCE(sm.max_lookups, s.lookup_count)), 0) > 0
THEN CAST(COALESCE(SUM(COALESCE(sm.max_hits, s.lookup_hits)), 0) AS REAL)
/ CAST(COALESCE(SUM(COALESCE(sm.max_lookups, s.lookup_count)), 0) AS REAL)
WHEN COALESCE(SUM(sm.max_lookups), 0) > 0
THEN CAST(COALESCE(SUM(sm.max_hits), 0) AS REAL) / CAST(SUM(sm.max_lookups) AS REAL)
ELSE NULL
END AS lookup_hit_rate,
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM imm_sessions s
LEFT JOIN (
JOIN (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
@@ -232,14 +230,14 @@ function upsertMonthlyRollupsForGroups(
CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollup_month,
s.video_id AS video_id,
COUNT(DISTINCT s.session_id) AS total_sessions,
COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) / 60000.0 AS total_active_min,
COALESCE(SUM(COALESCE(sm.max_lines, s.lines_seen)), 0) AS total_lines_seen,
COALESCE(SUM(COALESCE(sm.max_tokens, s.tokens_seen)), 0) AS total_tokens_seen,
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
COALESCE(SUM(sm.max_active_ms), 0) / 60000.0 AS total_active_min,
COALESCE(SUM(sm.max_lines), 0) AS total_lines_seen,
COALESCE(SUM(sm.max_tokens), 0) AS total_tokens_seen,
COALESCE(SUM(sm.max_cards), 0) AS total_cards,
? AS CREATED_DATE,
? AS LAST_UPDATE_DATE
FROM imm_sessions s
LEFT JOIN (
JOIN (
SELECT
t.session_id,
MAX(t.active_watched_ms) AS max_active_ms,
@@ -281,7 +279,7 @@ function getAffectedRollupGroups(
FROM imm_session_telemetry t
JOIN imm_sessions s
ON s.session_id = t.session_id
WHERE t.sample_ms >= ?
WHERE t.sample_ms > ?
`,
)
.all(lastRollupSampleMs) as unknown as RollupGroupRow[]

View File

@@ -186,7 +186,7 @@ export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): S
headword: string;
reading: string;
} | null;
if (!word || word.headword.trim() === '') return [];
if (!word) return [];
return db
.prepare(
`

View File

@@ -16,7 +16,7 @@ import type {
StreakCalendarRow,
WatchTimePerAnimeRow,
} from './types';
import { ACTIVE_SESSION_METRICS_CTE, resolvedCoverBlobExpr } from './query-shared';
import { ACTIVE_SESSION_METRICS_CTE, resolvedCoverBlobExpr } from './query-shared.js';
export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
return db

View File

@@ -16,8 +16,7 @@ import {
getAffectedWordIdsForSessions,
getAffectedWordIdsForVideo,
refreshLexicalAggregates,
toDbMs,
} from './query-shared';
} from './query-shared.js';
type CleanupVocabularyRow = {
id: number;
@@ -544,3 +543,6 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
throw error;
}
}
function toDbMs(ms: number | bigint): bigint {
return BigInt(Math.trunc(Number(ms)));
}

View File

@@ -5,7 +5,7 @@ import type {
SessionSummaryQueryRow,
SessionTimelineRow,
} from './types';
import { ACTIVE_SESSION_METRICS_CTE } from './query-shared';
import { ACTIVE_SESSION_METRICS_CTE } from './query-shared.js';
export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] {
const prepared = db.prepare(`
@@ -205,7 +205,7 @@ export function getQueryHints(db: DatabaseSync): {
const now = new Date();
const todayLocal = Math.floor(
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000,
(now.getTime() / 1000 - now.getTimezoneOffset() * 60) / 86_400,
);
const episodesToday =

View File

@@ -89,61 +89,72 @@ export function findSharedCoverBlobHash(
return null;
}
type LexicalEntity = 'word' | 'kanji';
function getAffectedIdsForSessions(
db: DatabaseSync,
entity: LexicalEntity,
sessionIds: number[],
): number[] {
if (sessionIds.length === 0) return [];
const table = entity === 'word' ? 'imm_word_line_occurrences' : 'imm_kanji_line_occurrences';
const col = `${entity}_id`;
return (
db
.prepare(
`SELECT DISTINCT o.${col} AS id
FROM ${table} o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE sl.session_id IN (${makePlaceholders(sessionIds)})`,
)
.all(...sessionIds) as Array<{ id: number }>
).map((row) => row.id);
}
function getAffectedIdsForVideo(
db: DatabaseSync,
entity: LexicalEntity,
videoId: number,
): number[] {
const table = entity === 'word' ? 'imm_word_line_occurrences' : 'imm_kanji_line_occurrences';
const col = `${entity}_id`;
return (
db
.prepare(
`SELECT DISTINCT o.${col} AS id
FROM ${table} o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE sl.video_id = ?`,
)
.all(videoId) as Array<{ id: number }>
).map((row) => row.id);
}
export function getAffectedWordIdsForSessions(db: DatabaseSync, sessionIds: number[]): number[] {
return getAffectedIdsForSessions(db, 'word', sessionIds);
if (sessionIds.length === 0) {
return [];
}
return (
db
.prepare(
`
SELECT DISTINCT o.word_id AS wordId
FROM imm_word_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE sl.session_id IN (${makePlaceholders(sessionIds)})
`,
)
.all(...sessionIds) as Array<{ wordId: number }>
).map((row) => row.wordId);
}
export function getAffectedKanjiIdsForSessions(db: DatabaseSync, sessionIds: number[]): number[] {
return getAffectedIdsForSessions(db, 'kanji', sessionIds);
if (sessionIds.length === 0) {
return [];
}
return (
db
.prepare(
`
SELECT DISTINCT o.kanji_id AS kanjiId
FROM imm_kanji_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE sl.session_id IN (${makePlaceholders(sessionIds)})
`,
)
.all(...sessionIds) as Array<{ kanjiId: number }>
).map((row) => row.kanjiId);
}
export function getAffectedWordIdsForVideo(db: DatabaseSync, videoId: number): number[] {
return getAffectedIdsForVideo(db, 'word', videoId);
return (
db
.prepare(
`
SELECT DISTINCT o.word_id AS wordId
FROM imm_word_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE sl.video_id = ?
`,
)
.all(videoId) as Array<{ wordId: number }>
).map((row) => row.wordId);
}
export function getAffectedKanjiIdsForVideo(db: DatabaseSync, videoId: number): number[] {
return getAffectedIdsForVideo(db, 'kanji', videoId);
return (
db
.prepare(
`
SELECT DISTINCT o.kanji_id AS kanjiId
FROM imm_kanji_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE sl.video_id = ?
`,
)
.all(videoId) as Array<{ kanjiId: number }>
).map((row) => row.kanjiId);
}
function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
@@ -270,13 +281,3 @@ export function deleteSessionsByIds(db: DatabaseSync, sessionIds: number[]): voi
);
db.prepare(`DELETE FROM imm_sessions WHERE session_id IN (${placeholders})`).run(...sessionIds);
}
export function toDbMs(ms: number | bigint): bigint {
if (typeof ms === 'bigint') {
return ms;
}
if (!Number.isFinite(ms)) {
throw new TypeError(`Invalid database timestamp: ${ms}`);
}
return BigInt(Math.trunc(ms));
}

View File

@@ -1,7 +1,7 @@
import type { DatabaseSync } from './sqlite';
import type { ImmersionSessionRollupRow } from './types';
import { ACTIVE_SESSION_METRICS_CTE, makePlaceholders } from './query-shared';
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
import { ACTIVE_SESSION_METRICS_CTE, makePlaceholders } from './query-shared.js';
import { getDailyRollups, getMonthlyRollups } from './query-sessions.js';
type TrendRange = '7d' | '30d' | '90d' | 'all';
type TrendGroupBy = 'day' | 'month';
@@ -168,7 +168,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
words: 0,
sessions: 0,
};
existing.activeMin += rollup.totalActiveMin;
existing.activeMin += Math.round(rollup.totalActiveMin);
existing.cards += rollup.totalCards;
existing.words += rollup.totalTokensSeen;
existing.sessions += rollup.totalSessions;
@@ -179,7 +179,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
.sort(([left], [right]) => left - right)
.map(([key, value]) => ({
label: makeTrendLabel(key),
activeMin: Math.round(value.activeMin),
activeMin: value.activeMin,
cards: value.cards,
words: value.words,
sessions: value.sessions,
@@ -243,32 +243,22 @@ function buildSessionSeriesByMonth(
.map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value }));
}
function buildLookupsPerHundredWords(
sessions: TrendSessionMetricRow[],
groupBy: TrendGroupBy,
): TrendChartPoint[] {
const lookupsByBucket = new Map<number, number>();
const wordsByBucket = new Map<number, number>();
function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
const lookupsByDay = new Map<number, number>();
const wordsByDay = new Map<number, number>();
for (const session of sessions) {
const bucketKey =
groupBy === 'month' ? getLocalMonthKey(session.startedAtMs) : getLocalEpochDay(session.startedAtMs);
lookupsByBucket.set(
bucketKey,
(lookupsByBucket.get(bucketKey) ?? 0) + session.yomitanLookupCount,
);
wordsByBucket.set(
bucketKey,
(wordsByBucket.get(bucketKey) ?? 0) + getTrendSessionWordCount(session),
);
const epochDay = getLocalEpochDay(session.startedAtMs);
lookupsByDay.set(epochDay, (lookupsByDay.get(epochDay) ?? 0) + session.yomitanLookupCount);
wordsByDay.set(epochDay, (wordsByDay.get(epochDay) ?? 0) + getTrendSessionWordCount(session));
}
return Array.from(lookupsByBucket.entries())
return Array.from(lookupsByDay.entries())
.sort(([left], [right]) => left - right)
.map(([bucketKey, lookups]) => {
const words = wordsByBucket.get(bucketKey) ?? 0;
.map(([epochDay, lookups]) => {
const words = wordsByDay.get(epochDay) ?? 0;
return {
label: groupBy === 'month' ? makeTrendLabel(bucketKey) : dayLabel(bucketKey),
label: dayLabel(epochDay),
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
};
});
@@ -605,7 +595,7 @@ export function getTrendsDashboard(
const animePerDay = {
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
watchTime: buildPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId, (rollup) =>
rollup.totalActiveMin,
Math.round(rollup.totalActiveMin),
),
cards: buildPerAnimeFromDailyRollups(
dailyRollups,
@@ -643,7 +633,7 @@ export function getTrendsDashboard(
),
},
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
lookupsPerHundred: buildLookupsPerHundredWords(sessions),
},
animePerDay,
animeCumulative: {

View File

@@ -1,5 +1,5 @@
export * from './query-sessions';
export * from './query-trends';
export * from './query-lexical';
export * from './query-library';
export * from './query-maintenance';
export * from './query-sessions.js';
export * from './query-trends.js';
export * from './query-lexical.js';
export * from './query-library.js';
export * from './query-maintenance.js';

View File

@@ -4,7 +4,10 @@ import { createInitialSessionState } from './reducer';
import { nowMs } from './time';
import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types';
import type { SessionState } from './types';
import { toDbMs } from './query-shared';
function toDbMs(ms: number | bigint): bigint {
return BigInt(Math.trunc(Number(ms)));
}
export function startSessionRecord(
db: DatabaseSync,

View File

@@ -4,7 +4,10 @@ import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
import { SCHEMA_VERSION } from './types';
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
import { toDbMs } from './query-shared';
function toDbMs(ms: number | bigint): bigint {
return BigInt(Math.trunc(Number(ms)));
}
export interface TrackerPreparedStatements {
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;

View File

@@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import electron from 'electron';
import { ensureDirForFile } from '../../shared/fs-utils';
const { safeStorage } = electron;
@@ -27,8 +27,15 @@ export interface JellyfinTokenStore {
clearSession: () => void;
}
function ensureDirectory(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function writePayload(filePath: string, payload: PersistedSessionPayload): void {
ensureDirForFile(filePath);
ensureDirectory(filePath);
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
}

View File

@@ -59,21 +59,3 @@ export function handleOverlayWindowBeforeInputEvent(options: {
options.preventDefault();
return true;
}
export function handleOverlayWindowBlurred(options: {
kind: OverlayWindowKind;
windowVisible: boolean;
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
ensureOverlayWindowLevel: () => void;
moveWindowTop: () => void;
}): boolean {
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
return false;
}
options.ensureOverlayWindowLevel();
if (options.kind === 'visible' && options.windowVisible) {
options.moveWindowTop();
}
return true;
}

View File

@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
handleOverlayWindowBeforeInputEvent,
handleOverlayWindowBlurred,
isTabInputForMpvForwarding,
} from './overlay-window-input';
@@ -83,58 +82,3 @@ test('handleOverlayWindowBeforeInputEvent leaves modal Tab handling alone', () =
assert.equal(handled, false);
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred skips visible overlay restacking after manual hide', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => false,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
const calls: string[] = [];
assert.equal(
handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-visible');
},
moveWindowTop: () => {
calls.push('move-visible');
},
}),
true,
);
assert.equal(
handleOverlayWindowBlurred({
kind: 'modal',
windowVisible: true,
isOverlayVisible: () => false,
ensureOverlayWindowLevel: () => {
calls.push('ensure-modal');
},
moveWindowTop: () => {
calls.push('move-modal');
},
}),
true,
);
assert.deepEqual(calls, ['ensure-visible', 'move-visible', 'ensure-modal']);
});

View File

@@ -5,7 +5,6 @@ import { createLogger } from '../../logger';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
handleOverlayWindowBeforeInputEvent,
handleOverlayWindowBlurred,
type OverlayWindowKind,
} from './overlay-window-input';
import { buildOverlayWindowOptions } from './overlay-window-options';
@@ -125,18 +124,12 @@ export function createOverlayWindow(
});
window.on('blur', () => {
if (window.isDestroyed()) return;
handleOverlayWindowBlurred({
kind,
windowVisible: window.isVisible(),
isOverlayVisible: options.isOverlayVisible,
ensureOverlayWindowLevel: () => {
options.ensureOverlayWindowLevel(window);
},
moveWindowTop: () => {
if (!window.isDestroyed()) {
options.ensureOverlayWindowLevel(window);
if (kind === 'visible' && window.isVisible()) {
window.moveTop();
},
});
}
}
});
if (options.isDev && kind === 'visible') {

View File

@@ -1,9 +1,8 @@
import { Hono } from 'hono';
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { basename, extname, resolve, sep } from 'node:path';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { Readable } from 'node:stream';
import { MediaGenerator } from '../../media-generator.js';
import { AnkiConnectClient } from '../../anki-connect.js';
import type { AnkiConnectConfig } from '../../types.js';
@@ -61,71 +60,6 @@ function resolveStatsNoteFieldName(
return null;
}
function toFetchHeaders(headers: IncomingMessage['headers']): Headers {
const fetchHeaders = new Headers();
for (const [name, value] of Object.entries(headers)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
for (const entry of value) {
fetchHeaders.append(name, entry);
}
continue;
}
fetchHeaders.set(name, value);
}
return fetchHeaders;
}
function toFetchRequest(req: IncomingMessage): Request {
const method = req.method ?? 'GET';
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
const init: RequestInit & { duplex?: 'half' } = {
method,
headers: toFetchHeaders(req.headers),
};
if (method !== 'GET' && method !== 'HEAD') {
init.body = Readable.toWeb(req) as BodyInit;
init.duplex = 'half';
}
return new Request(url, init);
}
async function writeFetchResponse(res: ServerResponse, response: Response): Promise<void> {
res.statusCode = response.status;
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
const body = await response.arrayBuffer();
res.end(Buffer.from(body));
}
function startNodeHttpServer(
app: Hono,
config: StatsServerConfig,
): { close: () => void } {
const server = http.createServer((req, res) => {
void (async () => {
try {
await writeFetchResponse(res, await app.fetch(toFetchRequest(req)));
} catch {
res.statusCode = 500;
res.end('Internal Server Error');
}
})();
});
server.listen(config.port, '127.0.0.1');
return {
close: () => {
server.close();
},
};
}
/** Load known words cache from disk into a Set. Returns null if unavailable. */
function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
if (!cachePath || !existsSync(cachePath)) return null;
@@ -222,6 +156,26 @@ export interface StatsServerConfig {
resolveAnkiNoteId?: (noteId: number) => number;
}
type StatsServerHandle = {
stop: () => void;
};
type StatsApp = ReturnType<typeof createStatsApp>;
type BunRuntime = {
Bun: {
serve: (options: {
fetch: StatsApp['fetch'];
port: number;
hostname: string;
}) => StatsServerHandle;
};
};
type NodeRuntimeHandle = {
stop: () => void;
};
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
@@ -294,6 +248,82 @@ function createStatsStaticResponse(staticDir: string, requestPath: string): Resp
});
}
async function readNodeRequestBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
async function createNodeRequest(req: IncomingMessage): Promise<Request> {
const host = req.headers.host ?? '127.0.0.1';
const url = new URL(req.url ?? '/', `http://${host}`);
const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
headers.set(name, value.join(', '));
} else {
headers.set(name, value);
}
}
const method = req.method ?? 'GET';
const body = method === 'GET' || method === 'HEAD' ? undefined : await readNodeRequestBody(req);
const init: RequestInit = {
method,
headers,
};
if (body !== undefined && body.length > 0) {
init.body = new Uint8Array(body);
}
return new Request(url, init);
}
async function writeNodeResponse(
res: ServerResponse<IncomingMessage>,
response: Response,
): Promise<void> {
res.statusCode = response.status;
res.statusMessage = response.statusText;
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
if (!response.body) {
res.end();
return;
}
const body = Buffer.from(await response.arrayBuffer());
res.end(body);
}
function startNodeStatsServer(app: StatsApp, port: number): NodeRuntimeHandle {
const server = createServer((req, res) => {
void (async () => {
try {
const response = await app.fetch(await createNodeRequest(req));
await writeNodeResponse(res, response);
} catch {
if (!res.headersSent) {
res.statusCode = 500;
}
res.end('Internal Server Error');
}
})();
});
server.listen(port, '127.0.0.1');
return {
stop: () => {
server.close();
},
};
}
export function createStatsApp(
tracker: ImmersionTrackerService,
options?: {
@@ -1073,29 +1103,18 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
resolveAnkiNoteId: config.resolveAnkiNoteId,
});
const bunRuntime = globalThis as typeof globalThis & {
Bun?: {
serve?: (options: {
fetch: (typeof app)['fetch'];
port: number;
hostname: string;
}) => { stop: () => void };
};
const bunServe = (globalThis as typeof globalThis & Partial<BunRuntime>).Bun?.serve;
const server = bunServe
? bunServe({
fetch: app.fetch,
port: config.port,
hostname: '127.0.0.1',
})
: startNodeStatsServer(app, config.port);
return {
close: () => {
server.stop();
},
};
if (bunRuntime.Bun?.serve) {
const server = bunRuntime.Bun.serve({
fetch: app.fetch,
port: config.port,
hostname: '127.0.0.1',
});
return {
close: () => {
server.stop();
},
};
}
return startNodeHttpServer(app, config);
}

View File

@@ -35,21 +35,6 @@ test('parseSrtCues handles multi-line subtitle text', () => {
assert.equal(cues[0]!.text, 'これは\nテストです');
});
test('parseSrtCues strips HTML-like markup while preserving line breaks', () => {
const content = [
'1',
'00:01:00,000 --> 00:01:05,000',
'<font color="japanese">これは</font>',
'<font color="japanese">テストです</font>',
'',
].join('\n');
const cues = parseSrtCues(content);
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, 'これは\nテストです');
});
test('parseSrtCues handles hours in timestamps', () => {
const content = ['1', '01:30:00,000 --> 01:30:05,000', 'テスト', ''].join('\n');
@@ -149,18 +134,6 @@ test('parseAssCues handles \\N line breaks', () => {
assert.equal(cues[0]!.text, '一行目\\N二行目');
});
test('parseAssCues strips HTML-like markup while preserving ASS line breaks', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,<font color="japanese">一行目</font>\\N<font color="japanese">二行目</font>',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues[0]!.text, '一行目\\N二行目');
});
test('parseAssCues returns empty for content without Events section', () => {
const content = ['[Script Info]', 'Title: Test'].join('\n');

View File

@@ -4,8 +4,6 @@ export interface SubtitleCue {
text: string;
}
const HTML_SUBTITLE_TAG_PATTERN = /<\/?[A-Za-z][^>\n]*>/g;
const SRT_TIMING_PATTERN =
/^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/;
@@ -23,10 +21,6 @@ function parseTimestamp(
);
}
function sanitizeSubtitleCueText(text: string): string {
return text.replace(ASS_OVERRIDE_TAG_PATTERN, '').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim();
}
export function parseSrtCues(content: string): SubtitleCue[] {
const cues: SubtitleCue[] = [];
const lines = content.split(/\r?\n/);
@@ -60,7 +54,7 @@ export function parseSrtCues(content: string): SubtitleCue[] {
i += 1;
}
const text = sanitizeSubtitleCueText(textLines.join('\n'));
const text = textLines.join('\n').trim();
if (text) {
cues.push({ startTime, endTime, text });
}
@@ -146,9 +140,13 @@ export function parseAssCues(content: string): SubtitleCue[] {
continue;
}
const text = sanitizeSubtitleCueText(fields.slice(textFieldIndex).join(','));
if (text) {
cues.push({ startTime, endTime, text });
const rawText = fields
.slice(textFieldIndex)
.join(',')
.replace(ASS_OVERRIDE_TAG_PATTERN, '')
.trim();
if (rawText) {
cues.push({ startTime, endTime, text: rawText });
}
}

View File

@@ -31,7 +31,6 @@ import {
screen,
} from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import { mergeAiConfig } from './ai/config';
function getPasswordStoreArg(argv: string[]): string | null {
@@ -69,26 +68,6 @@ function getDefaultPasswordStore(): string {
return 'gnome-libsecret';
}
function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
shouldUseMinimalStartup: boolean;
shouldSkipHeavyStartup: boolean;
} {
return {
shouldUseMinimalStartup: Boolean(
initialArgs?.texthooker ||
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
),
shouldSkipHeavyStartup: Boolean(
initialArgs &&
(shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.setup),
),
};
}
protocol.registerSchemesAsPrivileged([
{
scheme: 'chrome-extension',
@@ -122,7 +101,8 @@ import { AnkiIntegration } from './anki-integration';
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { RuntimeOptionsManager } from './runtime-options';
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
import { createLogger, setLogLevel, resolveDefaultLogFilePath, type LogLevelSource } from './logger';
import { createLogger, setLogLevel, type LogLevelSource } from './logger';
import { resolveDefaultLogFilePath } from './logger';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import {
commandNeedsOverlayStartupPrereqs,
@@ -131,11 +111,10 @@ import {
parseArgs,
shouldRunSettingsOnlyStartup,
shouldStartApp,
type CliArgs,
type CliCommandSource,
} from './cli/args';
import type { CliArgs, CliCommandSource } from './cli/args';
import { printHelp } from './cli/help';
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
import { IPC_CHANNELS } from './shared/ipc/contracts';
import {
buildConfigParseErrorDetails,
buildConfigWarningDialogDetails,
@@ -163,9 +142,9 @@ import {
createGetDefaultSocketPathHandler,
buildJellyfinSetupFormHtml,
parseJellyfinSetupSubmissionUrl,
getConfiguredJellyfinSession,
type ActiveJellyfinRemotePlaybackState,
} from './main/runtime/domains/jellyfin';
import type { ActiveJellyfinRemotePlaybackState } from './main/runtime/domains/jellyfin';
import { getConfiguredJellyfinSession } from './main/runtime/domains/jellyfin';
import {
createBuildConfigHotReloadMessageMainDepsHandler,
createBuildConfigHotReloadAppliedMainDepsHandler,
@@ -356,7 +335,6 @@ import {
import {
detectInstalledFirstRunPlugin,
installFirstRunPluginToDefaultLocation,
syncInstalledFirstRunPluginBinaryPath,
} from './main/runtime/first-run-setup-plugin';
import {
applyWindowsMpvShortcuts,
@@ -406,11 +384,13 @@ import {
composeIpcRuntimeHandlers,
composeJellyfinRuntimeHandlers,
composeMpvRuntimeHandlers,
composeOverlayWindowHandlers,
composeOverlayVisibilityRuntime,
composeShortcutRuntimes,
composeStatsStartupRuntime,
composeSubtitlePrefetchRuntime,
composeStartupLifecycleHandlers,
} from './main/runtime/composers';
import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers';
import { createStartupBootstrapRuntimeDeps } from './main/startup';
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
import {
@@ -421,11 +401,36 @@ import {
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
import { createMainBootServices } from './main/boot/services';
import {
composeBootOverlayVisibilityRuntime,
composeBootJellyfinRuntimeHandlers,
composeBootAnilistSetupHandlers,
createBootMaybeFocusExistingAnilistSetupWindowHandler,
createBootBuildOpenAnilistSetupWindowMainDepsHandler,
createBootOpenAnilistSetupWindowHandler,
composeBootAnilistTrackingHandlers,
composeBootStatsStartupRuntime,
createBootRunStatsCliCommandHandler,
composeBootAppReadyRuntime,
composeBootMpvRuntimeHandlers,
createBootTrayRuntimeHandlers,
createBootYomitanProfilePolicy,
createBootYomitanExtensionRuntime,
createBootYomitanSettingsRuntime,
} from './main/boot/runtimes';
import {
composeBootStartupLifecycleHandlers,
composeBootIpcRuntimeHandlers,
composeBootCliStartupHandlers,
composeBootHeadlessStartupHandlers,
composeBootOverlayWindowHandlers,
} from './main/boot/handlers';
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import type { OverlayHostedModal } from './shared/ipc/contracts';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import {
createFrequencyDictionaryRuntimeService,
@@ -487,7 +492,8 @@ import {
} from './config';
import { resolveConfigDir } from './config/path-resolution';
import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
import { createSubtitlePrefetchService, type SubtitlePrefetchService } from './core/services/subtitle-prefetch';
import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch';
import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch';
import {
buildSubtitleSidebarSourceKey,
resolveSubtitleSourcePath,
@@ -520,6 +526,9 @@ const ANILIST_DEVELOPER_SETTINGS_URL = 'https://anilist.co/settings/developer';
const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
const ANILIST_TOKEN_STORE_FILE = 'anilist-token-store.json';
const JELLYFIN_TOKEN_STORE_FILE = 'jellyfin-token-store.json';
const ANILIST_RETRY_QUEUE_FILE = 'anilist-retry-queue.json';
const TRAY_TOOLTIP = 'SubMiner';
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
@@ -618,28 +627,6 @@ const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler(getDefault
function getDefaultSocketPath(): string {
return getDefaultSocketPathHandler();
}
type BootServices = 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>,
{
requestSingleInstanceLock: () => boolean;
quit: () => void;
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
whenReady: () => Promise<void>;
}
>;
const bootServices = createMainBootServices({
platform: process.platform,
argv: process.argv,
@@ -719,7 +706,31 @@ const bootServices = createMainBootServices({
});
},
createAppState,
}) as BootServices;
}) as {
configDir: string;
userDataPath: string;
defaultMpvLogPath: string;
defaultImmersionDbPath: string;
configService: ConfigService;
anilistTokenStore: ReturnType<typeof createAnilistTokenStore>;
jellyfinTokenStore: ReturnType<typeof createJellyfinTokenStore>;
anilistUpdateQueue: ReturnType<typeof createAnilistUpdateQueue>;
subtitleWsService: SubtitleWebSocket;
annotationSubtitleWsService: SubtitleWebSocket;
logger: ReturnType<typeof createLogger>;
runtimeRegistry: ReturnType<typeof createMainRuntimeRegistry>;
overlayManager: ReturnType<typeof createOverlayManager>;
overlayModalInputState: ReturnType<typeof createOverlayModalInputState>;
overlayContentMeasurementStore: ReturnType<typeof createOverlayContentMeasurementStore>;
overlayModalRuntime: ReturnType<typeof createOverlayModalRuntimeService>;
appState: ReturnType<typeof createAppState>;
appLifecycleApp: {
requestSingleInstanceLock: () => boolean;
quit: () => void;
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
whenReady: () => Promise<void>;
};
};
const {
configDir: CONFIG_DIR,
userDataPath: USER_DATA_PATH,
@@ -1003,7 +1014,7 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
mpvYtdlFormat: YOUTUBE_MPV_YTDL_FORMAT,
autoLaunchTimeoutMs: YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS,
connectTimeoutMs: YOUTUBE_MPV_CONNECT_TIMEOUT_MS,
getSocketPath: () => appState.mpvSocketPath,
socketPath: appState.mpvSocketPath,
getMpvConnected: () => Boolean(appState.mpvClient?.connected),
invalidatePendingAutoplayReadyFallbacks: () =>
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(),
@@ -1037,12 +1048,6 @@ const resolveWindowsMpvShortcutRuntimePaths = () =>
appDataDir: app.getPath('appData'),
desktopDir: app.getPath('desktop'),
});
syncInstalledFirstRunPluginBinaryPath({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
binaryPath: process.execPath,
});
const firstRunSetupService = createFirstRunSetupService({
platform: process.platform,
configDir: CONFIG_DIR,
@@ -1072,7 +1077,6 @@ const firstRunSetupService = createFirstRunSetupService({
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
binaryPath: process.execPath,
}),
detectWindowsMpvShortcuts: () => {
if (process.platform !== 'win32') {
@@ -1124,6 +1128,26 @@ const discordPresenceRuntime = createDiscordPresenceRuntime({
},
});
function createDiscordRpcClient() {
const discordRpc = require('discord-rpc') as {
Client: new (opts: { transport: 'ipc' }) => {
login: (opts: { clientId: string }) => Promise<void>;
setActivity: (activity: Record<string, unknown>) => Promise<void>;
clearActivity: () => Promise<void>;
destroy: () => void;
};
};
const client = new discordRpc.Client({ transport: 'ipc' });
return {
login: () => client.login({ clientId: DISCORD_PRESENCE_APP_ID }),
setActivity: (activity: unknown) =>
client.setActivity(activity as unknown as Record<string, unknown>),
clearActivity: () => client.clearActivity(),
destroy: () => client.destroy(),
};
}
async function initializeDiscordPresenceService(): Promise<void> {
if (getResolvedConfig().discordPresence.enabled !== true) {
appState.discordPresenceService = null;
@@ -1132,7 +1156,7 @@ async function initializeDiscordPresenceService(): Promise<void> {
appState.discordPresenceService = createDiscordPresenceService({
config: getResolvedConfig().discordPresence,
createClient: () => createDiscordRpcClient(DISCORD_PRESENCE_APP_ID),
createClient: () => createDiscordRpcClient(),
logDebug: (message, meta) => logger.debug(message, meta),
});
await appState.discordPresenceService.start();
@@ -1400,14 +1424,13 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
void refreshSubtitlePrefetchFromActiveTrackHandler();
}, delayMs);
}
const subtitlePrefetchRuntime = {
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
refreshSubtitleSidebarFromSource: (sourcePath: string) => refreshSubtitleSidebarFromSource(sourcePath),
const subtitlePrefetchRuntime = composeSubtitlePrefetchRuntime({
subtitlePrefetchInitController,
refreshSubtitleSidebarFromSource: (sourcePath) => refreshSubtitleSidebarFromSource(sourcePath),
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
scheduleSubtitlePrefetchRefresh: (delayMs) => scheduleSubtitlePrefetchRefresh(delayMs),
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
} as const;
});
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
createBuildOverlayShortcutsRuntimeMainDepsHandler({
@@ -1893,7 +1916,7 @@ const buildOpenRuntimeOptionsPaletteMainDepsHandler =
createBuildOpenRuntimeOptionsPaletteMainDepsHandler({
openRuntimeOptionsPaletteRuntime: () => overlayModalRuntime.openRuntimeOptionsPalette(),
});
const overlayVisibilityComposer = composeOverlayVisibilityRuntime({
const overlayVisibilityComposer = composeBootOverlayVisibilityRuntime({
overlayVisibilityRuntime,
restorePreviousSecondarySubVisibilityMainDeps:
buildRestorePreviousSecondarySubVisibilityMainDepsHandler(),
@@ -1965,7 +1988,7 @@ const {
stopJellyfinRemoteSession,
runJellyfinCommand,
openJellyfinSetupWindow,
} = composeJellyfinRuntimeHandlers({
} = composeBootJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => getResolvedConfig(),
loadStoredSession: () => jellyfinTokenStore.loadSession(),
@@ -2265,7 +2288,7 @@ const {
consumeAnilistSetupTokenFromUrl,
handleAnilistSetupProtocolUrl,
registerSubminerProtocolClient,
} = composeAnilistSetupHandlers({
} = composeBootAnilistSetupHandlers({
notifyDeps: {
hasMpvClient: () => Boolean(appState.mpvClient),
showMpvOsd: (message) => showMpvOsd(message),
@@ -2316,10 +2339,10 @@ const {
},
});
const maybeFocusExistingAnilistSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({
const maybeFocusExistingAnilistSetupWindow = createBootMaybeFocusExistingAnilistSetupWindowHandler({
getSetupWindow: () => appState.anilistSetupWindow,
});
const buildOpenAnilistSetupWindowMainDepsHandler = createBuildOpenAnilistSetupWindowMainDepsHandler(
const buildOpenAnilistSetupWindowMainDepsHandler = createBootBuildOpenAnilistSetupWindowMainDepsHandler(
{
maybeFocusExistingSetupWindow: maybeFocusExistingAnilistSetupWindow,
createSetupWindow: createCreateAnilistSetupWindowHandler({
@@ -2367,7 +2390,7 @@ const buildOpenAnilistSetupWindowMainDepsHandler = createBuildOpenAnilistSetupWi
);
function openAnilistSetupWindow(): void {
createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())();
createBootOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())();
}
const {
@@ -2381,7 +2404,7 @@ const {
ensureAnilistMediaGuess,
processNextAnilistRetryUpdate,
maybeRunAnilistPostWatchUpdate,
} = composeAnilistTrackingHandlers({
} = composeBootAnilistTrackingHandlers({
refreshClientSecretMainDeps: {
getResolvedConfig: () => getResolvedConfig(),
isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig),
@@ -2648,7 +2671,7 @@ const {
onWillQuitCleanup: onWillQuitCleanupHandler,
shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler,
restoreWindowsOnActivate: restoreWindowsOnActivateHandler,
} = composeStartupLifecycleHandlers({
} = composeBootStartupLifecycleHandlers({
registerProtocolUrlHandlersMainDeps: {
registerOpenUrl: (listener) => {
app.on('open-url', listener);
@@ -2956,7 +2979,7 @@ const ensureImmersionTrackerStarted = (): void => {
hasAttemptedImmersionTrackerStartup = true;
createImmersionTrackerStartup();
};
const statsStartupRuntime = {
const statsStartupRuntime = composeBootStatsStartupRuntime({
ensureStatsServerStarted: () => ensureStatsServerStarted(),
ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(),
stopBackgroundStatsServer: () => stopBackgroundStatsServer(),
@@ -2968,9 +2991,9 @@ const statsStartupRuntime = {
appState.statsStartupInProgress = false;
}
},
} as const;
});
const runStatsCliCommand = createRunStatsCliCommandHandler({
const runStatsCliCommand = createBootRunStatsCliCommandHandler({
getResolvedConfig: () => getResolvedConfig(),
ensureImmersionTrackerStarted: () => statsStartupRuntime.ensureImmersionTrackerStarted(),
ensureVocabularyCleanupTokenizerReady: async () => {
@@ -3037,7 +3060,7 @@ async function runHeadlessInitialCommand(): Promise<void> {
}
}
const { appReadyRuntimeRunner } = composeAppReadyRuntime({
const { appReadyRuntimeRunner } = composeBootAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => configService.reloadConfigStrict(),
logInfo: (message) => appLogger.logInfo(message),
@@ -3193,9 +3216,21 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
shouldRunHeadlessInitialCommand: () =>
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
shouldUseMinimalStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
Boolean(
appState.initialArgs?.texthooker ||
(appState.initialArgs?.stats &&
(appState.initialArgs?.statsCleanup ||
appState.initialArgs?.statsBackground ||
appState.initialArgs?.statsStop)),
),
shouldSkipHeavyStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
Boolean(
appState.initialArgs &&
(shouldRunSettingsOnlyStartup(appState.initialArgs) ||
appState.initialArgs.stats ||
appState.initialArgs.dictionary ||
appState.initialArgs.setup),
),
createImmersionTracker: () => {
ensureImmersionTrackerStarted();
},
@@ -3260,7 +3295,7 @@ const {
startBackgroundWarmups,
startTokenizationWarmups,
isTokenizationWarmupReady,
} = composeMpvRuntimeHandlers<
} = composeBootMpvRuntimeHandlers<
MpvIpcClient,
ReturnType<typeof createTokenizerDepsRuntime>,
SubtitleData
@@ -4107,7 +4142,7 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
showMpvOsd: (text) => showMpvOsd(text),
});
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
const { registerIpcRuntimeHandlers } = composeBootIpcRuntimeHandlers({
mpvCommandMainDeps: {
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
@@ -4216,16 +4251,16 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
};
}
try {
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
try {
const content = await loadSubtitleSourceText(resolvedSource.path);
const cues = parseSubtitleCues(content, resolvedSource.path);
appState.activeParsedSubtitleCues = cues;
@@ -4327,7 +4362,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
registerIpcRuntimeServices,
},
});
const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
const { handleCliCommand, handleInitialArgs } = composeBootCliStartupHandlers({
cliCommandContextMainDeps: {
appState,
setLogLevel: (level) => setLogLevel(level, 'cli'),
@@ -4413,7 +4448,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
logInfo: (message) => logger.info(message),
},
});
const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
const { runAndApplyStartupState } = composeBootHeadlessStartupHandlers<
CliArgs,
StartupState,
ReturnType<typeof createStartupBootstrapRuntimeDeps>
@@ -4475,22 +4510,13 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
});
runAndApplyStartupState();
const startupModeFlags = getStartupModeFlags(appState.initialArgs);
const shouldUseMinimalStartup = startupModeFlags.shouldUseMinimalStartup;
const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup;
if (!appState.initialArgs || (!shouldUseMinimalStartup && !shouldSkipHeavyStartup)) {
if (isAnilistTrackingEnabled(getResolvedConfig())) {
void refreshAnilistClientSecretStateIfEnabled({ force: true }).catch((error) => {
logger.error('Failed to refresh AniList client secret state during startup', error);
});
anilistStateRuntime.refreshRetryQueueState();
}
void initializeDiscordPresenceService().catch((error) => {
logger.error('Failed to initialize Discord presence service during startup', error);
});
if (isAnilistTrackingEnabled(getResolvedConfig())) {
void refreshAnilistClientSecretStateIfEnabled({ force: true });
anilistStateRuntime.refreshRetryQueueState();
}
void initializeDiscordPresenceService();
const { createMainWindow: createMainWindowHandler, createModalWindow: createModalWindowHandler } =
createOverlayWindowRuntimeHandlers<BrowserWindow>({
composeBootOverlayWindowHandlers<BrowserWindow>({
createOverlayWindowDeps: {
createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options),
isDev,
@@ -4516,7 +4542,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
setModalWindow: (window) => overlayManager.setModalWindow(window),
});
const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
createTrayRuntimeHandlers({
createBootTrayRuntimeHandlers({
resolveTrayIconPathDeps: {
resolveTrayIconPathRuntime,
platform: process.platform,
@@ -4563,12 +4589,12 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
},
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
});
const yomitanProfilePolicy = createYomitanProfilePolicy({
const yomitanProfilePolicy = createBootYomitanProfilePolicy({
externalProfilePath: getResolvedConfig().yomitan.externalProfilePath,
logInfo: (message) => logger.info(message),
});
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
const yomitanExtensionRuntime = createYomitanExtensionRuntime({
const yomitanExtensionRuntime = createBootYomitanExtensionRuntime({
loadYomitanExtensionCore,
userDataPath: USER_DATA_PATH,
externalProfilePath: configuredExternalYomitanProfilePath,
@@ -4650,7 +4676,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
},
},
});
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
const { openYomitanSettings: openYomitanSettingsHandler } = createBootYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
getYomitanSession: () => appState.yomitanSession,
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {

View File

@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMainBootHandlers } from './handlers';
test('createMainBootHandlers returns grouped handler bundles', () => {
const handlers = createMainBootHandlers<any, any, any, any>({
startupLifecycleDeps: {
registerProtocolUrlHandlersMainDeps: {} as never,
onWillQuitCleanupMainDeps: {} as never,
shouldRestoreWindowsOnActivateMainDeps: {} as never,
restoreWindowsOnActivateMainDeps: {} as never,
},
ipcRuntimeDeps: {
mpvCommandMainDeps: {} as never,
handleMpvCommandFromIpcRuntime: () => ({ ok: true }) as never,
runSubsyncManualFromIpc: () => Promise.resolve({ ok: true }) as never,
registration: {
runtimeOptions: {} as never,
mainDeps: {} as never,
ankiJimakuDeps: {} as never,
registerIpcRuntimeServices: () => {},
},
},
cliStartupDeps: {
cliCommandContextMainDeps: {} as never,
cliCommandRuntimeHandlerMainDeps: {} as never,
initialArgsRuntimeHandlerMainDeps: {} as never,
},
headlessStartupDeps: {
startupRuntimeHandlersDeps: {
appLifecycleRuntimeRunnerMainDeps: {
app: { on: () => {} } as never,
platform: 'darwin',
shouldStartApp: () => true,
parseArgs: () => ({}) as never,
handleCliCommand: () => {},
printHelp: () => {},
logNoRunningInstance: () => {},
onReady: async () => {},
onWillQuitCleanup: () => {},
shouldRestoreWindowsOnActivate: () => false,
restoreWindowsOnActivate: () => {},
shouldQuitOnWindowAllClosed: () => false,
},
createAppLifecycleRuntimeRunner: () => () => {},
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
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: (args) => startAppLifecycle(args as never),
}),
createStartupBootstrapRuntimeDeps: (deps) => ({
startAppLifecycle: deps.startAppLifecycle,
}),
runStartupBootstrapRuntime: () => ({ mode: 'started' } as never),
applyStartupState: () => {},
},
},
overlayWindowDeps: {
createOverlayWindowDeps: {
createOverlayWindowCore: (kind) => ({ kind }),
isDev: false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => {},
setOverlayDebugVisualizationEnabled: () => {},
isOverlayVisible: () => false,
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => {},
onWindowClosed: () => {},
getYomitanSession: () => null,
},
setMainWindow: () => {},
setModalWindow: () => {},
},
});
assert.equal(typeof handlers.startupLifecycle.registerProtocolUrlHandlers, 'function');
assert.equal(typeof handlers.ipcRuntime.registerIpcRuntimeHandlers, 'function');
assert.equal(typeof handlers.cliStartup.handleCliCommand, 'function');
assert.equal(typeof handlers.headlessStartup.runAndApplyStartupState, 'function');
assert.equal(typeof handlers.overlayWindow.createMainWindow, 'function');
});

40
src/main/boot/handlers.ts Normal file
View File

@@ -0,0 +1,40 @@
import { composeOverlayWindowHandlers } from '../runtime/composers/overlay-window-composer';
import {
composeCliStartupHandlers,
composeHeadlessStartupHandlers,
composeIpcRuntimeHandlers,
composeStartupLifecycleHandlers,
} from '../runtime/composers';
export interface MainBootHandlersParams<TBrowserWindow, TCliArgs, TStartupState, TBootstrapDeps> {
startupLifecycleDeps: Parameters<typeof composeStartupLifecycleHandlers>[0];
ipcRuntimeDeps: Parameters<typeof composeIpcRuntimeHandlers>[0];
cliStartupDeps: Parameters<typeof composeCliStartupHandlers>[0];
headlessStartupDeps: Parameters<
typeof composeHeadlessStartupHandlers<TCliArgs, TStartupState, TBootstrapDeps>
>[0];
overlayWindowDeps: Parameters<typeof composeOverlayWindowHandlers<TBrowserWindow>>[0];
}
export function createMainBootHandlers<
TBrowserWindow,
TCliArgs,
TStartupState,
TBootstrapDeps,
>(params: MainBootHandlersParams<TBrowserWindow, TCliArgs, TStartupState, TBootstrapDeps>) {
return {
startupLifecycle: composeStartupLifecycleHandlers(params.startupLifecycleDeps),
ipcRuntime: composeIpcRuntimeHandlers(params.ipcRuntimeDeps),
cliStartup: composeCliStartupHandlers(params.cliStartupDeps),
headlessStartup: composeHeadlessStartupHandlers<TCliArgs, TStartupState, TBootstrapDeps>(
params.headlessStartupDeps,
),
overlayWindow: composeOverlayWindowHandlers<TBrowserWindow>(params.overlayWindowDeps),
};
}
export const composeBootStartupLifecycleHandlers = composeStartupLifecycleHandlers;
export const composeBootIpcRuntimeHandlers = composeIpcRuntimeHandlers;
export const composeBootCliStartupHandlers = composeCliStartupHandlers;
export const composeBootHeadlessStartupHandlers = composeHeadlessStartupHandlers;
export const composeBootOverlayWindowHandlers = composeOverlayWindowHandlers;

View File

@@ -0,0 +1,339 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMainBootRuntimes } from './runtimes';
test('createMainBootRuntimes returns grouped runtime bundles', () => {
const runtimes = createMainBootRuntimes<any, any, any, any>({
overlayVisibilityRuntimeDeps: {
overlayVisibilityRuntime: {} as never,
restorePreviousSecondarySubVisibilityMainDeps: {} as never,
broadcastRuntimeOptionsChangedMainDeps: {} as never,
sendToActiveOverlayWindowMainDeps: {} as never,
setOverlayDebugVisualizationEnabledMainDeps: {} as never,
openRuntimeOptionsPaletteMainDeps: {} as never,
},
jellyfinRuntimeHandlerDeps: {
getResolvedJellyfinConfigMainDeps: {} as never,
getJellyfinClientInfoMainDeps: {} as never,
waitForMpvConnectedMainDeps: {} as never,
launchMpvIdleForJellyfinPlaybackMainDeps: {} as never,
ensureMpvConnectedForJellyfinPlaybackMainDeps: {} as never,
preloadJellyfinExternalSubtitlesMainDeps: {} as never,
playJellyfinItemInMpvMainDeps: {} as never,
remoteComposerOptions: {} as never,
handleJellyfinAuthCommandsMainDeps: {} as never,
handleJellyfinListCommandsMainDeps: {} as never,
handleJellyfinPlayCommandMainDeps: {} as never,
handleJellyfinRemoteAnnounceCommandMainDeps: {} as never,
startJellyfinRemoteSessionMainDeps: {} as never,
stopJellyfinRemoteSessionMainDeps: {} as never,
runJellyfinCommandMainDeps: {} as never,
maybeFocusExistingJellyfinSetupWindowMainDeps: {} as never,
openJellyfinSetupWindowMainDeps: {} as never,
},
anilistSetupDeps: {
notifyDeps: {} as never,
consumeTokenDeps: {} as never,
handleProtocolDeps: {} as never,
registerProtocolClientDeps: {} as never,
},
buildOpenAnilistSetupWindowMainDeps: {
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => null as never,
buildAuthorizeUrl: () => 'https://example.test',
consumeCallbackUrl: () => false,
openSetupInBrowser: () => {},
loadManualTokenEntry: () => Promise.resolve(),
redirectUri: 'https://example.test/callback',
developerSettingsUrl: 'https://example.test/dev',
isAllowedExternalUrl: () => true,
isAllowedNavigationUrl: () => true,
logWarn: () => {},
logError: () => {},
clearSetupWindow: () => {},
setSetupPageOpened: () => {},
setSetupWindow: () => {},
openExternal: () => {},
},
anilistTrackingDeps: {
refreshClientSecretMainDeps: {} as never,
getCurrentMediaKeyMainDeps: {} as never,
resetMediaTrackingMainDeps: {} as never,
getMediaGuessRuntimeStateMainDeps: {} as never,
setMediaGuessRuntimeStateMainDeps: {} as never,
resetMediaGuessStateMainDeps: {} as never,
maybeProbeDurationMainDeps: {} as never,
ensureMediaGuessMainDeps: {} as never,
processNextRetryUpdateMainDeps: {} as never,
maybeRunPostWatchUpdateMainDeps: {} as never,
},
statsStartupRuntimeDeps: {
ensureStatsServerStarted: () => '',
ensureBackgroundStatsServerStarted: () => ({ url: '', runningInCurrentProcess: false }),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
ensureImmersionTrackerStarted: () => {},
},
runStatsCliCommandDeps: {
getResolvedConfig: () => ({}) as never,
ensureImmersionTrackerStarted: () => {},
ensureVocabularyCleanupTokenizerReady: async () => {},
getImmersionTracker: () => null,
ensureStatsServerStarted: () => '',
ensureBackgroundStatsServerStarted: () => ({ url: '', runningInCurrentProcess: false }),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
openExternal: () => Promise.resolve(),
writeResponse: () => {},
exitAppWithCode: () => {},
logInfo: () => {},
logWarn: () => {},
logError: () => {},
},
appReadyRuntimeDeps: {
reloadConfigMainDeps: {
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
startConfigHotReload: () => {},
refreshAnilistClientSecretState: async () => {},
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
criticalConfigErrorMainDeps: {
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
appReadyRuntimeMainDeps: {
ensureDefaultConfigBootstrap: () => {},
loadSubtitlePosition: () => {},
resolveKeybindings: () => {},
createMpvClient: () => {},
getResolvedConfig: () => ({}) as never,
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {},
initRuntimeOptionsManager: () => {},
setSecondarySubMode: () => {},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {},
createMecabTokenizerAndCheck: async () => {},
createSubtitleTimingTracker: () => {},
loadYomitanExtension: async () => {},
handleFirstRunSetup: async () => {},
startJellyfinRemoteSession: async () => {},
prewarmSubtitleDictionaries: async () => {},
startBackgroundWarmups: () => {},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {},
initializeOverlayRuntime: () => {},
handleInitialArgs: () => {},
logDebug: () => {},
now: () => Date.now(),
},
immersionTrackerStartupMainDeps: {
getResolvedConfig: () => ({}) as never,
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
createTrackerService: () =>
({
startSession: () => {},
}) as never,
setTracker: () => {},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => {},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
},
},
mpvRuntimeDeps: {
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class FakeMpvClient {
connected = true;
on(): void {}
connect(): void {}
} as never,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => ({
subPos: 100,
subFontSize: 36,
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: '',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
osdDimensions: null,
}),
setCurrentMetrics: () => {},
applyPatch: (current: any) => ({ next: current, changed: false }),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({}),
tokenizeSubtitle: async () => ({ text: '' }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({}) as never,
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {},
ensureFrequencyDictionaryLookup: async () => {},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
},
trayRuntimeDeps: {
resolveTrayIconPathDeps: {} as never,
buildTrayMenuTemplateDeps: {} as never,
ensureTrayDeps: {} as never,
destroyTrayDeps: {} as never,
buildMenuFromTemplate: () => ({}) as never,
},
yomitanProfilePolicyDeps: {
externalProfilePath: '',
logInfo: () => {},
},
yomitanExtensionRuntimeDeps: {
loadYomitanExtensionCore: async () => null,
userDataPath: '/tmp',
externalProfilePath: '',
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
setYomitanParserReadyPromise: () => {},
setYomitanParserInitPromise: () => {},
setYomitanExtension: () => {},
setYomitanSession: () => {},
getYomitanExtension: () => null,
getLoadInFlight: () => null,
setLoadInFlight: () => {},
},
yomitanSettingsRuntimeDeps: {
ensureYomitanExtensionLoaded: async () => {},
getYomitanSession: () => null,
openYomitanSettingsWindow: () => {},
getExistingWindow: () => null,
setWindow: () => {},
logWarn: () => {},
logError: () => {},
},
createOverlayRuntimeBootstrapHandlers: () => ({
initializeOverlayRuntime: () => {},
}),
initializeOverlayRuntimeMainDeps: {},
initializeOverlayRuntimeBootstrapDeps: {},
});
assert.equal(typeof runtimes.overlayVisibilityComposer.sendToActiveOverlayWindow, 'function');
assert.equal(typeof runtimes.jellyfinRuntimeHandlers.runJellyfinCommand, 'function');
assert.equal(typeof runtimes.anilistSetupHandlers.notifyAnilistSetup, 'function');
assert.equal(typeof runtimes.openAnilistSetupWindow, 'function');
assert.equal(typeof runtimes.anilistTrackingHandlers.maybeRunAnilistPostWatchUpdate, 'function');
assert.equal(typeof runtimes.runStatsCliCommand, 'function');
assert.equal(typeof runtimes.appReadyRuntime.appReadyRuntimeRunner, 'function');
assert.equal(typeof runtimes.initializeOverlayRuntime, 'function');
});

127
src/main/boot/runtimes.ts Normal file
View File

@@ -0,0 +1,127 @@
import { createOpenFirstRunSetupWindowHandler } from '../runtime/first-run-setup-window';
import { createRunStatsCliCommandHandler } from '../runtime/stats-cli-command';
import { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy';
import {
createBuildOpenAnilistSetupWindowMainDepsHandler,
createMaybeFocusExistingAnilistSetupWindowHandler,
createOpenAnilistSetupWindowHandler,
} from '../runtime/domains/anilist';
import {
createTrayRuntimeHandlers,
createYomitanExtensionRuntime,
createYomitanSettingsRuntime,
} from '../runtime/domains/overlay';
import {
composeAnilistSetupHandlers,
composeAnilistTrackingHandlers,
composeAppReadyRuntime,
composeJellyfinRuntimeHandlers,
composeMpvRuntimeHandlers,
composeOverlayVisibilityRuntime,
composeStatsStartupRuntime,
} from '../runtime/composers';
export interface MainBootRuntimesParams<TBrowserWindow, TMpvClient, TTokenizerDeps, TSubtitleData> {
overlayVisibilityRuntimeDeps: Parameters<typeof composeOverlayVisibilityRuntime>[0];
jellyfinRuntimeHandlerDeps: Parameters<typeof composeJellyfinRuntimeHandlers>[0];
anilistSetupDeps: Parameters<typeof composeAnilistSetupHandlers>[0];
buildOpenAnilistSetupWindowMainDeps: Parameters<
typeof createBuildOpenAnilistSetupWindowMainDepsHandler
>[0];
anilistTrackingDeps: Parameters<typeof composeAnilistTrackingHandlers>[0];
statsStartupRuntimeDeps: Parameters<typeof composeStatsStartupRuntime>[0];
runStatsCliCommandDeps: Parameters<typeof createRunStatsCliCommandHandler>[0];
appReadyRuntimeDeps: Parameters<typeof composeAppReadyRuntime>[0];
mpvRuntimeDeps: any;
trayRuntimeDeps: Parameters<typeof createTrayRuntimeHandlers>[0];
yomitanProfilePolicyDeps: Parameters<typeof createYomitanProfilePolicy>[0];
yomitanExtensionRuntimeDeps: Parameters<typeof createYomitanExtensionRuntime>[0];
yomitanSettingsRuntimeDeps: Parameters<typeof createYomitanSettingsRuntime>[0];
createOverlayRuntimeBootstrapHandlers: (params: {
initializeOverlayRuntimeMainDeps: unknown;
initializeOverlayRuntimeBootstrapDeps: unknown;
}) => {
initializeOverlayRuntime: () => void;
};
initializeOverlayRuntimeMainDeps: unknown;
initializeOverlayRuntimeBootstrapDeps: unknown;
}
export function createMainBootRuntimes<
TBrowserWindow,
TMpvClient,
TTokenizerDeps,
TSubtitleData,
>(
params: MainBootRuntimesParams<TBrowserWindow, TMpvClient, TTokenizerDeps, TSubtitleData>,
) {
const overlayVisibilityComposer = composeOverlayVisibilityRuntime(
params.overlayVisibilityRuntimeDeps,
);
const jellyfinRuntimeHandlers = composeJellyfinRuntimeHandlers(
params.jellyfinRuntimeHandlerDeps,
);
const anilistSetupHandlers = composeAnilistSetupHandlers(params.anilistSetupDeps);
const buildOpenAnilistSetupWindowMainDepsHandler =
createBuildOpenAnilistSetupWindowMainDepsHandler(params.buildOpenAnilistSetupWindowMainDeps);
const maybeFocusExistingAnilistSetupWindow =
params.buildOpenAnilistSetupWindowMainDeps.maybeFocusExistingSetupWindow;
const anilistTrackingHandlers = composeAnilistTrackingHandlers(params.anilistTrackingDeps);
const statsStartupRuntime = composeStatsStartupRuntime(params.statsStartupRuntimeDeps);
const runStatsCliCommand = createRunStatsCliCommandHandler(params.runStatsCliCommandDeps);
const appReadyRuntime = composeAppReadyRuntime(params.appReadyRuntimeDeps);
const mpvRuntimeHandlers = composeMpvRuntimeHandlers<any, any, any>(
params.mpvRuntimeDeps as any,
);
const trayRuntimeHandlers = createTrayRuntimeHandlers(params.trayRuntimeDeps);
const yomitanProfilePolicy = createYomitanProfilePolicy(params.yomitanProfilePolicyDeps);
const yomitanExtensionRuntime = createYomitanExtensionRuntime(
params.yomitanExtensionRuntimeDeps,
);
const yomitanSettingsRuntime = createYomitanSettingsRuntime(
params.yomitanSettingsRuntimeDeps,
);
const overlayRuntimeBootstrapHandlers = params.createOverlayRuntimeBootstrapHandlers({
initializeOverlayRuntimeMainDeps: params.initializeOverlayRuntimeMainDeps,
initializeOverlayRuntimeBootstrapDeps: params.initializeOverlayRuntimeBootstrapDeps,
});
return {
overlayVisibilityComposer,
jellyfinRuntimeHandlers,
anilistSetupHandlers,
maybeFocusExistingAnilistSetupWindow,
buildOpenAnilistSetupWindowMainDepsHandler,
openAnilistSetupWindow: () =>
createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(),
anilistTrackingHandlers,
statsStartupRuntime,
runStatsCliCommand,
appReadyRuntime,
mpvRuntimeHandlers,
trayRuntimeHandlers,
yomitanProfilePolicy,
yomitanExtensionRuntime,
yomitanSettingsRuntime,
initializeOverlayRuntime: overlayRuntimeBootstrapHandlers.initializeOverlayRuntime,
openFirstRunSetupWindowHandler: createOpenFirstRunSetupWindowHandler,
};
}
export const composeBootOverlayVisibilityRuntime = composeOverlayVisibilityRuntime;
export const composeBootJellyfinRuntimeHandlers = composeJellyfinRuntimeHandlers;
export const composeBootAnilistSetupHandlers = composeAnilistSetupHandlers;
export const composeBootAnilistTrackingHandlers = composeAnilistTrackingHandlers;
export const composeBootStatsStartupRuntime = composeStatsStartupRuntime;
export const createBootRunStatsCliCommandHandler = createRunStatsCliCommandHandler;
export const composeBootAppReadyRuntime = composeAppReadyRuntime;
export const composeBootMpvRuntimeHandlers = composeMpvRuntimeHandlers;
export const createBootTrayRuntimeHandlers = createTrayRuntimeHandlers;
export const createBootYomitanProfilePolicy = createYomitanProfilePolicy;
export const createBootYomitanExtensionRuntime = createYomitanExtensionRuntime;
export const createBootYomitanSettingsRuntime = createYomitanSettingsRuntime;
export const createBootMaybeFocusExistingAnilistSetupWindowHandler =
createMaybeFocusExistingAnilistSetupWindowHandler;
export const createBootBuildOpenAnilistSetupWindowMainDepsHandler =
createBuildOpenAnilistSetupWindowMainDepsHandler;
export const createBootOpenAnilistSetupWindowHandler = createOpenAnilistSetupWindowHandler;

View File

@@ -24,7 +24,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean },
{ getModalWindow: () => null },
{ inputState: boolean; getModalInputExclusive: () => boolean; handleModalInputStateChange: (isActive: boolean) => void },
{ inputState: boolean },
{ measurementStore: boolean },
{ modalRuntime: boolean },
{ mpvSocketPath: string; texthookerPort: number },
@@ -50,7 +50,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
setPathValue = value;
},
quit: () => {},
on: (event: string) => {
on: (event) => {
appOnCalls.push(event);
return {};
},
@@ -80,7 +80,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
createOverlayManager: () => ({
getModalWindow: () => null,
}),
createOverlayModalInputState: () => ({ inputState: true, getModalInputExclusive: () => false, handleModalInputStateChange: () => {} }),
createOverlayModalInputState: () => ({ inputState: true }),
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
getSyncOverlayShortcutsForModal: () => () => {},
getSyncOverlayVisibilityForModal: () => () => {},

View File

@@ -1,18 +1,5 @@
import type { BrowserWindow } from 'electron';
import { ConfigStartupParseError } from '../../config';
export interface AppLifecycleShape {
requestSingleInstanceLock: () => boolean;
quit: () => void;
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
whenReady: () => Promise<void>;
}
export interface OverlayModalInputStateShape {
getModalInputExclusive: () => boolean;
handleModalInputStateChange: (isActive: boolean) => void;
}
export interface MainBootServicesParams<
TConfigService,
TAnilistTokenStore,
@@ -50,8 +37,7 @@ export interface MainBootServicesParams<
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;
on: (...args: any[]) => unknown;
whenReady: () => Promise<void>;
};
shouldBypassSingleInstanceLock: () => boolean;
@@ -72,11 +58,7 @@ export interface MainBootServicesParams<
};
createMainRuntimeRegistry: () => TRuntimeRegistry;
createOverlayManager: () => TOverlayManager;
createOverlayModalInputState: (params: {
getModalWindow: () => BrowserWindow | null;
syncOverlayShortcutsForModal: (isActive: boolean) => void;
syncOverlayVisibilityForModal: () => void;
}) => TOverlayModalInputState;
createOverlayModalInputState: (params: any) => TOverlayModalInputState;
createOverlayContentMeasurementStore: (params: {
logger: TLogger;
}) => TOverlayContentMeasurementStore;
@@ -136,12 +118,12 @@ export function createMainBootServices<
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager extends { getModalWindow: () => BrowserWindow | null },
TOverlayModalInputState extends OverlayModalInputStateShape,
TOverlayManager extends { getModalWindow: () => unknown },
TOverlayModalInputState,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp extends AppLifecycleShape,
TAppLifecycleApp,
>(
params: MainBootServicesParams<
TConfigService,
@@ -225,7 +207,8 @@ export function createMainBootServices<
overlayManager,
overlayModalInputState,
onModalStateChange: (isActive: boolean) =>
overlayModalInputState.handleModalInputStateChange(isActive),
(overlayModalInputState as { handleModalInputStateChange?: (isActive: boolean) => void })
.handleModalInputStateChange?.(isActive),
});
const appState = params.createAppState({
mpvSocketPath: params.getDefaultSocketPath(),
@@ -254,7 +237,7 @@ export function createMainBootServices<
return appLifecycleApp;
},
whenReady: () => params.app.whenReady(),
} satisfies AppLifecycleShape as TAppLifecycleApp;
} as TAppLifecycleApp;
return {
configDir,

View File

@@ -1 +1,6 @@
export { ensureDir } from '../../shared/fs-utils';
import * as fs from 'fs';
export function ensureDir(dirPath: string): void {
if (fs.existsSync(dirPath)) return;
fs.mkdirSync(dirPath, { recursive: true });
}

View File

@@ -33,66 +33,13 @@ test('autoplay ready gate suppresses duplicate media signals unless forced while
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
const firstScheduled = scheduled.shift();
firstScheduled?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
assert.deepEqual(commands.slice(0, 3), [
['script-message', 'subminer-autoplay-ready'],
['script-message', 'subminer-autoplay-ready'],
['script-message', 'subminer-autoplay-ready'],
]);
assert.ok(
commands.some(
(command) =>
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
);
assert.ok(commands.some((command) => command[0] === 'set_property' && command[1] === 'pause'));
assert.equal(scheduled.length > 0, true);
});
test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
for (const callback of scheduled.splice(0, 3)) {
callback();
await new Promise((resolve) => setTimeout(resolve, 0));
}
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
['script-message', 'subminer-autoplay-ready'],
]);
assert.equal(
commands.filter(
(command) =>
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length > 0,
true,
);
});

View File

@@ -46,6 +46,19 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const allowDuplicateWhilePaused =
options?.forceWhilePaused === true && deps.getPlaybackPaused() !== false;
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
return;
}
if (duplicateMediaSignal && allowDuplicateWhilePaused) {
deps.signalPluginAutoplayReady();
return;
}
autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady();
const releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
forceWhilePaused: options?.forceWhilePaused === true,
@@ -75,7 +88,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return true;
};
const attemptRelease = (playbackGeneration: number, attempt: number): void => {
const attemptRelease = (attempt: number): void => {
void (async () => {
if (
autoPlayReadySignalMediaPath !== mediaPath ||
@@ -87,7 +100,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const mpvClient = deps.getMpvClient();
if (!mpvClient?.connected) {
if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
deps.schedule(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
}
return;
}
@@ -97,27 +110,15 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return;
}
deps.signalPluginAutoplayReady();
mpvClient.send({ command: ['set_property', 'pause', false] });
if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
deps.schedule(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
}
})();
};
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
return;
}
if (!duplicateMediaSignal) {
autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady();
attemptRelease(playbackGeneration, 0);
return;
}
const playbackGeneration = ++autoPlayReadySignalGeneration;
attemptRelease(playbackGeneration, 0);
attemptRelease(0);
};
return {

View File

@@ -1,6 +1,5 @@
import * as fs from 'fs';
import * as path from 'path';
import { ensureDir } from '../../shared/fs-utils';
import type { AnilistCharacterDictionaryProfileScope } from '../../types';
import type {
CharacterDictionarySnapshotProgressCallbacks,
@@ -64,6 +63,12 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
onSyncComplete?: (result: { mediaId: number; mediaTitle: string; changed: boolean }) => void;
}
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function normalizeMediaId(rawMediaId: number): number | null {
const mediaId = Math.max(1, Math.floor(rawMediaId));
return Number.isFinite(mediaId) ? mediaId : null;

View File

@@ -3,14 +3,11 @@ import assert from 'node:assert/strict';
import { composeAnilistSetupHandlers } from './anilist-setup-composer';
test('composeAnilistSetupHandlers returns callable setup handlers', () => {
const calls: string[] = [];
const composed = composeAnilistSetupHandlers({
notifyDeps: {
hasMpvClient: () => false,
showMpvOsd: () => {},
showDesktopNotification: (title, opts) => {
calls.push(`notify:${opts.body}`);
},
showDesktopNotification: () => {},
logInfo: () => {},
},
consumeTokenDeps: {
@@ -40,16 +37,4 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
assert.equal(typeof composed.consumeAnilistSetupTokenFromUrl, 'function');
assert.equal(typeof composed.handleAnilistSetupProtocolUrl, 'function');
assert.equal(typeof composed.registerSubminerProtocolClient, 'function');
// notifyAnilistSetup forwards to showDesktopNotification when no MPV client
composed.notifyAnilistSetup('Setup complete');
assert.deepEqual(calls, ['notify:Setup complete']);
// handleAnilistSetupProtocolUrl returns false for non-subminer URLs
const handled = composed.handleAnilistSetupProtocolUrl('https://other.example.com/');
assert.equal(handled, false);
// handleAnilistSetupProtocolUrl returns true for subminer:// URLs
const handledProtocol = composed.handleAnilistSetupProtocolUrl('subminer://anilist-setup?code=abc');
assert.equal(handledProtocol, true);
});

View File

@@ -3,13 +3,9 @@ import test from 'node:test';
import { composeAppReadyRuntime } from './app-ready-composer';
test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => {
const calls: string[] = [];
const composed = composeAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => {
calls.push('reloadConfigStrict');
return { ok: true, path: '/tmp/config.jsonc', warnings: [] };
},
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
@@ -83,8 +79,4 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
assert.equal(typeof composed.reloadConfig, 'function');
assert.equal(typeof composed.criticalConfigError, 'function');
assert.equal(typeof composed.appReadyRuntimeRunner, 'function');
// reloadConfig invokes the injected reloadConfigStrict dep
composed.reloadConfig();
assert.deepEqual(calls, ['reloadConfigStrict']);
});

View File

@@ -1,10 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CliArgs } from '../../../cli/args';
import { composeCliStartupHandlers } from './cli-startup-composer';
test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
const calls: string[] = [];
const handlers = composeCliStartupHandlers({
cliCommandContextMainDeps: {
appState: {} as never,
@@ -59,9 +57,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
startBackgroundWarmups: () => {},
logInfo: () => {},
},
handleCliCommandRuntimeServiceWithContext: (args, _source, _ctx) => {
calls.push(`handleCommand:${(args as { command?: string }).command ?? 'unknown'}`);
},
handleCliCommandRuntimeServiceWithContext: () => {},
},
initialArgsRuntimeHandlerMainDeps: {
getInitialArgs: () => null,
@@ -84,8 +80,4 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
assert.equal(typeof handlers.createCliCommandContext, 'function');
assert.equal(typeof handlers.handleCliCommand, 'function');
assert.equal(typeof handlers.handleInitialArgs, 'function');
// handleCliCommand routes to the injected handleCliCommandRuntimeServiceWithContext dep
handlers.handleCliCommand({ command: 'start' } as unknown as CliArgs);
assert.deepEqual(calls, ['handleCommand:start']);
});

View File

@@ -8,6 +8,9 @@ export * from './ipc-runtime-composer';
export * from './jellyfin-remote-composer';
export * from './jellyfin-runtime-composer';
export * from './mpv-runtime-composer';
export * from './overlay-window-composer';
export * from './overlay-visibility-runtime-composer';
export * from './shortcuts-runtime-composer';
export * from './stats-startup-composer';
export * from './subtitle-prefetch-runtime-composer';
export * from './startup-lifecycle-composer';

View File

@@ -2,11 +2,8 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer';
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', async () => {
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', () => {
let lastProgressAt = 0;
let activePlayback: unknown = { itemId: 'item-1', mediaSourceId: 'src-1', playMethod: 'DirectPlay', audioStreamIndex: null, subtitleStreamIndex: null };
const calls: string[] = [];
const composed = composeJellyfinRemoteHandlers({
getConfiguredSession: () => null,
getClientInfo: () =>
@@ -17,11 +14,8 @@ test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers',
getMpvClient: () => null,
sendMpvCommand: () => {},
jellyfinTicksToSeconds: () => 0,
getActivePlayback: () => activePlayback as never,
clearActivePlayback: () => {
activePlayback = null;
calls.push('clearActivePlayback');
},
getActivePlayback: () => null,
clearActivePlayback: () => {},
getSession: () => null,
getNow: () => 0,
getLastProgressAtMs: () => lastProgressAt,
@@ -38,9 +32,4 @@ test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers',
assert.equal(typeof composed.handleJellyfinRemotePlay, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
// reportJellyfinRemoteStopped clears active playback when there is no connected session
await composed.reportJellyfinRemoteStopped();
assert.equal(activePlayback, null);
assert.deepEqual(calls, ['clearActivePlayback']);
});

View File

@@ -190,9 +190,4 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
assert.equal(typeof composed.runJellyfinCommand, 'function');
assert.equal(typeof composed.openJellyfinSetupWindow, 'function');
// getResolvedJellyfinConfig forwards to the injected getResolvedConfig dep
const jellyfinConfig = composed.getResolvedJellyfinConfig();
assert.equal(jellyfinConfig.enabled, false);
assert.equal(jellyfinConfig.serverUrl, '');
});

View File

@@ -30,13 +30,37 @@ function createDeferred(): { promise: Promise<void>; resolve: () => void } {
return { promise, resolve };
}
class DefaultFakeMpvClient {
connect(): void {}
on(): void {}
}
test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => {
const calls: string[] = [];
let started = false;
let metrics = BASE_METRICS;
let mecabTokenizer: { id: string } | null = null;
function createDefaultMpvFixture() {
return {
class FakeMpvClient {
connected = false;
constructor(
public socketPath: string,
public options: unknown,
) {
const autoStartOverlay = (options as { autoStartOverlay: boolean }).autoStartOverlay;
calls.push(`create-client:${socketPath}`);
calls.push(`auto-start:${String(autoStartOverlay)}`);
}
on(): void {}
connect(): void {
this.connected = true;
calls.push('client-connect');
}
}
const composed = composeMpvRuntimeHandlers<
FakeMpvClient,
{ isKnownWord: (text: string) => boolean },
{ text: string }
>({
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
@@ -73,119 +97,15 @@ function createDefaultMpvFixture() {
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: DefaultFakeMpvClient,
createClient: FakeMpvClient,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current: MpvSubtitleRenderMetrics, patch: Partial<MpvSubtitleRenderMetrics>) => ({
next: { ...current, ...patch },
changed: true,
}),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword' as const,
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword' as const,
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text: string) => ({ text }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {},
ensureFrequencyDictionaryLookup: async () => {},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
};
}
test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => {
const calls: string[] = [];
let started = false;
let metrics = BASE_METRICS;
let mecabTokenizer: { id: string } | null = null;
class FakeMpvClient {
connected = false;
constructor(
public socketPath: string,
public options: unknown,
) {
const autoStartOverlay = (options as { autoStartOverlay: boolean }).autoStartOverlay;
calls.push(`create-client:${socketPath}`);
calls.push(`auto-start:${String(autoStartOverlay)}`);
}
on(): void {}
connect(): void {
this.connected = true;
calls.push('client-connect');
}
}
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers<
FakeMpvClient,
{ isKnownWord: (text: string) => boolean },
{ text: string }
>({
...fixture,
mpvClientRuntimeServiceFactoryMainDeps: {
...fixture.mpvClientRuntimeServiceFactoryMainDeps,
createClient: FakeMpvClient,
isAutoStartOverlayEnabled: () => true,
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => metrics,
setCurrentMetrics: (next) => {
@@ -201,12 +121,25 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
},
},
tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: {
...fixture.tokenizer.buildTokenizerDepsMainDeps,
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: (text) => text === 'known',
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: (deps) => {
calls.push('create-tokenizer-runtime-deps');
@@ -251,12 +184,12 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
},
},
startBackgroundWarmupsMainDeps: {
...fixture.warmups.startBackgroundWarmupsMainDeps,
getStarted: () => started,
setStarted: (next) => {
started = next;
calls.push(`set-started:${String(next)}`);
},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {
calls.push('warmup-yomitan');
},
@@ -264,6 +197,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => true,
shouldWarmupJellyfinRemoteSession: () => true,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {
calls.push('warmup-jellyfin');
},
@@ -330,20 +264,86 @@ test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annota
}
}
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers<
FakeMpvClient,
{ isKnownWord: (text: string) => boolean },
{ text: string }
>({
...fixture,
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
...fixture.mpvClientRuntimeServiceFactoryMainDeps,
createClient: FakeMpvClient,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => mecabTokenizer,
setMecabTokenizer: (next) => {
@@ -358,6 +358,29 @@ test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annota
calls.push('check-mecab');
},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {},
ensureFrequencyDictionaryLookup: async () => {},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
@@ -372,19 +395,98 @@ test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential
let prewarmFrequencyCalls = 0;
const tokenizeCalls: string[] = [];
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void },
{ isKnownWord: () => boolean },
{ text: string }
>({
...fixture,
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => {
tokenizeCalls.push(text);
return { text };
},
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {
prewarmJlptCalls += 1;
@@ -395,12 +497,24 @@ test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential
},
},
warmups: {
...fixture.warmups,
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
...fixture.warmups.startBackgroundWarmupsMainDeps,
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {
yomitanWarmupCalls += 1;
},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
@@ -420,23 +534,93 @@ test('composeMpvRuntimeHandlers does not block first tokenization on dictionary
const mecabDeferred = createDeferred();
let tokenizeResolved = false;
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void },
{ isKnownWord: () => boolean },
{ text: string }
>({
...fixture,
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: {
...fixture.tokenizer.buildTokenizerDepsMainDeps,
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => true,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: {
...fixture.tokenizer.createMecabTokenizerAndCheckMainDeps,
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => mecabDeferred.promise,
},
prewarmSubtitleDictionariesMainDeps: {
@@ -444,6 +628,25 @@ test('composeMpvRuntimeHandlers does not block first tokenization on dictionary
ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise,
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => undefined,
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
const tokenizePromise = composed.tokenizeSubtitle('first line').then(() => {
@@ -464,19 +667,86 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
const frequencyDeferred = createDeferred();
const osdMessages: string[] = [];
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void },
{ onTokenizationReady?: (text: string) => void },
{ text: string }
>({
...fixture,
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: {
...fixture.tokenizer.buildTokenizerDepsMainDeps,
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: (deps) =>
deps as unknown as { onTokenizationReady?: (text: string) => void },
@@ -484,6 +754,12 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
deps.onTokenizationReady?.(text);
return { text };
},
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise,
@@ -492,6 +768,25 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => undefined,
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});
const warmupPromise = composed.startTokenizationWarmups();
@@ -519,22 +814,89 @@ test('composeMpvRuntimeHandlers reuses completed background tokenization warmups
let frequencyWarmupCalls = 0;
let mecabTokenizer: { tokenize: () => Promise<never[]> } | null = null;
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void },
{ isKnownWord: () => boolean },
{ text: string }
>({
...fixture,
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: {
...fixture.tokenizer.buildTokenizerDepsMainDeps,
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => true,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => mecabTokenizer,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => mecabTokenizer,
setMecabTokenizer: (next) => {
@@ -555,19 +917,26 @@ test('composeMpvRuntimeHandlers reuses completed background tokenization warmups
},
},
warmups: {
...fixture.warmups,
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
...fixture.warmups.startBackgroundWarmupsMainDeps,
getStarted: () => started,
setStarted: (next) => {
started = next;
},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {
yomitanWarmupCalls += 1;
},
shouldWarmupMecab: () => true,
shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => true,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
});

View File

@@ -3,20 +3,15 @@ import test from 'node:test';
import { composeOverlayVisibilityRuntime } from './overlay-visibility-runtime-composer';
test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () => {
const calls: string[] = [];
const composed = composeOverlayVisibilityRuntime({
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => {
calls.push('updateVisibleOverlayVisibility');
},
updateVisibleOverlayVisibility: () => {},
},
restorePreviousSecondarySubVisibilityMainDeps: {
getMpvClient: () => null,
},
broadcastRuntimeOptionsChangedMainDeps: {
broadcastRuntimeOptionsChangedRuntime: () => {
calls.push('broadcastRuntimeOptionsChangedRuntime');
},
broadcastRuntimeOptionsChangedRuntime: () => {},
getRuntimeOptionsState: () => [],
broadcastToOverlayWindows: () => {},
},
@@ -29,9 +24,7 @@ test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () =
setCurrentEnabled: () => {},
},
openRuntimeOptionsPaletteMainDeps: {
openRuntimeOptionsPaletteRuntime: () => {
calls.push('openRuntimeOptionsPaletteRuntime');
},
openRuntimeOptionsPaletteRuntime: () => {},
},
});
@@ -41,16 +34,4 @@ test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () =
assert.equal(typeof composed.sendToActiveOverlayWindow, 'function');
assert.equal(typeof composed.setOverlayDebugVisualizationEnabled, 'function');
assert.equal(typeof composed.openRuntimeOptionsPalette, 'function');
// updateVisibleOverlayVisibility passes through to the injected runtime dep
composed.updateVisibleOverlayVisibility();
assert.deepEqual(calls, ['updateVisibleOverlayVisibility']);
// openRuntimeOptionsPalette forwards to the injected runtime dep
composed.openRuntimeOptionsPalette();
assert.deepEqual(calls, ['updateVisibleOverlayVisibility', 'openRuntimeOptionsPaletteRuntime']);
// broadcastRuntimeOptionsChanged forwards to the injected runtime dep
composed.broadcastRuntimeOptionsChanged();
assert.ok(calls.includes('broadcastRuntimeOptionsChangedRuntime'));
});

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeOverlayWindowHandlers } from './overlay-window-composer';
test('composeOverlayWindowHandlers returns overlay window handlers', () => {
let mainWindow: { kind: string } | null = null;
let modalWindow: { kind: string } | null = null;
const handlers = composeOverlayWindowHandlers<{ kind: string }>({
createOverlayWindowDeps: {
createOverlayWindowCore: (kind) => ({ kind }),
isDev: false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => {},
setOverlayDebugVisualizationEnabled: () => {},
isOverlayVisible: (kind) => kind === 'visible',
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => {},
onWindowClosed: () => {},
getYomitanSession: () => null,
},
setMainWindow: (window) => {
mainWindow = window;
},
setModalWindow: (window) => {
modalWindow = window;
},
});
assert.deepEqual(handlers.createMainWindow(), { kind: 'visible' });
assert.deepEqual(mainWindow, { kind: 'visible' });
assert.deepEqual(handlers.createModalWindow(), { kind: 'modal' });
assert.deepEqual(modalWindow, { kind: 'modal' });
});

View File

@@ -0,0 +1,18 @@
import { createOverlayWindowRuntimeHandlers } from '../overlay-window-runtime-handlers';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type OverlayWindowRuntimeDeps<TWindow> =
Parameters<typeof createOverlayWindowRuntimeHandlers<TWindow>>[0];
type OverlayWindowRuntimeHandlers<TWindow> = ReturnType<
typeof createOverlayWindowRuntimeHandlers<TWindow>
>;
export type OverlayWindowComposerOptions<TWindow> = ComposerInputs<OverlayWindowRuntimeDeps<TWindow>>;
export type OverlayWindowComposerResult<TWindow> =
ComposerOutputs<OverlayWindowRuntimeHandlers<TWindow>>;
export function composeOverlayWindowHandlers<TWindow>(
options: OverlayWindowComposerOptions<TWindow>,
): OverlayWindowComposerResult<TWindow> {
return createOverlayWindowRuntimeHandlers<TWindow>(options);
}

View File

@@ -3,7 +3,6 @@ import test from 'node:test';
import { composeShortcutRuntimes } from './shortcuts-runtime-composer';
test('composeShortcutRuntimes returns callable shortcut runtime handlers', () => {
const calls: string[] = [];
const composed = composeShortcutRuntimes({
globalShortcuts: {
getConfiguredShortcutsMainDeps: {
@@ -40,13 +39,9 @@ test('composeShortcutRuntimes returns callable shortcut runtime handlers', () =>
},
overlayShortcutsRuntimeMainDeps: {
overlayShortcutsRuntime: {
registerOverlayShortcuts: () => {
calls.push('registerOverlayShortcuts');
},
registerOverlayShortcuts: () => {},
unregisterOverlayShortcuts: () => {},
syncOverlayShortcuts: () => {
calls.push('syncOverlayShortcuts');
},
syncOverlayShortcuts: () => {},
refreshOverlayShortcuts: () => {},
},
},
@@ -63,12 +58,4 @@ test('composeShortcutRuntimes returns callable shortcut runtime handlers', () =>
assert.equal(typeof composed.unregisterOverlayShortcuts, 'function');
assert.equal(typeof composed.syncOverlayShortcuts, 'function');
assert.equal(typeof composed.refreshOverlayShortcuts, 'function');
// registerOverlayShortcuts forwards to the injected overlayShortcutsRuntime dep
composed.registerOverlayShortcuts();
assert.deepEqual(calls, ['registerOverlayShortcuts']);
// syncOverlayShortcuts forwards to the injected overlayShortcutsRuntime dep
composed.syncOverlayShortcuts();
assert.deepEqual(calls, ['registerOverlayShortcuts', 'syncOverlayShortcuts']);
});

View File

@@ -3,7 +3,6 @@ import test from 'node:test';
import { composeStartupLifecycleHandlers } from './startup-lifecycle-composer';
test('composeStartupLifecycleHandlers returns callable startup lifecycle handlers', () => {
const calls: string[] = [];
const composed = composeStartupLifecycleHandlers({
registerProtocolUrlHandlersMainDeps: {
registerOpenUrl: () => {},
@@ -52,9 +51,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
getAllWindowCount: () => 0,
},
restoreWindowsOnActivateMainDeps: {
createMainWindow: () => {
calls.push('createMainWindow');
},
createMainWindow: () => {},
updateVisibleOverlayVisibility: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
},
@@ -64,11 +61,4 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
assert.equal(typeof composed.onWillQuitCleanup, 'function');
assert.equal(typeof composed.shouldRestoreWindowsOnActivate, 'function');
assert.equal(typeof composed.restoreWindowsOnActivate, 'function');
// shouldRestoreWindowsOnActivate returns false when overlay runtime is not initialized
assert.equal(composed.shouldRestoreWindowsOnActivate(), false);
// restoreWindowsOnActivate invokes the injected createMainWindow dep
composed.restoreWindowsOnActivate();
assert.deepEqual(calls, ['createMainWindow']);
});

View File

@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeStatsStartupRuntime } from './stats-startup-composer';
test('composeStatsStartupRuntime returns stats startup handlers', async () => {
const composed = composeStatsStartupRuntime({
ensureStatsServerStarted: () => 'http://127.0.0.1:8766',
ensureBackgroundStatsServerStarted: () => ({
url: 'http://127.0.0.1:8766',
runningInCurrentProcess: true,
}),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
ensureImmersionTrackerStarted: () => {},
});
assert.equal(composed.ensureStatsServerStarted(), 'http://127.0.0.1:8766');
assert.deepEqual(composed.ensureBackgroundStatsServerStarted(), {
url: 'http://127.0.0.1:8766',
runningInCurrentProcess: true,
});
assert.deepEqual(await composed.stopBackgroundStatsServer(), { ok: true, stale: false });
assert.equal(typeof composed.ensureImmersionTrackerStarted, 'function');
});

View File

@@ -0,0 +1,26 @@
import type { ComposerInputs, ComposerOutputs } from './contracts';
type BackgroundStatsStartResult = {
url: string;
runningInCurrentProcess: boolean;
};
type BackgroundStatsStopResult = {
ok: boolean;
stale: boolean;
};
export type StatsStartupComposerOptions = ComposerInputs<{
ensureStatsServerStarted: () => string;
ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult;
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
ensureImmersionTrackerStarted: () => void;
}>;
export type StatsStartupComposerResult = ComposerOutputs<StatsStartupComposerOptions>;
export function composeStatsStartupRuntime(
options: StatsStartupComposerOptions,
): StatsStartupComposerResult {
return options;
}

View File

@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeSubtitlePrefetchRuntime } from './subtitle-prefetch-runtime-composer';
test('composeSubtitlePrefetchRuntime returns subtitle prefetch runtime helpers', () => {
const composed = composeSubtitlePrefetchRuntime({
subtitlePrefetchInitController: {
cancelPendingInit: () => {},
initSubtitlePrefetch: async () => {},
},
refreshSubtitleSidebarFromSource: async () => {},
refreshSubtitlePrefetchFromActiveTrack: async () => {},
scheduleSubtitlePrefetchRefresh: () => {},
clearScheduledSubtitlePrefetchRefresh: () => {},
});
assert.equal(typeof composed.cancelPendingInit, 'function');
assert.equal(typeof composed.initSubtitlePrefetch, 'function');
assert.equal(typeof composed.refreshSubtitleSidebarFromSource, 'function');
assert.equal(typeof composed.refreshSubtitlePrefetchFromActiveTrack, 'function');
assert.equal(typeof composed.scheduleSubtitlePrefetchRefresh, 'function');
assert.equal(typeof composed.clearScheduledSubtitlePrefetchRefresh, 'function');
});

View File

@@ -0,0 +1,32 @@
import type { SubtitlePrefetchInitController } from '../subtitle-prefetch-init';
import type { ComposerInputs, ComposerOutputs } from './contracts';
export type SubtitlePrefetchRuntimeComposerOptions = ComposerInputs<{
subtitlePrefetchInitController: SubtitlePrefetchInitController;
refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise<void>;
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => void;
clearScheduledSubtitlePrefetchRefresh: () => void;
}>;
export type SubtitlePrefetchRuntimeComposerResult = ComposerOutputs<{
cancelPendingInit: () => void;
initSubtitlePrefetch: SubtitlePrefetchInitController['initSubtitlePrefetch'];
refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise<void>;
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => void;
clearScheduledSubtitlePrefetchRefresh: () => void;
}>;
export function composeSubtitlePrefetchRuntime(
options: SubtitlePrefetchRuntimeComposerOptions,
): SubtitlePrefetchRuntimeComposerResult {
return {
cancelPendingInit: () => options.subtitlePrefetchInitController.cancelPendingInit(),
initSubtitlePrefetch: options.subtitlePrefetchInitController.initSubtitlePrefetch,
refreshSubtitleSidebarFromSource: options.refreshSubtitleSidebarFromSource,
refreshSubtitlePrefetchFromActiveTrack: options.refreshSubtitlePrefetchFromActiveTrack,
scheduleSubtitlePrefetchRefresh: options.scheduleSubtitlePrefetchRefresh,
clearScheduledSubtitlePrefetchRefresh: options.clearScheduledSubtitlePrefetchRefresh,
};
}

View File

@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDiscordRpcClient } from './discord-rpc-client';
test('createDiscordRpcClient forwards rich presence calls through client.user', async () => {
const calls: Array<string> = [];
const rpcClient = createDiscordRpcClient('123456789012345678', {
createClient: () =>
({
login: async () => {
calls.push('login');
},
user: {
setActivity: async () => {
calls.push('setActivity');
},
clearActivity: async () => {
calls.push('clearActivity');
},
},
destroy: async () => {
calls.push('destroy');
},
}) as never,
});
await rpcClient.login();
await rpcClient.setActivity({
details: 'Title',
state: 'Playing 00:01 / 00:02',
startTimestamp: 1_700_000_000,
});
await rpcClient.clearActivity();
await rpcClient.destroy();
assert.deepEqual(calls, ['login', 'setActivity', 'clearActivity', 'destroy']);
});

View File

@@ -1,49 +0,0 @@
import { Client } from '@xhayper/discord-rpc';
import type { DiscordActivityPayload } from '../../core/services/discord-presence';
type DiscordRpcClientUserLike = {
setActivity: (activity: DiscordActivityPayload) => Promise<unknown>;
clearActivity: () => Promise<void>;
};
type DiscordRpcRawClient = {
login: () => Promise<void>;
destroy: () => Promise<void>;
user?: DiscordRpcClientUserLike;
};
export type DiscordRpcClient = {
login: () => Promise<void>;
setActivity: (activity: DiscordActivityPayload) => Promise<void>;
clearActivity: () => Promise<void>;
destroy: () => Promise<void>;
};
function requireUser(client: DiscordRpcRawClient): DiscordRpcClientUserLike {
if (!client.user) {
throw new Error('Discord RPC client user is unavailable');
}
return client.user;
}
export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcClient {
return {
login: () => client.login(),
setActivity: (activity) => requireUser(client).setActivity(activity).then(() => undefined),
clearActivity: () => requireUser(client).clearActivity(),
destroy: () => client.destroy(),
};
}
export function createDiscordRpcClient(
clientId: string,
deps?: { createClient?: (options: { clientId: string; transport: { type: 'ipc' } }) => DiscordRpcRawClient },
): DiscordRpcClient {
const client =
deps?.createClient?.({ clientId, transport: { type: 'ipc' } }) ??
new Client({ clientId, transport: { type: 'ipc' } });
return wrapDiscordRpcClient(client);
}

View File

@@ -7,7 +7,6 @@ import {
detectInstalledFirstRunPlugin,
installFirstRunPluginToDefaultLocation,
resolvePackagedFirstRunPluginAssets,
syncInstalledFirstRunPluginBinaryPath,
} from './first-run-setup-plugin';
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
@@ -69,17 +68,13 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
});
assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'configured=true\nbinary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\n',
);
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n');
const scriptsDirEntries = fs.readdirSync(installPaths.scriptsDir);
const scriptOptsEntries = fs.readdirSync(installPaths.scriptOptsDir);
@@ -118,17 +113,13 @@ test('installFirstRunPluginToDefaultLocation installs plugin to Windows mpv defa
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
binaryPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
});
assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'configured=true\nbinary_path=C:\\Program Files\\SubMiner\\SubMiner.exe\n',
);
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n');
});
});
@@ -155,70 +146,12 @@ test('installFirstRunPluginToDefaultLocation rewrites Windows plugin socket_path
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
binaryPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
});
assert.equal(result.ok, true);
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=C:\\Program Files\\SubMiner\\SubMiner.exe\nsocket_path=\\\\.\\pipe\\subminer-socket\n',
);
});
});
test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing installs', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(installPaths.pluginConfigPath, 'binary_path=\nsocket_path=/tmp/subminer-socket\n');
const result = syncInstalledFirstRunPluginBinaryPath({
platform: 'linux',
homeDir,
xdgConfigHome,
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
});
assert.deepEqual(result, {
updated: true,
configPath: installPaths.pluginConfigPath,
});
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\nsocket_path=/tmp/subminer-socket\n',
);
});
});
test('syncInstalledFirstRunPluginBinaryPath preserves explicit binary_path overrides', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(
installPaths.pluginConfigPath,
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
);
const result = syncInstalledFirstRunPluginBinaryPath({
platform: 'linux',
homeDir,
xdgConfigHome,
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
});
assert.deepEqual(result, {
updated: false,
configPath: installPaths.pluginConfigPath,
});
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
'binary_path=\nsocket_path=\\\\.\\pipe\\subminer-socket\n',
);
});
});

View File

@@ -28,43 +28,6 @@ function rewriteInstalledWindowsPluginConfig(configPath: string): void {
}
}
function sanitizePluginConfigValue(value: string): string {
return value.replace(/[\r\n]/g, '').trim();
}
function upsertPluginConfigLine(content: string, key: string, value: string): string {
const normalizedValue = sanitizePluginConfigValue(value);
const line = `${key}=${normalizedValue}`;
const pattern = new RegExp(`^${key}=.*$`, 'm');
if (pattern.test(content)) {
return content.replace(pattern, line);
}
const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
return `${content}${suffix}${line}\n`;
}
function rewriteInstalledPluginBinaryPath(configPath: string, binaryPath: string): boolean {
const content = fs.readFileSync(configPath, 'utf8');
const updated = upsertPluginConfigLine(content, 'binary_path', binaryPath);
if (updated === content) {
return false;
}
fs.writeFileSync(configPath, updated, 'utf8');
return true;
}
function readInstalledPluginBinaryPath(configPath: string): string | null {
const content = fs.readFileSync(configPath, 'utf8');
const match = content.match(/^binary_path=(.*)$/m);
if (!match) {
return null;
}
const rawValue = match[1] ?? '';
const value = sanitizePluginConfigValue(rawValue);
return value.length > 0 ? value : null;
}
export function resolvePackagedFirstRunPluginAssets(deps: {
dirname: string;
appPath: string;
@@ -116,7 +79,6 @@ export function installFirstRunPluginToDefaultLocation(options: {
dirname: string;
appPath: string;
resourcesPath: string;
binaryPath: string;
}): PluginInstallResult {
const installPaths = resolveDefaultMpvInstallPaths(
options.platform,
@@ -154,7 +116,6 @@ export function installFirstRunPluginToDefaultLocation(options: {
backupExistingPath(installPaths.pluginConfigPath);
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
}
@@ -166,33 +127,3 @@ export function installFirstRunPluginToDefaultLocation(options: {
message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`,
};
}
export function syncInstalledFirstRunPluginBinaryPath(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
binaryPath: string;
}): { updated: boolean; configPath: string | null } {
const installPaths = resolveDefaultMpvInstallPaths(
options.platform,
options.homeDir,
options.xdgConfigHome,
);
if (!installPaths.supported || !fs.existsSync(installPaths.pluginConfigPath)) {
return { updated: false, configPath: null };
}
const configuredBinaryPath = readInstalledPluginBinaryPath(installPaths.pluginConfigPath);
if (configuredBinaryPath) {
return { updated: false, configPath: installPaths.pluginConfigPath };
}
const updated = rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
}
return {
updated,
configPath: installPaths.pluginConfigPath,
};
}

View File

@@ -1,26 +1,16 @@
interface SetupWindowConfig {
width: number;
height: number;
title: string;
resizable?: boolean;
minimizable?: boolean;
maximizable?: boolean;
}
function createSetupWindowHandler<TWindow>(
deps: { createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow },
config: SetupWindowConfig,
) {
export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return (): TWindow =>
deps.createBrowserWindow({
width: config.width,
height: config.height,
title: config.title,
width: 480,
height: 460,
title: 'SubMiner Setup',
show: true,
autoHideMenuBar: true,
...(config.resizable === undefined ? {} : { resizable: config.resizable }),
...(config.minimizable === undefined ? {} : { minimizable: config.minimizable }),
...(config.maximizable === undefined ? {} : { maximizable: config.maximizable }),
resizable: false,
minimizable: false,
maximizable: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
@@ -28,35 +18,36 @@ function createSetupWindowHandler<TWindow>(
});
}
export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return createSetupWindowHandler(deps, {
width: 480,
height: 460,
title: 'SubMiner Setup',
resizable: false,
minimizable: false,
maximizable: false,
});
}
export function createCreateJellyfinSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return createSetupWindowHandler(deps, {
width: 520,
height: 560,
title: 'Jellyfin Setup',
});
return (): TWindow =>
deps.createBrowserWindow({
width: 520,
height: 560,
title: 'Jellyfin Setup',
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
}
export function createCreateAnilistSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return createSetupWindowHandler(deps, {
width: 1000,
height: 760,
title: 'Anilist Setup',
});
return (): TWindow =>
deps.createBrowserWindow({
width: 1000,
height: 760,
title: 'Anilist Setup',
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
}

View File

@@ -6,7 +6,6 @@ test('youtube playback runtime resets flow ownership after a successful run', as
const calls: string[] = [];
let appOwnedFlowInFlight = false;
let timeoutCallback: (() => void) | null = null;
let socketPath = '/tmp/mpv.sock';
const runtime = createYoutubePlaybackRuntime({
platform: 'linux',
@@ -14,7 +13,7 @@ test('youtube playback runtime resets flow ownership after a successful run', as
mpvYtdlFormat: 'bestvideo+bestaudio',
autoLaunchTimeoutMs: 2_000,
connectTimeoutMs: 1_000,
getSocketPath: () => socketPath,
socketPath: '/tmp/mpv.sock',
getMpvConnected: () => true,
invalidatePendingAutoplayReadyFallbacks: () => {
calls.push('invalidate-autoplay');
@@ -79,70 +78,3 @@ test('youtube playback runtime resets flow ownership after a successful run', as
scheduledCallback();
assert.equal(runtime.getQuitOnDisconnectArmed(), true);
});
test('youtube playback runtime resolves the socket path lazily for windows startup', async () => {
const calls: string[] = [];
let socketPath = '/tmp/initial.sock';
const runtime = createYoutubePlaybackRuntime({
platform: 'win32',
directPlaybackFormat: 'best',
mpvYtdlFormat: 'bestvideo+bestaudio',
autoLaunchTimeoutMs: 2_000,
connectTimeoutMs: 1_000,
getSocketPath: () => socketPath,
getMpvConnected: () => false,
invalidatePendingAutoplayReadyFallbacks: () => {
calls.push('invalidate-autoplay');
},
setAppOwnedFlowInFlight: (next) => {
calls.push(`app-owned:${next}`);
},
ensureYoutubePlaybackRuntimeReady: async () => {
calls.push('ensure-runtime-ready');
},
resolveYoutubePlaybackUrl: async (url, format) => {
calls.push(`resolve:${url}:${format}`);
return 'https://example.com/direct';
},
launchWindowsMpv: (_playbackUrl, args) => {
calls.push(`launch:${args.join(' ')}`);
return { ok: true, mpvPath: '/usr/bin/mpv' };
},
waitForYoutubeMpvConnected: async (timeoutMs) => {
calls.push(`wait-connected:${timeoutMs}`);
return true;
},
prepareYoutubePlaybackInMpv: async ({ url }) => {
calls.push(`prepare:${url}`);
return true;
},
runYoutubePlaybackFlow: async ({ url, mode }) => {
calls.push(`run-flow:${url}:${mode}`);
},
logInfo: (message) => {
calls.push(`info:${message}`);
},
logWarn: (message) => {
calls.push(`warn:${message}`);
},
schedule: (callback) => {
calls.push('schedule-arm');
callback();
return 1 as never;
},
clearScheduled: () => {
calls.push('clear-scheduled');
},
});
socketPath = '/tmp/updated.sock';
await runtime.runYoutubePlaybackFlow({
url: 'https://youtu.be/demo',
mode: 'download',
source: 'initial',
});
assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock')));
});

View File

@@ -11,7 +11,7 @@ export type YoutubePlaybackRuntimeDeps = {
mpvYtdlFormat: string;
autoLaunchTimeoutMs: number;
connectTimeoutMs: number;
getSocketPath: () => string;
socketPath: string;
getMpvConnected: () => boolean;
invalidatePendingAutoplayReadyFallbacks: () => void;
setAppOwnedFlowInFlight: (next: boolean) => void;
@@ -76,7 +76,6 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
}
if (deps.platform === 'win32' && !deps.getMpvConnected()) {
const socketPath = deps.getSocketPath();
const launchResult = deps.launchWindowsMpv(playbackUrl, [
'--pause=yes',
'--ytdl=yes',
@@ -88,7 +87,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
'--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
`--input-ipc-server=${socketPath}`,
`--input-ipc-server=${deps.socketPath}`,
]);
launchedWindowsMpv = launchResult.ok;
if (launchResult.ok && launchResult.mpvPath) {

View File

@@ -518,26 +518,6 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
}
});
test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
testGlobals.dispatchKeydown({ key: 't', code: 'KeyT' });
assert.equal(
testGlobals.mpvCommands.some(
(command) => command[0] === 'script-message' && command[1] === 'subminer-toggle',
),
true,
);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();

View File

@@ -1241,7 +1241,6 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const modalListeners = new Map<string, Array<() => void>>();
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
@@ -1318,11 +1317,6 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
addEventListener: (type: string, listener: () => void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
@@ -1339,7 +1333,7 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
await modal.openSubtitleSidebarModal();
await modal.refreshSubtitleSidebarSnapshot();
mpvCommands.length = 0;
await contentListeners.get('mouseenter')?.[0]?.();
await modalListeners.get('mouseenter')?.[0]?.();
assert.deepEqual(mpvCommands.at(-1), ['set_property', 'pause', 'yes']);
@@ -1359,7 +1353,6 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const modalListeners = new Map<string, Array<() => Promise<void> | void>>();
const contentListeners = new Map<string, Array<() => Promise<void> | void>>();
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
@@ -1438,11 +1431,6 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
addEventListener: (type: string, listener: () => Promise<void> | void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
@@ -1458,7 +1446,7 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
await modal.openSubtitleSidebarModal();
await assert.doesNotReject(async () => {
await contentListeners.get('mouseenter')?.[0]?.();
await modalListeners.get('mouseenter')?.[0]?.();
});
assert.equal(state.subtitleSidebarPausedByHover, false);
@@ -1756,7 +1744,6 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
const mpvCommands: Array<Array<string | number>> = [];
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
const modalListeners = new Map<string, Array<() => void>>();
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
@@ -1836,11 +1823,6 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 360 }),
addEventListener: (type: string, listener: () => void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
@@ -1860,15 +1842,15 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
await modal.openSubtitleSidebarModal();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
contentListeners.get('mouseenter')?.[0]?.();
modalListeners.get('mouseenter')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
contentListeners.get('mouseleave')?.[0]?.();
modalListeners.get('mouseleave')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
state.isOverSubtitle = true;
contentListeners.get('mouseenter')?.[0]?.();
contentListeners.get('mouseleave')?.[0]?.();
modalListeners.get('mouseenter')?.[0]?.();
modalListeners.get('mouseleave')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
void mpvCommands;
@@ -1878,251 +1860,6 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
}
});
test('subtitle sidebar overlay layout restores macOS and Windows passthrough outside sidebar hover', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
const modalListeners = new Map<string, Array<() => void>>();
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
startTime: 1,
endTime: 2,
},
currentTimeSec: 1.1,
config: {
enabled: true,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 360,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
textColor: '#cad3f5',
fontFamily: '"Iosevka Aile", sans-serif',
fontSize: 17,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
innerWidth: 1200,
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreMouseCalls.push([ignore, options]);
},
} as unknown as ElectronAPI,
addEventListener: () => {},
removeEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: (type: string, listener: () => void) => {
const bucket = modalListeners.get(type) ?? [];
bucket.push(listener);
modalListeners.set(type, bucket);
},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 360 }),
addEventListener: (type: string, listener: () => void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: createListStub(),
},
platform: {
shouldToggleMouseIgnore: true,
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
});
modal.wireDomEvents();
assert.equal(modalListeners.get('mouseenter')?.length ?? 0, 0);
assert.equal(modalListeners.get('mouseleave')?.length ?? 0, 0);
assert.equal(contentListeners.get('mouseenter')?.length ?? 0, 1);
assert.equal(contentListeners.get('mouseleave')?.length ?? 0, 1);
await modal.openSubtitleSidebarModal();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
contentListeners.get('mouseenter')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
contentListeners.get('mouseleave')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
void mpvCommands;
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('subtitle sidebar overlay layout only stays interactive while focus remains inside the sidebar panel', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
const contentListeners = new Map<string, Array<(event?: FocusEvent) => void>>();
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
startTime: 1,
endTime: 2,
},
currentTimeSec: 1.1,
config: {
enabled: true,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 360,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
textColor: '#cad3f5',
fontFamily: '"Iosevka Aile", sans-serif',
fontSize: 17,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
innerWidth: 1200,
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
sendMpvCommand: () => {},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreMouseCalls.push([ignore, options]);
},
} as unknown as ElectronAPI,
addEventListener: () => {},
removeEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
const sidebarContent = {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 360 }),
addEventListener: (type: string, listener: (event?: FocusEvent) => void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
contains: () => false,
};
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: sidebarContent,
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: createListStub(),
},
platform: {
shouldToggleMouseIgnore: true,
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
});
modal.wireDomEvents();
await modal.openSubtitleSidebarModal();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
contentListeners.get('focusin')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
contentListeners.get('focusout')?.[0]?.({ relatedTarget: null } as FocusEvent);
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('closing embedded subtitle sidebar recomputes passthrough from remaining subtitle hover state', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;

View File

@@ -143,23 +143,11 @@ export function createSubtitleSidebarModal(
let lastAppliedVideoMarginRatio: number | null = null;
let subtitleSidebarHoverRequestId = 0;
let disposeDomEvents: (() => void) | null = null;
let subtitleSidebarHovered = false;
let subtitleSidebarFocusedWithin = false;
function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx);
}
function syncSidebarInteractionState(): void {
ctx.state.isOverSubtitleSidebar = subtitleSidebarHovered || subtitleSidebarFocusedWithin;
}
function clearSidebarInteractionState(): void {
subtitleSidebarHovered = false;
subtitleSidebarFocusedWithin = false;
syncSidebarInteractionState();
}
function setStatus(message: string): void {
ctx.dom.subtitleSidebarStatus.textContent = message;
}
@@ -391,7 +379,6 @@ export function createSubtitleSidebarModal(
applyConfig(snapshot);
if (!snapshot.config.enabled) {
resumeSubtitleSidebarHoverPause();
clearSidebarInteractionState();
ctx.state.subtitleSidebarCues = [];
ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden');
@@ -463,7 +450,7 @@ export function createSubtitleSidebarModal(
}
ctx.state.subtitleSidebarModalOpen = true;
clearSidebarInteractionState();
ctx.state.isOverSubtitleSidebar = false;
ctx.dom.subtitleSidebarModal.classList.remove('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'false');
renderCueList();
@@ -491,7 +478,7 @@ export function createSubtitleSidebarModal(
return;
}
resumeSubtitleSidebarHoverPause();
clearSidebarInteractionState();
ctx.state.isOverSubtitleSidebar = false;
ctx.state.subtitleSidebarModalOpen = false;
ctx.dom.subtitleSidebarModal.classList.add('hidden');
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
@@ -549,9 +536,8 @@ export function createSubtitleSidebarModal(
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
ctx.state.subtitleSidebarManualScrollUntilMs = Date.now() + MANUAL_SCROLL_HOLD_MS;
});
ctx.dom.subtitleSidebarContent.addEventListener('mouseenter', async () => {
subtitleSidebarHovered = true;
syncSidebarInteractionState();
ctx.dom.subtitleSidebarModal.addEventListener('mouseenter', async () => {
ctx.state.isOverSubtitleSidebar = true;
restoreEmbeddedSidebarPassthrough();
if (!ctx.state.subtitleSidebarPauseVideoOnHover || ctx.state.subtitleSidebarPausedByHover) {
return;
@@ -571,36 +557,8 @@ export function createSubtitleSidebarModal(
ctx.state.subtitleSidebarPausedByHover = true;
}
});
ctx.dom.subtitleSidebarContent.addEventListener('mouseleave', () => {
subtitleSidebarHovered = false;
syncSidebarInteractionState();
if (ctx.state.isOverSubtitleSidebar) {
restoreEmbeddedSidebarPassthrough();
return;
}
resumeSubtitleSidebarHoverPause();
});
ctx.dom.subtitleSidebarContent.addEventListener('focusin', () => {
subtitleSidebarFocusedWithin = true;
syncSidebarInteractionState();
restoreEmbeddedSidebarPassthrough();
});
ctx.dom.subtitleSidebarContent.addEventListener('focusout', (event: FocusEvent) => {
const relatedTarget = event.relatedTarget;
if (
typeof Node !== 'undefined' &&
relatedTarget instanceof Node &&
ctx.dom.subtitleSidebarContent.contains(relatedTarget)
) {
return;
}
subtitleSidebarFocusedWithin = false;
syncSidebarInteractionState();
if (ctx.state.isOverSubtitleSidebar) {
restoreEmbeddedSidebarPassthrough();
return;
}
ctx.dom.subtitleSidebarModal.addEventListener('mouseleave', () => {
ctx.state.isOverSubtitleSidebar = false;
resumeSubtitleSidebarHoverPause();
});
const resizeHandler = () => {

Some files were not shown because too many files have changed in this diff Show More