Compare commits

..

1 Commits

Author SHA1 Message Date
35adf8299c Refactor startup, queries, and workflow into focused modules (#36)
* chore(backlog): add mining workflow milestone and tasks

* refactor: split character dictionary runtime modules

* refactor: split shared type entrypoints

* refactor: use bun serve for stats server

* feat: add repo-local subminer workflow plugin

* fix: add stats server node fallback

* refactor: split immersion tracker query modules

* chore: update backlog task records

* refactor: migrate shared type imports

* refactor: compose startup and setup window wiring

* 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

* test: increase launcher test timeout for CI stability

* fix: address CodeRabbit review feedback

* refactor(main): extract remaining inline runtime logic from main

* chore(backlog): update task notes and changelog fragment

* refactor: split main boot phases

* test: stabilize bun coverage reporting

* 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

* Restrict docs analytics and build coverage input

- limit Plausible init to docs.subminer.moe
- build Yomitan before src coverage lane

* fix(ci): normalize Windows shortcut paths for cross-platform tests

* 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

* fix: resolve CI type failures in boot and immersion query tests

* fix: remove strict spread usage in Date mocks

* fix: use explicit super args for MockDate constructors

* 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

* fix: use variadic array type for MockDate constructor args

TS2367: fixed-length tuple made args.length === 0 unreachable.

* refactor: remove unused createMainBootRuntimes/Handlers aggregate functions

These functions were never called by production code — main.ts imports
the individual composeBoot* re-exports directly.

* refactor: remove boot re-export alias layer

main.ts now imports directly from the runtime/composers and runtime/domains
modules, eliminating the intermediate boot/ indirection.

* refactor: consolidate 3 near-identical setup window factories

Extract shared createSetupWindowHandler with a config parameter.
Public API unchanged.

* refactor: parameterize duplicated getAffected*Ids query helpers

Four structurally identical functions collapsed into two parameterized
helpers while preserving the existing public API.

* refactor: inline identity composers (stats-startup, overlay-window)

composeStatsStartupRuntime was a no-op that returned its input.
composeOverlayWindowHandlers was a 1-line delegation.
Both removed in favor of direct usage.

* chore: remove unused token/queue file path constants from main.ts

* fix: replace any types in boot services with proper signatures

* refactor: deduplicate ensureDir into shared/fs-utils

5 copies of mkdir-p-if-not-exists consolidated into one shared module
with ensureDir (directory path) and ensureDirForFile (file path) variants.

* fix: tighten type safety in boot services

- Add AppLifecycleShape and OverlayModalInputStateShape constraints
  so TAppLifecycleApp and TOverlayModalInputState generics are bounded
- Remove unsafe `as { handleModalInputStateChange? }` cast — now
  directly callable via the constraint
- Use `satisfies AppLifecycleShape` for structural validation on the
  appLifecycleApp object literal
- Document Electron App.on incompatibility with simple signatures

* refactor: inline subtitle-prefetch-runtime-composer

The composer was a pure pass-through that destructured an object and
reassembled it with the same fields. Inlined at the call site.

* chore: consolidate duplicate import paths in main.ts

* test: extract mpv composer test fixture factory to reduce duplication

* test: add behavioral assertions to composer tests

Upgrade 8 composer test files from shape-only typeof checks to behavioral
assertions that invoke returned handlers and verify injected dependencies are
actually called, following the mpv-runtime-composer pattern.

* refactor: normalize import extensions in query modules

* refactor: consolidate toDbMs into query-shared.ts

* refactor: remove Node.js fallback from stats-server, use Bun only

* Fix monthly rollup test expectations

- Preserve multi-arg Date construction in mock helper
- Align rollup assertions with the correct videoId

* fix: address PR 36 CodeRabbit follow-ups

* fix: harden coverage lane cleanup

* fix(stats): fallback to node server when Bun.serve unavailable

* fix(ci): restore coverage lane compatibility

* chore(backlog): close TASK-242

* fix: address latest CodeRabbit review round

* fix: guard disabled immersion retention windows

* fix: migrate discord rpc wrapper

* fix(ci): add changelog fragment for PR 36

* fix: stabilize macOS visible overlay toggle

* fix: pin installed mpv plugin to current binary

* fix: strip inline subtitle markup from sidebar cues

* fix(renderer): restore subtitle sidebar mpv passthrough

* feat(discord): add configurable presence style presets

Replace the hardcoded "Mining and crafting (Anki cards)" meme message
with a preset system. New `discordPresence.presenceStyle` option
supports four presets: "default" (clean bilingual), "meme" (the OG
Minecraft joke), "japanese" (fully JP), and "minimal". The default
preset shows "Sentence Mining" with 日本語学習中 as the small image
tooltip. Existing users can set presenceStyle to "meme" to keep the
old behavior.

* fix: finalize v0.10.0 release prep

* docs: add subtitle sidebar guide and release note

* chore(backlog): mark docs task done

* fix: lazily resolve youtube playback socket path

* chore(release): build v0.10.0 changelog

* Revert "chore(release): build v0.10.0 changelog"

This reverts commit 9741c0f020.
2026-03-29 16:16:29 -07:00
104 changed files with 2566 additions and 1940 deletions

1
.gitignore vendored
View File

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

View File

@@ -20,7 +20,7 @@ Priority keys:
| ID | Pri | Status | Area | Title | | ID | Pri | Status | Area | Title |
| ------ | --- | ------ | -------------- | --------------------------------------------------- | | ------ | --- | ------ | -------------- | --------------------------------------------------- |
| SM-013 | P1 | doing | review-followup | Address PR #36 CodeRabbit action items | | SM-013 | P1 | done | review-followup | Address PR #36 CodeRabbit action items |
## Ready ## Ready
@@ -241,7 +241,7 @@ Done:
Title: Address PR #36 CodeRabbit action items Title: Address PR #36 CodeRabbit action items
Priority: P1 Priority: P1
Status: doing Status: done
Scope: Scope:
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh` - `plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
@@ -251,7 +251,16 @@ Scope:
- `src/core/services/immersion-tracker/maintenance.ts` - `src/core/services/immersion-tracker/maintenance.ts`
- `src/main/boot/services.ts` - `src/main/boot/services.ts`
- `src/main/character-dictionary-runtime/zip.test.ts` - `src/main/character-dictionary-runtime/zip.test.ts`
Acceptance: Acceptance:
- fix valid open CodeRabbit findings on PR #36 - fix valid open CodeRabbit findings on PR #36
- add focused regression coverage for behavior changes where practical - add focused regression coverage for behavior changes where practical
- verify touched tests plus typecheck stay green - 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,5 +1,26 @@
# Changelog # 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) ## v0.9.3 (2026-03-25)
### Changed ### Changed

View File

@@ -0,0 +1,35 @@
---
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

@@ -0,0 +1,35 @@
---
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

@@ -0,0 +1,35 @@
---
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

@@ -0,0 +1,68 @@
---
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

@@ -0,0 +1,55 @@
---
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

@@ -0,0 +1,60 @@
---
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

@@ -0,0 +1,69 @@
---
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

@@ -0,0 +1,37 @@
---
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

@@ -0,0 +1,72 @@
---
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

@@ -0,0 +1,32 @@
---
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": { "dependencies": {
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7", "@fontsource-variable/geist-mono": "^5.2.7",
"@xhayper/discord-rpc": "^1.3.3",
"axios": "^1.13.5", "axios": "^1.13.5",
"commander": "^14.0.3", "commander": "^14.0.3",
"discord-rpc": "^4.0.1",
"hono": "^4.12.7", "hono": "^4.12.7",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"libsql": "^0.5.22", "libsql": "^0.5.22",
@@ -37,6 +37,12 @@
"@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=="], "@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/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=="], "@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=="],
@@ -143,6 +149,10 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@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=="], "@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=="], "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
@@ -171,6 +181,10 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], "@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=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
@@ -209,8 +223,6 @@
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "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=="], "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=="], "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
@@ -293,7 +305,7 @@
"dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
"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=="], "discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
"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=="], "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=="],
@@ -359,8 +371,6 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "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=="], "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=="], "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
@@ -477,6 +487,8 @@
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "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=="], "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=="], "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
@@ -523,8 +535,6 @@
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], "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=="], "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=="], "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
@@ -587,8 +597,6 @@
"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=="], "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=="], "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=="], "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
@@ -673,14 +681,16 @@
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "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=="], "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=="], "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=="], "unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="],
@@ -699,10 +709,6 @@
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], "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=="], "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=="], "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=="],
@@ -769,8 +775,6 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "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/@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=="], "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

@@ -1,5 +0,0 @@
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

@@ -1,5 +0,0 @@
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

@@ -1,6 +0,0 @@
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

@@ -1,6 +0,0 @@
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

@@ -0,0 +1,6 @@
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

@@ -0,0 +1,5 @@
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,6 +498,7 @@
// ========================================== // ==========================================
"discordPresence": { "discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false "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. "updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates. "debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session. }, // Optional Discord Rich Presence activity card updates for current playback/study session.

View File

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

View File

@@ -34,6 +34,25 @@
system-ui, system-ui,
sans-serif; sans-serif;
--tui-transition: 180ms ease; --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 { :root {
@@ -48,7 +67,7 @@
/* === Selection === */ /* === Selection === */
::selection { ::selection {
background: hsla(267, 83%, 80%, 0.22); background: var(--tui-selection-bg);
color: var(--vp-c-text-1); color: var(--vp-c-text-1);
} }
@@ -102,7 +121,7 @@ button,
} }
.VPNav .VPNavBar:not(.has-sidebar) { .VPNav .VPNavBar:not(.has-sidebar) {
background: hsla(232, 23%, 18%, 0.82); background: var(--tui-nav-bg);
} }
.VPNav .VPNavBar.has-sidebar .content { .VPNav .VPNavBar.has-sidebar .content {
@@ -245,13 +264,13 @@ button,
} }
.vp-doc table tr:hover td { .vp-doc table tr:hover td {
background: hsla(232, 23%, 18%, 0.4); background: var(--tui-table-hover-bg);
} }
/* === Links === */ /* === Links === */
.vp-doc a { .vp-doc a {
text-decoration: none; text-decoration: none;
border-bottom: 1px solid hsla(267, 83%, 80%, 0.3); border-bottom: 1px solid var(--tui-link-underline);
transition: border-color var(--tui-transition), color var(--tui-transition); transition: border-color var(--tui-transition), color var(--tui-transition);
} }
@@ -653,7 +672,7 @@ body {
height: 400px; height: 400px;
background: radial-gradient( background: radial-gradient(
ellipse at center, ellipse at center,
hsla(267, 83%, 80%, 0.06) 0%, var(--tui-hero-glow) 0%,
transparent 70% transparent 70%
); );
pointer-events: none; pointer-events: none;

View File

@@ -1,5 +1,12 @@
# Changelog # 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) ## v0.9.3 (2026-03-25)
- Moved YouTube primary subtitle language defaults to `youtube.primarySubLanguages`. - Moved YouTube primary subtitle language defaults to `youtube.primarySubLanguages`.
- Removed the placeholder YouTube subtitle retime step; downloaded primary subtitle tracks are now used directly. - Removed the placeholder YouTube subtitle retime step; downloaded primary subtitle tracks are now used directly.

View File

@@ -390,6 +390,8 @@ 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. `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: `jlptColors` keys are:
| Key | Default | Description | | Key | Default | Description |
@@ -1197,30 +1199,38 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine
{ {
"discordPresence": { "discordPresence": {
"enabled": true, "enabled": true,
"presenceStyle": "default",
"updateIntervalMs": 3000, "updateIntervalMs": 3000,
"debounceMs": 750 "debounceMs": 750
} }
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------ | --------------- | ---------------------------------------------------------- | | ------------------ | ------------------------------------------------- | ---------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) | | `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | | `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | | `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
Setup steps: Setup steps:
1. Set `discordPresence.enabled` to `true`. 1. Set `discordPresence.enabled` to `true`.
2. Restart SubMiner. 2. Optionally set `discordPresence.presenceStyle` to choose a card text preset.
3. Restart SubMiner.
SubMiner uses a fixed official activity card style for all users: #### Presence style presets
- Details: current media title while playing (fallback: `Mining and crafting (Anki cards)` when idle/disconnected) 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.
- State: `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss` (fallback: `Idle`)
- Large image key/text: `subminer-logo` / `SubMiner` | Preset | Idle details | Small image text | Vibe |
- Small image key/text: `study` / `Sentence Mining` | ------------ | ----------------------------------- | ------------------ | --------------------------------------- |
- No activity button by default | **`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.
Troubleshooting: Troubleshooting:

View File

@@ -67,7 +67,7 @@ features:
alt: Subtitle download icon alt: Subtitle download icon
title: Subtitle Download & Sync title: Subtitle Download & Sync
details: Search and pull subtitles from Jimaku, then auto-sync timing with alass or ffsubsync — all from the overlay. details: Search and pull subtitles from Jimaku, then auto-sync timing with alass or ffsubsync — all from the overlay.
link: /configuration#jimaku link: /jimaku-integration
linkText: Jimaku integration linkText: Jimaku integration
- icon: - icon:
src: /assets/tokenization.svg src: /assets/tokenization.svg
@@ -223,12 +223,12 @@ const demoAssetVersion = '20260223-2';
} }
.workflow-step:hover { .workflow-step:hover {
background: hsla(232, 23%, 18%, 0.6); background: var(--tui-step-hover-bg);
} }
.workflow-step:hover .step-number { .workflow-step:hover .step-number {
color: var(--vp-c-brand-1); color: var(--vp-c-brand-1);
text-shadow: 0 0 12px hsla(267, 83%, 80%, 0.3); text-shadow: 0 0 12px var(--tui-step-hover-glow);
} }
.workflow-connector { .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 ### 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. - 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.
- If you use the mpv plugin, leave `binary_path` empty unless SubMiner is installed in a non-standard location. - First-run mpv plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is installed in a non-standard location.
- Windows plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket`; do not keep `/tmp/subminer-socket` on Windows. - 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. - Native window tracking is built in on Windows; no `xdotool`, `xwininfo`, or compositor-specific helper is required.
@@ -201,6 +201,7 @@ 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`. 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 ```bash
# Option 1: install from release assets bundle # 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 ## Related
- [Configuration Reference](/configuration#jimaku) — full config section - [Configuration Reference](/configuration#jimaku) — full config options
- [Mining Workflow](/mining-workflow#jimaku-subtitle-search) — how Jimaku fits into the sentence mining loop - [Mining Workflow](/mining-workflow#jimaku-subtitle-search) — how Jimaku fits into the sentence mining loop
- [Troubleshooting](/troubleshooting#jimaku) — additional error guidance - [Troubleshooting](/troubleshooting#jimaku) — additional error guidance

View File

@@ -498,6 +498,7 @@
// ========================================== // ==========================================
"discordPresence": { "discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false "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. "updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates. "debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session. }, // Optional Discord Rich Presence activity card updates for current playback/study session.

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { mergeLcovReports } from './run-coverage-lane'; import { mergeLcovReports, resolveCoverageDir } from './run-coverage-lane';
test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => { test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => {
const merged = mergeLcovReports([ const merged = mergeLcovReports([
@@ -59,3 +60,15 @@ 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\/a\.ts[\s\S]*end_of_record/);
assert.match(merged, /SF:src\/b\.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 { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { join, relative, resolve } from 'node:path'; import { isAbsolute, join, relative, resolve } from 'node:path';
type LaneConfig = { type LaneConfig = {
roots: string[]; roots: string[];
@@ -85,6 +85,15 @@ function parseCoverageDirArg(argv: string[]): string {
return 'coverage'; 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[] { function parseLcovReport(report: string): LcovRecord[] {
const records: LcovRecord[] = []; const records: LcovRecord[] = [];
let current: LcovRecord | null = null; let current: LcovRecord | null = null;
@@ -251,7 +260,7 @@ function runCoverageLane(): number {
return 1; return 1;
} }
const coverageDir = resolve(repoRoot, parseCoverageDirArg(process.argv.slice(3))); const coverageDir = resolveCoverageDir(repoRoot, process.argv.slice(3));
const shardRoot = join(coverageDir, '.shards'); const shardRoot = join(coverageDir, '.shards');
mkdirSync(coverageDir, { recursive: true }); mkdirSync(coverageDir, { recursive: true });
rmSync(shardRoot, { recursive: true, force: true }); rmSync(shardRoot, { recursive: true, force: true });
@@ -260,39 +269,43 @@ function runCoverageLane(): number {
const files = getLaneFiles(laneName); const files = getLaneFiles(laneName);
const reports: string[] = []; const reports: string[] = [];
for (const [index, file] of files.entries()) { try {
const shardDir = join(shardRoot, `${String(index + 1).padStart(3, '0')}`); for (const [index, file] of files.entries()) {
const result = spawnSync( const shardDir = join(shardRoot, `${String(index + 1).padStart(3, '0')}`);
'bun', const result = spawnSync(
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`], 'bun',
{ ['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
cwd: repoRoot, {
stdio: 'inherit', cwd: repoRoot,
}, stdio: 'inherit',
); },
);
if (result.error) { if (result.error) {
throw result.error; throw result.error;
} }
if ((result.status ?? 1) !== 0) { if ((result.status ?? 1) !== 0) {
return result.status ?? 1; 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'));
} }
const lcovPath = join(shardDir, 'lcov.info'); writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
if (!existsSync(lcovPath)) { process.stdout.write(`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`);
process.stdout.write(`Skipping empty coverage shard for ${file}\n`); return 0;
continue; } finally {
} rmSync(shardRoot, { recursive: true, force: true });
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;
} }
if (require.main === module) { // @ts-ignore Bun entrypoint detection; TS config for scripts still targets CommonJS.
if (import.meta.main) {
process.exit(runCoverageLane()); process.exit(runCoverageLane());
} }

View File

@@ -822,6 +822,92 @@ do
) )
end 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 do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",

View File

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

View File

@@ -323,6 +323,13 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.discordPresence.enabled, defaultValue: defaultConfig.discordPresence.enabled,
description: 'Enable optional Discord Rich Presence updates.', 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', path: 'discordPresence.updateIntervalMs',
kind: 'number', kind: 'number',

View File

@@ -1,6 +1,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path';
import * as electron from 'electron'; import * as electron from 'electron';
import { ensureDirForFile } from '../../../shared/fs-utils';
interface PersistedTokenPayload { interface PersistedTokenPayload {
encryptedToken?: string; encryptedToken?: string;
@@ -21,15 +21,8 @@ export interface SafeStorageLike {
getSelectedStorageBackend?: () => string; 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 { function writePayload(filePath: string, payload: PersistedTokenPayload): void {
ensureDirectory(filePath); ensureDirForFile(filePath);
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
} }

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import {
const baseConfig = { const baseConfig = {
enabled: true, enabled: true,
presenceStyle: 'default' as const,
updateIntervalMs: 10_000, updateIntervalMs: 10_000,
debounceMs: 200, debounceMs: 200,
} as const; } as const;
@@ -27,24 +28,67 @@ const baseSnapshot: DiscordPresenceSnapshot = {
sessionStartedAtMs: BASE_SESSION_STARTED_AT_MS, sessionStartedAtMs: BASE_SESSION_STARTED_AT_MS,
}; };
test('buildDiscordPresenceActivity maps polished payload fields', () => { test('buildDiscordPresenceActivity maps polished payload fields (default style)', () => {
const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot); const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot);
assert.equal(payload.details, 'Sousou no Frieren E01'); assert.equal(payload.details, 'Sousou no Frieren E01');
assert.equal(payload.state, 'Playing 01:35 / 24:10'); assert.equal(payload.state, 'Playing 01:35 / 24:10');
assert.equal(payload.largeImageKey, 'subminer-logo'); assert.equal(payload.largeImageKey, 'subminer-logo');
assert.equal(payload.smallImageKey, 'study'); assert.equal(payload.smallImageKey, 'study');
assert.equal(payload.smallImageText, '日本語学習中');
assert.equal(payload.buttons, undefined); assert.equal(payload.buttons, undefined);
assert.equal(payload.startTimestamp, Math.floor(BASE_SESSION_STARTED_AT_MS / 1000)); assert.equal(payload.startTimestamp, Math.floor(BASE_SESSION_STARTED_AT_MS / 1000));
}); });
test('buildDiscordPresenceActivity falls back to idle when disconnected', () => { test('buildDiscordPresenceActivity falls back to idle with default style', () => {
const payload = buildDiscordPresenceActivity(baseConfig, { const payload = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot, ...baseSnapshot,
connected: false, connected: false,
mediaPath: null, mediaPath: null,
}); });
assert.equal(payload.state, 'Idle'); 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.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 () => { test('service deduplicates identical updates and sends changed timeline', async () => {

View File

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

View File

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

View File

@@ -82,6 +82,65 @@ 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', () => { test('toMonthKey floors negative timestamps into the prior UTC month', () => {
assert.equal(toMonthKey(-1), 196912); assert.equal(toMonthKey(-1), 196912);
assert.equal(toMonthKey(-86_400_000), 196912); assert.equal(toMonthKey(-86_400_000), 196912);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -89,72 +89,61 @@ export function findSharedCoverBlobHash(
return null; return null;
} }
export function getAffectedWordIdsForSessions(db: DatabaseSync, sessionIds: number[]): number[] { type LexicalEntity = 'word' | 'kanji';
if (sessionIds.length === 0) {
return [];
}
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 ( return (
db db
.prepare( .prepare(
` `SELECT DISTINCT o.${col} AS id
SELECT DISTINCT o.word_id AS wordId FROM ${table} o
FROM imm_word_line_occurrences o JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id WHERE sl.session_id IN (${makePlaceholders(sessionIds)})`,
WHERE sl.session_id IN (${makePlaceholders(sessionIds)})
`,
) )
.all(...sessionIds) as Array<{ wordId: number }> .all(...sessionIds) as Array<{ id: number }>
).map((row) => row.wordId); ).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);
} }
export function getAffectedKanjiIdsForSessions(db: DatabaseSync, sessionIds: number[]): number[] { export function getAffectedKanjiIdsForSessions(db: DatabaseSync, sessionIds: number[]): number[] {
if (sessionIds.length === 0) { return getAffectedIdsForSessions(db, 'kanji', sessionIds);
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[] { export function getAffectedWordIdsForVideo(db: DatabaseSync, videoId: number): number[] {
return ( return getAffectedIdsForVideo(db, 'word', videoId);
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[] { export function getAffectedKanjiIdsForVideo(db: DatabaseSync, videoId: number): number[] {
return ( return getAffectedIdsForVideo(db, 'kanji', videoId);
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 { function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
@@ -281,3 +270,13 @@ export function deleteSessionsByIds(db: DatabaseSync, sessionIds: number[]): voi
); );
db.prepare(`DELETE FROM imm_sessions WHERE session_id IN (${placeholders})`).run(...sessionIds); 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 { DatabaseSync } from './sqlite';
import type { ImmersionSessionRollupRow } from './types'; import type { ImmersionSessionRollupRow } from './types';
import { ACTIVE_SESSION_METRICS_CTE, makePlaceholders } from './query-shared.js'; import { ACTIVE_SESSION_METRICS_CTE, makePlaceholders } from './query-shared';
import { getDailyRollups, getMonthlyRollups } from './query-sessions.js'; import { getDailyRollups, getMonthlyRollups } from './query-sessions';
type TrendRange = '7d' | '30d' | '90d' | 'all'; type TrendRange = '7d' | '30d' | '90d' | 'all';
type TrendGroupBy = 'day' | 'month'; type TrendGroupBy = 'day' | 'month';
@@ -168,7 +168,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
words: 0, words: 0,
sessions: 0, sessions: 0,
}; };
existing.activeMin += Math.round(rollup.totalActiveMin); existing.activeMin += rollup.totalActiveMin;
existing.cards += rollup.totalCards; existing.cards += rollup.totalCards;
existing.words += rollup.totalTokensSeen; existing.words += rollup.totalTokensSeen;
existing.sessions += rollup.totalSessions; existing.sessions += rollup.totalSessions;
@@ -179,7 +179,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
.sort(([left], [right]) => left - right) .sort(([left], [right]) => left - right)
.map(([key, value]) => ({ .map(([key, value]) => ({
label: makeTrendLabel(key), label: makeTrendLabel(key),
activeMin: value.activeMin, activeMin: Math.round(value.activeMin),
cards: value.cards, cards: value.cards,
words: value.words, words: value.words,
sessions: value.sessions, sessions: value.sessions,
@@ -243,22 +243,32 @@ function buildSessionSeriesByMonth(
.map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value })); .map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value }));
} }
function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendChartPoint[] { function buildLookupsPerHundredWords(
const lookupsByDay = new Map<number, number>(); sessions: TrendSessionMetricRow[],
const wordsByDay = new Map<number, number>(); groupBy: TrendGroupBy,
): TrendChartPoint[] {
const lookupsByBucket = new Map<number, number>();
const wordsByBucket = new Map<number, number>();
for (const session of sessions) { for (const session of sessions) {
const epochDay = getLocalEpochDay(session.startedAtMs); const bucketKey =
lookupsByDay.set(epochDay, (lookupsByDay.get(epochDay) ?? 0) + session.yomitanLookupCount); groupBy === 'month' ? getLocalMonthKey(session.startedAtMs) : getLocalEpochDay(session.startedAtMs);
wordsByDay.set(epochDay, (wordsByDay.get(epochDay) ?? 0) + getTrendSessionWordCount(session)); lookupsByBucket.set(
bucketKey,
(lookupsByBucket.get(bucketKey) ?? 0) + session.yomitanLookupCount,
);
wordsByBucket.set(
bucketKey,
(wordsByBucket.get(bucketKey) ?? 0) + getTrendSessionWordCount(session),
);
} }
return Array.from(lookupsByDay.entries()) return Array.from(lookupsByBucket.entries())
.sort(([left], [right]) => left - right) .sort(([left], [right]) => left - right)
.map(([epochDay, lookups]) => { .map(([bucketKey, lookups]) => {
const words = wordsByDay.get(epochDay) ?? 0; const words = wordsByBucket.get(bucketKey) ?? 0;
return { return {
label: dayLabel(epochDay), label: groupBy === 'month' ? makeTrendLabel(bucketKey) : dayLabel(bucketKey),
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0, value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
}; };
}); });
@@ -595,7 +605,7 @@ export function getTrendsDashboard(
const animePerDay = { const animePerDay = {
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId), episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
watchTime: buildPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId, (rollup) => watchTime: buildPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId, (rollup) =>
Math.round(rollup.totalActiveMin), rollup.totalActiveMin,
), ),
cards: buildPerAnimeFromDailyRollups( cards: buildPerAnimeFromDailyRollups(
dailyRollups, dailyRollups,
@@ -633,7 +643,7 @@ export function getTrendsDashboard(
), ),
}, },
ratios: { ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions), lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
}, },
animePerDay, animePerDay,
animeCumulative: { animeCumulative: {

View File

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

View File

@@ -4,10 +4,7 @@ import { createInitialSessionState } from './reducer';
import { nowMs } from './time'; import { nowMs } from './time';
import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types'; import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types';
import type { SessionState } 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( export function startSessionRecord(
db: DatabaseSync, db: DatabaseSync,

View File

@@ -4,10 +4,7 @@ import type { DatabaseSync } from './sqlite';
import { nowMs } from './time'; import { nowMs } from './time';
import { SCHEMA_VERSION } from './types'; import { SCHEMA_VERSION } from './types';
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } 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 { export interface TrackerPreparedStatements {
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>; telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;

View File

@@ -1,6 +1,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path';
import electron from 'electron'; import electron from 'electron';
import { ensureDirForFile } from '../../shared/fs-utils';
const { safeStorage } = electron; const { safeStorage } = electron;
@@ -27,15 +27,8 @@ export interface JellyfinTokenStore {
clearSession: () => void; 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 { function writePayload(filePath: string, payload: PersistedSessionPayload): void {
ensureDirectory(filePath); ensureDirForFile(filePath);
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
} }

View File

@@ -59,3 +59,21 @@ export function handleOverlayWindowBeforeInputEvent(options: {
options.preventDefault(); options.preventDefault();
return true; 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,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
handleOverlayWindowBeforeInputEvent, handleOverlayWindowBeforeInputEvent,
handleOverlayWindowBlurred,
isTabInputForMpvForwarding, isTabInputForMpvForwarding,
} from './overlay-window-input'; } from './overlay-window-input';
@@ -82,3 +83,58 @@ test('handleOverlayWindowBeforeInputEvent leaves modal Tab handling alone', () =
assert.equal(handled, false); assert.equal(handled, false);
assert.deepEqual(calls, []); 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,6 +5,7 @@ import { createLogger } from '../../logger';
import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { import {
handleOverlayWindowBeforeInputEvent, handleOverlayWindowBeforeInputEvent,
handleOverlayWindowBlurred,
type OverlayWindowKind, type OverlayWindowKind,
} from './overlay-window-input'; } from './overlay-window-input';
import { buildOverlayWindowOptions } from './overlay-window-options'; import { buildOverlayWindowOptions } from './overlay-window-options';
@@ -124,12 +125,18 @@ export function createOverlayWindow(
}); });
window.on('blur', () => { window.on('blur', () => {
if (!window.isDestroyed()) { if (window.isDestroyed()) return;
options.ensureOverlayWindowLevel(window); handleOverlayWindowBlurred({
if (kind === 'visible' && window.isVisible()) { kind,
windowVisible: window.isVisible(),
isOverlayVisible: options.isOverlayVisible,
ensureOverlayWindowLevel: () => {
options.ensureOverlayWindowLevel(window);
},
moveWindowTop: () => {
window.moveTop(); window.moveTop();
} },
} });
}); });
if (options.isDev && kind === 'visible') { if (options.isDev && kind === 'visible') {

View File

@@ -1,8 +1,9 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { ImmersionTrackerService } from './immersion-tracker-service.js'; import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import { basename, extname, resolve, sep } from 'node:path'; import { basename, extname, resolve, sep } from 'node:path';
import { readFileSync, existsSync, statSync } from 'node:fs'; import { readFileSync, existsSync, statSync } from 'node:fs';
import { Readable } from 'node:stream';
import { MediaGenerator } from '../../media-generator.js'; import { MediaGenerator } from '../../media-generator.js';
import { AnkiConnectClient } from '../../anki-connect.js'; import { AnkiConnectClient } from '../../anki-connect.js';
import type { AnkiConnectConfig } from '../../types.js'; import type { AnkiConnectConfig } from '../../types.js';
@@ -60,6 +61,71 @@ function resolveStatsNoteFieldName(
return null; 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. */ /** Load known words cache from disk into a Set. Returns null if unavailable. */
function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null { function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
if (!cachePath || !existsSync(cachePath)) return null; if (!cachePath || !existsSync(cachePath)) return null;
@@ -156,26 +222,6 @@ export interface StatsServerConfig {
resolveAnkiNoteId?: (noteId: number) => number; 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> = { const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.gif': 'image/gif', '.gif': 'image/gif',
@@ -248,82 +294,6 @@ 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( export function createStatsApp(
tracker: ImmersionTrackerService, tracker: ImmersionTrackerService,
options?: { options?: {
@@ -1103,18 +1073,29 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
resolveAnkiNoteId: config.resolveAnkiNoteId, resolveAnkiNoteId: config.resolveAnkiNoteId,
}); });
const bunServe = (globalThis as typeof globalThis & Partial<BunRuntime>).Bun?.serve; const bunRuntime = globalThis as typeof globalThis & {
const server = bunServe Bun?: {
? bunServe({ serve?: (options: {
fetch: app.fetch, fetch: (typeof app)['fetch'];
port: config.port, port: number;
hostname: '127.0.0.1', hostname: string;
}) }) => { stop: () => void };
: 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,6 +35,21 @@ test('parseSrtCues handles multi-line subtitle text', () => {
assert.equal(cues[0]!.text, 'これは\nテストです'); 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', () => { test('parseSrtCues handles hours in timestamps', () => {
const content = ['1', '01:30:00,000 --> 01:30:05,000', 'テスト', ''].join('\n'); const content = ['1', '01:30:00,000 --> 01:30:05,000', 'テスト', ''].join('\n');
@@ -134,6 +149,18 @@ test('parseAssCues handles \\N line breaks', () => {
assert.equal(cues[0]!.text, '一行目\\N二行目'); 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', () => { test('parseAssCues returns empty for content without Events section', () => {
const content = ['[Script Info]', 'Title: Test'].join('\n'); const content = ['[Script Info]', 'Title: Test'].join('\n');

View File

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

View File

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

View File

@@ -1,94 +0,0 @@
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');
});

View File

@@ -1,40 +0,0 @@
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

@@ -1,339 +0,0 @@
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');
});

View File

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

View File

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

View File

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

View File

@@ -33,13 +33,66 @@ test('autoplay ready gate suppresses duplicate media signals unless forced while
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }); gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); 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)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands.slice(0, 3), [ assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
['script-message', 'subminer-autoplay-ready'],
['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')); assert.ok(
commands.some(
(command) =>
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
);
assert.equal(scheduled.length > 0, true); 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,19 +46,6 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath; const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const allowDuplicateWhilePaused = const allowDuplicateWhilePaused =
options?.forceWhilePaused === true && deps.getPlaybackPaused() !== false; 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 releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({ const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
forceWhilePaused: options?.forceWhilePaused === true, forceWhilePaused: options?.forceWhilePaused === true,
@@ -88,7 +75,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return true; return true;
}; };
const attemptRelease = (attempt: number): void => { const attemptRelease = (playbackGeneration: number, attempt: number): void => {
void (async () => { void (async () => {
if ( if (
autoPlayReadySignalMediaPath !== mediaPath || autoPlayReadySignalMediaPath !== mediaPath ||
@@ -100,7 +87,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const mpvClient = deps.getMpvClient(); const mpvClient = deps.getMpvClient();
if (!mpvClient?.connected) { if (!mpvClient?.connected) {
if (attempt < maxReleaseAttempts) { if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(attempt + 1), releaseRetryDelayMs); deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
} }
return; return;
} }
@@ -110,15 +97,27 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return; return;
} }
deps.signalPluginAutoplayReady();
mpvClient.send({ command: ['set_property', 'pause', false] }); mpvClient.send({ command: ['set_property', 'pause', false] });
if (attempt < maxReleaseAttempts) { if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(attempt + 1), releaseRetryDelayMs); deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
} }
})(); })();
}; };
attemptRelease(0); if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
return;
}
if (!duplicateMediaSignal) {
autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady();
attemptRelease(playbackGeneration, 0);
return;
}
const playbackGeneration = ++autoPlayReadySignalGeneration;
attemptRelease(playbackGeneration, 0);
}; };
return { return {

View File

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

View File

@@ -3,11 +3,14 @@ import assert from 'node:assert/strict';
import { composeAnilistSetupHandlers } from './anilist-setup-composer'; import { composeAnilistSetupHandlers } from './anilist-setup-composer';
test('composeAnilistSetupHandlers returns callable setup handlers', () => { test('composeAnilistSetupHandlers returns callable setup handlers', () => {
const calls: string[] = [];
const composed = composeAnilistSetupHandlers({ const composed = composeAnilistSetupHandlers({
notifyDeps: { notifyDeps: {
hasMpvClient: () => false, hasMpvClient: () => false,
showMpvOsd: () => {}, showMpvOsd: () => {},
showDesktopNotification: () => {}, showDesktopNotification: (title, opts) => {
calls.push(`notify:${opts.body}`);
},
logInfo: () => {}, logInfo: () => {},
}, },
consumeTokenDeps: { consumeTokenDeps: {
@@ -37,4 +40,16 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
assert.equal(typeof composed.consumeAnilistSetupTokenFromUrl, 'function'); assert.equal(typeof composed.consumeAnilistSetupTokenFromUrl, 'function');
assert.equal(typeof composed.handleAnilistSetupProtocolUrl, 'function'); assert.equal(typeof composed.handleAnilistSetupProtocolUrl, 'function');
assert.equal(typeof composed.registerSubminerProtocolClient, '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,9 +3,13 @@ import test from 'node:test';
import { composeAppReadyRuntime } from './app-ready-composer'; import { composeAppReadyRuntime } from './app-ready-composer';
test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => { test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => {
const calls: string[] = [];
const composed = composeAppReadyRuntime({ const composed = composeAppReadyRuntime({
reloadConfigMainDeps: { reloadConfigMainDeps: {
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }), reloadConfigStrict: () => {
calls.push('reloadConfigStrict');
return { ok: true, path: '/tmp/config.jsonc', warnings: [] };
},
logInfo: () => {}, logInfo: () => {},
logWarning: () => {}, logWarning: () => {},
showDesktopNotification: () => {}, showDesktopNotification: () => {},
@@ -79,4 +83,8 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
assert.equal(typeof composed.reloadConfig, 'function'); assert.equal(typeof composed.reloadConfig, 'function');
assert.equal(typeof composed.criticalConfigError, 'function'); assert.equal(typeof composed.criticalConfigError, 'function');
assert.equal(typeof composed.appReadyRuntimeRunner, 'function'); assert.equal(typeof composed.appReadyRuntimeRunner, 'function');
// reloadConfig invokes the injected reloadConfigStrict dep
composed.reloadConfig();
assert.deepEqual(calls, ['reloadConfigStrict']);
}); });

View File

@@ -1,8 +1,10 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import type { CliArgs } from '../../../cli/args';
import { composeCliStartupHandlers } from './cli-startup-composer'; import { composeCliStartupHandlers } from './cli-startup-composer';
test('composeCliStartupHandlers returns callable CLI startup handlers', () => { test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
const calls: string[] = [];
const handlers = composeCliStartupHandlers({ const handlers = composeCliStartupHandlers({
cliCommandContextMainDeps: { cliCommandContextMainDeps: {
appState: {} as never, appState: {} as never,
@@ -57,7 +59,9 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
startBackgroundWarmups: () => {}, startBackgroundWarmups: () => {},
logInfo: () => {}, logInfo: () => {},
}, },
handleCliCommandRuntimeServiceWithContext: () => {}, handleCliCommandRuntimeServiceWithContext: (args, _source, _ctx) => {
calls.push(`handleCommand:${(args as { command?: string }).command ?? 'unknown'}`);
},
}, },
initialArgsRuntimeHandlerMainDeps: { initialArgsRuntimeHandlerMainDeps: {
getInitialArgs: () => null, getInitialArgs: () => null,
@@ -80,4 +84,8 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
assert.equal(typeof handlers.createCliCommandContext, 'function'); assert.equal(typeof handlers.createCliCommandContext, 'function');
assert.equal(typeof handlers.handleCliCommand, 'function'); assert.equal(typeof handlers.handleCliCommand, 'function');
assert.equal(typeof handlers.handleInitialArgs, '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,9 +8,6 @@ export * from './ipc-runtime-composer';
export * from './jellyfin-remote-composer'; export * from './jellyfin-remote-composer';
export * from './jellyfin-runtime-composer'; export * from './jellyfin-runtime-composer';
export * from './mpv-runtime-composer'; export * from './mpv-runtime-composer';
export * from './overlay-window-composer';
export * from './overlay-visibility-runtime-composer'; export * from './overlay-visibility-runtime-composer';
export * from './shortcuts-runtime-composer'; export * from './shortcuts-runtime-composer';
export * from './stats-startup-composer';
export * from './subtitle-prefetch-runtime-composer';
export * from './startup-lifecycle-composer'; export * from './startup-lifecycle-composer';

View File

@@ -2,8 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer'; import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer';
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', () => { test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', async () => {
let lastProgressAt = 0; let lastProgressAt = 0;
let activePlayback: unknown = { itemId: 'item-1', mediaSourceId: 'src-1', playMethod: 'DirectPlay', audioStreamIndex: null, subtitleStreamIndex: null };
const calls: string[] = [];
const composed = composeJellyfinRemoteHandlers({ const composed = composeJellyfinRemoteHandlers({
getConfiguredSession: () => null, getConfiguredSession: () => null,
getClientInfo: () => getClientInfo: () =>
@@ -14,8 +17,11 @@ test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers',
getMpvClient: () => null, getMpvClient: () => null,
sendMpvCommand: () => {}, sendMpvCommand: () => {},
jellyfinTicksToSeconds: () => 0, jellyfinTicksToSeconds: () => 0,
getActivePlayback: () => null, getActivePlayback: () => activePlayback as never,
clearActivePlayback: () => {}, clearActivePlayback: () => {
activePlayback = null;
calls.push('clearActivePlayback');
},
getSession: () => null, getSession: () => null,
getNow: () => 0, getNow: () => 0,
getLastProgressAtMs: () => lastProgressAt, getLastProgressAtMs: () => lastProgressAt,
@@ -32,4 +38,9 @@ test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers',
assert.equal(typeof composed.handleJellyfinRemotePlay, 'function'); assert.equal(typeof composed.handleJellyfinRemotePlay, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function'); assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, '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,4 +190,9 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function'); assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
assert.equal(typeof composed.runJellyfinCommand, 'function'); assert.equal(typeof composed.runJellyfinCommand, 'function');
assert.equal(typeof composed.openJellyfinSetupWindow, '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,37 +30,13 @@ function createDeferred(): { promise: Promise<void>; resolve: () => void } {
return { promise, resolve }; return { promise, resolve };
} }
test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => { class DefaultFakeMpvClient {
const calls: string[] = []; connect(): void {}
let started = false; on(): void {}
let metrics = BASE_METRICS; }
let mecabTokenizer: { id: string } | null = null;
class FakeMpvClient { function createDefaultMpvFixture() {
connected = false; return {
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: { bindMpvMainEventHandlersMainDeps: {
appState: { appState: {
initialArgs: null, initialArgs: null,
@@ -97,15 +73,119 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
updateSubtitleRenderMetrics: () => {}, updateSubtitleRenderMetrics: () => {},
}, },
mpvClientRuntimeServiceFactoryMainDeps: { mpvClientRuntimeServiceFactoryMainDeps: {
createClient: FakeMpvClient, createClient: DefaultFakeMpvClient,
getSocketPath: () => '/tmp/mpv.sock', getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }), getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true, isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {}, setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false, isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null, getReconnectTimer: () => null,
setReconnectTimer: () => {}, 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: { updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => metrics, getCurrentMetrics: () => metrics,
setCurrentMetrics: (next) => { setCurrentMetrics: (next) => {
@@ -121,25 +201,12 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
}, },
}, },
tokenizer: { tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: { buildTokenizerDepsMainDeps: {
getYomitanExt: () => null, ...fixture.tokenizer.buildTokenizerDepsMainDeps,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: (text) => text === 'known', isKnownWord: (text) => text === 'known',
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true, getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true, getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
}, },
createTokenizerRuntimeDeps: (deps) => { createTokenizerRuntimeDeps: (deps) => {
calls.push('create-tokenizer-runtime-deps'); calls.push('create-tokenizer-runtime-deps');
@@ -184,12 +251,12 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
}, },
}, },
startBackgroundWarmupsMainDeps: { startBackgroundWarmupsMainDeps: {
...fixture.warmups.startBackgroundWarmupsMainDeps,
getStarted: () => started, getStarted: () => started,
setStarted: (next) => { setStarted: (next) => {
started = next; started = next;
calls.push(`set-started:${String(next)}`); calls.push(`set-started:${String(next)}`);
}, },
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => { ensureYomitanExtensionLoaded: async () => {
calls.push('warmup-yomitan'); calls.push('warmup-yomitan');
}, },
@@ -197,7 +264,6 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
shouldWarmupYomitanExtension: () => true, shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => true, shouldWarmupSubtitleDictionaries: () => true,
shouldWarmupJellyfinRemoteSession: () => true, shouldWarmupJellyfinRemoteSession: () => true,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => { startJellyfinRemoteSession: async () => {
calls.push('warmup-jellyfin'); calls.push('warmup-jellyfin');
}, },
@@ -264,86 +330,20 @@ test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annota
} }
} }
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers< const composed = composeMpvRuntimeHandlers<
FakeMpvClient, FakeMpvClient,
{ isKnownWord: (text: string) => boolean }, { isKnownWord: (text: string) => boolean },
{ text: string } { text: string }
>({ >({
bindMpvMainEventHandlersMainDeps: { ...fixture,
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: { mpvClientRuntimeServiceFactoryMainDeps: {
...fixture.mpvClientRuntimeServiceFactoryMainDeps,
createClient: FakeMpvClient, createClient: FakeMpvClient,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true, isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
}, },
tokenizer: { tokenizer: {
buildTokenizerDepsMainDeps: { ...fixture.tokenizer,
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: { createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => mecabTokenizer, getMecabTokenizer: () => mecabTokenizer,
setMecabTokenizer: (next) => { setMecabTokenizer: (next) => {
@@ -358,29 +358,6 @@ test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annota
calls.push('check-mecab'); 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 () => {},
},
}, },
}); });
@@ -395,98 +372,19 @@ test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential
let prewarmFrequencyCalls = 0; let prewarmFrequencyCalls = 0;
const tokenizeCalls: string[] = []; const tokenizeCalls: string[] = [];
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers< const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void }, { connect: () => void; on: () => void },
{ isKnownWord: () => boolean }, { isKnownWord: () => boolean },
{ text: string } { text: string }
>({ >({
bindMpvMainEventHandlersMainDeps: { ...fixture,
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: { tokenizer: {
buildTokenizerDepsMainDeps: { ...fixture.tokenizer,
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) => { tokenizeSubtitle: async (text) => {
tokenizeCalls.push(text); tokenizeCalls.push(text);
return { text }; return { text };
}, },
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: { prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => { ensureJlptDictionaryLookup: async () => {
prewarmJlptCalls += 1; prewarmJlptCalls += 1;
@@ -497,24 +395,12 @@ test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential
}, },
}, },
warmups: { warmups: {
launchBackgroundWarmupTaskMainDeps: { ...fixture.warmups,
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: { startBackgroundWarmupsMainDeps: {
getStarted: () => false, ...fixture.warmups.startBackgroundWarmupsMainDeps,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => { ensureYomitanExtensionLoaded: async () => {
yomitanWarmupCalls += 1; yomitanWarmupCalls += 1;
}, },
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
}, },
}, },
}); });
@@ -534,93 +420,23 @@ test('composeMpvRuntimeHandlers does not block first tokenization on dictionary
const mecabDeferred = createDeferred(); const mecabDeferred = createDeferred();
let tokenizeResolved = false; let tokenizeResolved = false;
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers< const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void }, { connect: () => void; on: () => void },
{ isKnownWord: () => boolean }, { isKnownWord: () => boolean },
{ text: string } { text: string }
>({ >({
bindMpvMainEventHandlersMainDeps: { ...fixture,
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: { tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: { buildTokenizerDepsMainDeps: {
getYomitanExt: () => null, ...fixture.tokenizer.buildTokenizerDepsMainDeps,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => true, getNPlusOneEnabled: () => true,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true, getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true, getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
}, },
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: { createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null, ...fixture.tokenizer.createMecabTokenizerAndCheckMainDeps,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => mecabDeferred.promise, checkAvailability: async () => mecabDeferred.promise,
}, },
prewarmSubtitleDictionariesMainDeps: { prewarmSubtitleDictionariesMainDeps: {
@@ -628,25 +444,6 @@ test('composeMpvRuntimeHandlers does not block first tokenization on dictionary
ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise, 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(() => { const tokenizePromise = composed.tokenizeSubtitle('first line').then(() => {
@@ -667,86 +464,19 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
const frequencyDeferred = createDeferred(); const frequencyDeferred = createDeferred();
const osdMessages: string[] = []; const osdMessages: string[] = [];
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers< const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void }, { connect: () => void; on: () => void },
{ onTokenizationReady?: (text: string) => void }, { onTokenizationReady?: (text: string) => void },
{ text: string } { text: string }
>({ >({
bindMpvMainEventHandlersMainDeps: { ...fixture,
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: { tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: { buildTokenizerDepsMainDeps: {
getYomitanExt: () => null, ...fixture.tokenizer.buildTokenizerDepsMainDeps,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true, getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true, getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
}, },
createTokenizerRuntimeDeps: (deps) => createTokenizerRuntimeDeps: (deps) =>
deps as unknown as { onTokenizationReady?: (text: string) => void }, deps as unknown as { onTokenizationReady?: (text: string) => void },
@@ -754,12 +484,6 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
deps.onTokenizationReady?.(text); deps.onTokenizationReady?.(text);
return { text }; return { text };
}, },
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: { prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => jlptDeferred.promise, ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise, ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise,
@@ -768,25 +492,6 @@ 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(); const warmupPromise = composed.startTokenizationWarmups();
@@ -814,89 +519,22 @@ test('composeMpvRuntimeHandlers reuses completed background tokenization warmups
let frequencyWarmupCalls = 0; let frequencyWarmupCalls = 0;
let mecabTokenizer: { tokenize: () => Promise<never[]> } | null = null; let mecabTokenizer: { tokenize: () => Promise<never[]> } | null = null;
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers< const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void }, { connect: () => void; on: () => void },
{ isKnownWord: () => boolean }, { isKnownWord: () => boolean },
{ text: string } { text: string }
>({ >({
bindMpvMainEventHandlersMainDeps: { ...fixture,
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: { tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: { buildTokenizerDepsMainDeps: {
getYomitanExt: () => null, ...fixture.tokenizer.buildTokenizerDepsMainDeps,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => true, getNPlusOneEnabled: () => true,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true, getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true, getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => mecabTokenizer, getMecabTokenizer: () => mecabTokenizer,
}, },
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: { createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => mecabTokenizer, getMecabTokenizer: () => mecabTokenizer,
setMecabTokenizer: (next) => { setMecabTokenizer: (next) => {
@@ -917,26 +555,19 @@ test('composeMpvRuntimeHandlers reuses completed background tokenization warmups
}, },
}, },
warmups: { warmups: {
launchBackgroundWarmupTaskMainDeps: { ...fixture.warmups,
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: { startBackgroundWarmupsMainDeps: {
...fixture.warmups.startBackgroundWarmupsMainDeps,
getStarted: () => started, getStarted: () => started,
setStarted: (next) => { setStarted: (next) => {
started = next; started = next;
}, },
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => { ensureYomitanExtensionLoaded: async () => {
yomitanWarmupCalls += 1; yomitanWarmupCalls += 1;
}, },
shouldWarmupMecab: () => true, shouldWarmupMecab: () => true,
shouldWarmupYomitanExtension: () => true, shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => true, shouldWarmupSubtitleDictionaries: () => true,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
}, },
}, },
}); });

View File

@@ -3,15 +3,20 @@ import test from 'node:test';
import { composeOverlayVisibilityRuntime } from './overlay-visibility-runtime-composer'; import { composeOverlayVisibilityRuntime } from './overlay-visibility-runtime-composer';
test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () => { test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () => {
const calls: string[] = [];
const composed = composeOverlayVisibilityRuntime({ const composed = composeOverlayVisibilityRuntime({
overlayVisibilityRuntime: { overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => {}, updateVisibleOverlayVisibility: () => {
calls.push('updateVisibleOverlayVisibility');
},
}, },
restorePreviousSecondarySubVisibilityMainDeps: { restorePreviousSecondarySubVisibilityMainDeps: {
getMpvClient: () => null, getMpvClient: () => null,
}, },
broadcastRuntimeOptionsChangedMainDeps: { broadcastRuntimeOptionsChangedMainDeps: {
broadcastRuntimeOptionsChangedRuntime: () => {}, broadcastRuntimeOptionsChangedRuntime: () => {
calls.push('broadcastRuntimeOptionsChangedRuntime');
},
getRuntimeOptionsState: () => [], getRuntimeOptionsState: () => [],
broadcastToOverlayWindows: () => {}, broadcastToOverlayWindows: () => {},
}, },
@@ -24,7 +29,9 @@ test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () =
setCurrentEnabled: () => {}, setCurrentEnabled: () => {},
}, },
openRuntimeOptionsPaletteMainDeps: { openRuntimeOptionsPaletteMainDeps: {
openRuntimeOptionsPaletteRuntime: () => {}, openRuntimeOptionsPaletteRuntime: () => {
calls.push('openRuntimeOptionsPaletteRuntime');
},
}, },
}); });
@@ -34,4 +41,16 @@ test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () =
assert.equal(typeof composed.sendToActiveOverlayWindow, 'function'); assert.equal(typeof composed.sendToActiveOverlayWindow, 'function');
assert.equal(typeof composed.setOverlayDebugVisualizationEnabled, 'function'); assert.equal(typeof composed.setOverlayDebugVisualizationEnabled, 'function');
assert.equal(typeof composed.openRuntimeOptionsPalette, '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

@@ -1,34 +0,0 @@
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

@@ -1,18 +0,0 @@
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,6 +3,7 @@ import test from 'node:test';
import { composeShortcutRuntimes } from './shortcuts-runtime-composer'; import { composeShortcutRuntimes } from './shortcuts-runtime-composer';
test('composeShortcutRuntimes returns callable shortcut runtime handlers', () => { test('composeShortcutRuntimes returns callable shortcut runtime handlers', () => {
const calls: string[] = [];
const composed = composeShortcutRuntimes({ const composed = composeShortcutRuntimes({
globalShortcuts: { globalShortcuts: {
getConfiguredShortcutsMainDeps: { getConfiguredShortcutsMainDeps: {
@@ -39,9 +40,13 @@ test('composeShortcutRuntimes returns callable shortcut runtime handlers', () =>
}, },
overlayShortcutsRuntimeMainDeps: { overlayShortcutsRuntimeMainDeps: {
overlayShortcutsRuntime: { overlayShortcutsRuntime: {
registerOverlayShortcuts: () => {}, registerOverlayShortcuts: () => {
calls.push('registerOverlayShortcuts');
},
unregisterOverlayShortcuts: () => {}, unregisterOverlayShortcuts: () => {},
syncOverlayShortcuts: () => {}, syncOverlayShortcuts: () => {
calls.push('syncOverlayShortcuts');
},
refreshOverlayShortcuts: () => {}, refreshOverlayShortcuts: () => {},
}, },
}, },
@@ -58,4 +63,12 @@ test('composeShortcutRuntimes returns callable shortcut runtime handlers', () =>
assert.equal(typeof composed.unregisterOverlayShortcuts, 'function'); assert.equal(typeof composed.unregisterOverlayShortcuts, 'function');
assert.equal(typeof composed.syncOverlayShortcuts, 'function'); assert.equal(typeof composed.syncOverlayShortcuts, 'function');
assert.equal(typeof composed.refreshOverlayShortcuts, '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,6 +3,7 @@ import test from 'node:test';
import { composeStartupLifecycleHandlers } from './startup-lifecycle-composer'; import { composeStartupLifecycleHandlers } from './startup-lifecycle-composer';
test('composeStartupLifecycleHandlers returns callable startup lifecycle handlers', () => { test('composeStartupLifecycleHandlers returns callable startup lifecycle handlers', () => {
const calls: string[] = [];
const composed = composeStartupLifecycleHandlers({ const composed = composeStartupLifecycleHandlers({
registerProtocolUrlHandlersMainDeps: { registerProtocolUrlHandlersMainDeps: {
registerOpenUrl: () => {}, registerOpenUrl: () => {},
@@ -51,7 +52,9 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
getAllWindowCount: () => 0, getAllWindowCount: () => 0,
}, },
restoreWindowsOnActivateMainDeps: { restoreWindowsOnActivateMainDeps: {
createMainWindow: () => {}, createMainWindow: () => {
calls.push('createMainWindow');
},
updateVisibleOverlayVisibility: () => {}, updateVisibleOverlayVisibility: () => {},
syncOverlayMpvSubtitleSuppression: () => {}, syncOverlayMpvSubtitleSuppression: () => {},
}, },
@@ -61,4 +64,11 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
assert.equal(typeof composed.onWillQuitCleanup, 'function'); assert.equal(typeof composed.onWillQuitCleanup, 'function');
assert.equal(typeof composed.shouldRestoreWindowsOnActivate, 'function'); assert.equal(typeof composed.shouldRestoreWindowsOnActivate, 'function');
assert.equal(typeof composed.restoreWindowsOnActivate, '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

@@ -1,23 +0,0 @@
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

@@ -1,26 +0,0 @@
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

@@ -1,23 +0,0 @@
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

@@ -1,32 +0,0 @@
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

@@ -0,0 +1,38 @@
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

@@ -0,0 +1,49 @@
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,6 +7,7 @@ import {
detectInstalledFirstRunPlugin, detectInstalledFirstRunPlugin,
installFirstRunPluginToDefaultLocation, installFirstRunPluginToDefaultLocation,
resolvePackagedFirstRunPluginAssets, resolvePackagedFirstRunPluginAssets,
syncInstalledFirstRunPluginBinaryPath,
} from './first-run-setup-plugin'; } from './first-run-setup-plugin';
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state'; import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
@@ -68,13 +69,17 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
dirname: path.join(root, 'dist', 'main', 'runtime'), dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'), appPath: path.join(root, 'app'),
resourcesPath, resourcesPath,
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
}); });
assert.equal(result.ok, true); assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed'); assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true); assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin'); assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n'); assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'configured=true\nbinary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\n',
);
const scriptsDirEntries = fs.readdirSync(installPaths.scriptsDir); const scriptsDirEntries = fs.readdirSync(installPaths.scriptsDir);
const scriptOptsEntries = fs.readdirSync(installPaths.scriptOptsDir); const scriptOptsEntries = fs.readdirSync(installPaths.scriptOptsDir);
@@ -113,13 +118,17 @@ test('installFirstRunPluginToDefaultLocation installs plugin to Windows mpv defa
dirname: path.join(root, 'dist', 'main', 'runtime'), dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'), appPath: path.join(root, 'app'),
resourcesPath, resourcesPath,
binaryPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
}); });
assert.equal(result.ok, true); assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed'); assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true); assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin'); assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n'); assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'configured=true\nbinary_path=C:\\Program Files\\SubMiner\\SubMiner.exe\n',
);
}); });
}); });
@@ -146,12 +155,70 @@ test('installFirstRunPluginToDefaultLocation rewrites Windows plugin socket_path
dirname: path.join(root, 'dist', 'main', 'runtime'), dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'), appPath: path.join(root, 'app'),
resourcesPath, resourcesPath,
binaryPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
}); });
assert.equal(result.ok, true); assert.equal(result.ok, true);
assert.equal( assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=\nsocket_path=\\\\.\\pipe\\subminer-socket\n', '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',
); );
}); });
}); });

View File

@@ -28,6 +28,43 @@ 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: { export function resolvePackagedFirstRunPluginAssets(deps: {
dirname: string; dirname: string;
appPath: string; appPath: string;
@@ -79,6 +116,7 @@ export function installFirstRunPluginToDefaultLocation(options: {
dirname: string; dirname: string;
appPath: string; appPath: string;
resourcesPath: string; resourcesPath: string;
binaryPath: string;
}): PluginInstallResult { }): PluginInstallResult {
const installPaths = resolveDefaultMpvInstallPaths( const installPaths = resolveDefaultMpvInstallPaths(
options.platform, options.platform,
@@ -116,6 +154,7 @@ export function installFirstRunPluginToDefaultLocation(options: {
backupExistingPath(installPaths.pluginConfigPath); backupExistingPath(installPaths.pluginConfigPath);
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true }); fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath); fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
if (options.platform === 'win32') { if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath); rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
} }
@@ -127,3 +166,33 @@ export function installFirstRunPluginToDefaultLocation(options: {
message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`, 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,16 +1,26 @@
export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: { interface SetupWindowConfig {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; width: number;
}) { height: number;
title: string;
resizable?: boolean;
minimizable?: boolean;
maximizable?: boolean;
}
function createSetupWindowHandler<TWindow>(
deps: { createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow },
config: SetupWindowConfig,
) {
return (): TWindow => return (): TWindow =>
deps.createBrowserWindow({ deps.createBrowserWindow({
width: 480, width: config.width,
height: 460, height: config.height,
title: 'SubMiner Setup', title: config.title,
show: true, show: true,
autoHideMenuBar: true, autoHideMenuBar: true,
resizable: false, ...(config.resizable === undefined ? {} : { resizable: config.resizable }),
minimizable: false, ...(config.minimizable === undefined ? {} : { minimizable: config.minimizable }),
maximizable: false, ...(config.maximizable === undefined ? {} : { maximizable: config.maximizable }),
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
@@ -18,36 +28,35 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
}); });
} }
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: { export function createCreateJellyfinSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) { }) {
return (): TWindow => return createSetupWindowHandler(deps, {
deps.createBrowserWindow({ width: 520,
width: 520, height: 560,
height: 560, title: 'Jellyfin Setup',
title: 'Jellyfin Setup', });
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
} }
export function createCreateAnilistSetupWindowHandler<TWindow>(deps: { export function createCreateAnilistSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) { }) {
return (): TWindow => return createSetupWindowHandler(deps, {
deps.createBrowserWindow({ width: 1000,
width: 1000, height: 760,
height: 760, title: 'Anilist Setup',
title: 'Anilist Setup', });
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
} }

View File

@@ -6,6 +6,7 @@ test('youtube playback runtime resets flow ownership after a successful run', as
const calls: string[] = []; const calls: string[] = [];
let appOwnedFlowInFlight = false; let appOwnedFlowInFlight = false;
let timeoutCallback: (() => void) | null = null; let timeoutCallback: (() => void) | null = null;
let socketPath = '/tmp/mpv.sock';
const runtime = createYoutubePlaybackRuntime({ const runtime = createYoutubePlaybackRuntime({
platform: 'linux', platform: 'linux',
@@ -13,7 +14,7 @@ test('youtube playback runtime resets flow ownership after a successful run', as
mpvYtdlFormat: 'bestvideo+bestaudio', mpvYtdlFormat: 'bestvideo+bestaudio',
autoLaunchTimeoutMs: 2_000, autoLaunchTimeoutMs: 2_000,
connectTimeoutMs: 1_000, connectTimeoutMs: 1_000,
socketPath: '/tmp/mpv.sock', getSocketPath: () => socketPath,
getMpvConnected: () => true, getMpvConnected: () => true,
invalidatePendingAutoplayReadyFallbacks: () => { invalidatePendingAutoplayReadyFallbacks: () => {
calls.push('invalidate-autoplay'); calls.push('invalidate-autoplay');
@@ -78,3 +79,70 @@ test('youtube playback runtime resets flow ownership after a successful run', as
scheduledCallback(); scheduledCallback();
assert.equal(runtime.getQuitOnDisconnectArmed(), true); 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; mpvYtdlFormat: string;
autoLaunchTimeoutMs: number; autoLaunchTimeoutMs: number;
connectTimeoutMs: number; connectTimeoutMs: number;
socketPath: string; getSocketPath: () => string;
getMpvConnected: () => boolean; getMpvConnected: () => boolean;
invalidatePendingAutoplayReadyFallbacks: () => void; invalidatePendingAutoplayReadyFallbacks: () => void;
setAppOwnedFlowInFlight: (next: boolean) => void; setAppOwnedFlowInFlight: (next: boolean) => void;
@@ -76,6 +76,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
} }
if (deps.platform === 'win32' && !deps.getMpvConnected()) { if (deps.platform === 'win32' && !deps.getMpvConnected()) {
const socketPath = deps.getSocketPath();
const launchResult = deps.launchWindowsMpv(playbackUrl, [ const launchResult = deps.launchWindowsMpv(playbackUrl, [
'--pause=yes', '--pause=yes',
'--ytdl=yes', '--ytdl=yes',
@@ -87,7 +88,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
'--secondary-sub-visibility=no', '--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
`--input-ipc-server=${deps.socketPath}`, `--input-ipc-server=${socketPath}`,
]); ]);
launchedWindowsMpv = launchResult.ok; launchedWindowsMpv = launchResult.ok;
if (launchResult.ok && launchResult.mpvPath) { if (launchResult.ok && launchResult.mpvPath) {

View File

@@ -518,6 +518,26 @@ 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 () => { test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();

View File

@@ -1241,6 +1241,7 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
const previousDocument = globals.document; const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = []; const mpvCommands: Array<Array<string | number>> = [];
const modalListeners = new Map<string, Array<() => void>>(); const modalListeners = new Map<string, Array<() => void>>();
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = { const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }], cues: [{ startTime: 1, endTime: 2, text: 'first' }],
@@ -1317,6 +1318,11 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
subtitleSidebarContent: { subtitleSidebarContent: {
classList: createClassList(), classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }), getBoundingClientRect: () => ({ width: 420 }),
addEventListener: (type: string, listener: () => void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
}, },
subtitleSidebarClose: { addEventListener: () => {} }, subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' }, subtitleSidebarStatus: { textContent: '' },
@@ -1333,7 +1339,7 @@ test('subtitle sidebar closes and resumes a hover pause', async () => {
await modal.openSubtitleSidebarModal(); await modal.openSubtitleSidebarModal();
await modal.refreshSubtitleSidebarSnapshot(); await modal.refreshSubtitleSidebarSnapshot();
mpvCommands.length = 0; mpvCommands.length = 0;
await modalListeners.get('mouseenter')?.[0]?.(); await contentListeners.get('mouseenter')?.[0]?.();
assert.deepEqual(mpvCommands.at(-1), ['set_property', 'pause', 'yes']); assert.deepEqual(mpvCommands.at(-1), ['set_property', 'pause', 'yes']);
@@ -1353,6 +1359,7 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
const previousDocument = globals.document; const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = []; const mpvCommands: Array<Array<string | number>> = [];
const modalListeners = new Map<string, Array<() => Promise<void> | void>>(); const modalListeners = new Map<string, Array<() => Promise<void> | void>>();
const contentListeners = new Map<string, Array<() => Promise<void> | void>>();
const snapshot: SubtitleSidebarSnapshot = { const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }], cues: [{ startTime: 1, endTime: 2, text: 'first' }],
@@ -1431,6 +1438,11 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
subtitleSidebarContent: { subtitleSidebarContent: {
classList: createClassList(), classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }), getBoundingClientRect: () => ({ width: 420 }),
addEventListener: (type: string, listener: () => Promise<void> | void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
}, },
subtitleSidebarClose: { addEventListener: () => {} }, subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' }, subtitleSidebarStatus: { textContent: '' },
@@ -1446,7 +1458,7 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
await modal.openSubtitleSidebarModal(); await modal.openSubtitleSidebarModal();
await assert.doesNotReject(async () => { await assert.doesNotReject(async () => {
await modalListeners.get('mouseenter')?.[0]?.(); await contentListeners.get('mouseenter')?.[0]?.();
}); });
assert.equal(state.subtitleSidebarPausedByHover, false); assert.equal(state.subtitleSidebarPausedByHover, false);
@@ -1744,6 +1756,7 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
const mpvCommands: Array<Array<string | number>> = []; const mpvCommands: Array<Array<string | number>> = [];
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = []; const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
const modalListeners = new Map<string, Array<() => void>>(); const modalListeners = new Map<string, Array<() => void>>();
const contentListeners = new Map<string, Array<() => void>>();
const snapshot: SubtitleSidebarSnapshot = { const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }], cues: [{ startTime: 1, endTime: 2, text: 'first' }],
@@ -1823,6 +1836,11 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
subtitleSidebarContent: { subtitleSidebarContent: {
classList: createClassList(), classList: createClassList(),
getBoundingClientRect: () => ({ width: 360 }), getBoundingClientRect: () => ({ width: 360 }),
addEventListener: (type: string, listener: () => void) => {
const bucket = contentListeners.get(type) ?? [];
bucket.push(listener);
contentListeners.set(type, bucket);
},
}, },
subtitleSidebarClose: { addEventListener: () => {} }, subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' }, subtitleSidebarStatus: { textContent: '' },
@@ -1842,15 +1860,15 @@ test('subtitle sidebar embedded layout restores macOS and Windows passthrough ou
await modal.openSubtitleSidebarModal(); await modal.openSubtitleSidebarModal();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]); assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
modalListeners.get('mouseenter')?.[0]?.(); contentListeners.get('mouseenter')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]); assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
modalListeners.get('mouseleave')?.[0]?.(); contentListeners.get('mouseleave')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]); assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
state.isOverSubtitle = true; state.isOverSubtitle = true;
modalListeners.get('mouseenter')?.[0]?.(); contentListeners.get('mouseenter')?.[0]?.();
modalListeners.get('mouseleave')?.[0]?.(); contentListeners.get('mouseleave')?.[0]?.();
assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]); assert.deepEqual(ignoreMouseCalls.at(-1), [false, undefined]);
void mpvCommands; void mpvCommands;
@@ -1860,6 +1878,251 @@ 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 () => { test('closing embedded subtitle sidebar recomputes passthrough from remaining subtitle hover state', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window; const previousWindow = globals.window;

View File

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

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