mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-26 12:11:26 -07:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
23c54bb01e
|
|||
|
ec667c64e8
|
|||
|
39b2ccad8e
|
|||
|
23815945bf
|
|||
|
9dca83acd9
|
|||
|
55300e2d8c
|
|||
|
28afd15134
|
|||
|
58304757aa
|
|||
|
c95518e94a
|
|||
| 5ee4617607 | |||
|
842008b089
|
|||
|
6f56a0bcf6
|
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.9.2 (2026-03-25)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Overlay: Fixed overlay pointer tracking so Windows click-through toggles immediately when the cursor enters or leaves subtitle regions, without waiting for a later hover resync.
|
||||||
|
- Overlay: Fixed Windows overlay window tracking on scaled displays by converting native tracked window bounds to Electron DIP coordinates before applying overlay bounds.
|
||||||
|
- Launcher: Fixed Windows direct `--youtube-play` startup so MPV boots reliably, stays paused until the app-owned subtitle flow is ready, and reuses an already-running SubMiner instance when available.
|
||||||
|
- Launcher: Fixed standalone Windows `--youtube-play` sessions so closing MPV fully exits SubMiner instead of leaving hidden overlay windows or a background process behind.
|
||||||
|
- Overlay: Fixed `subminer <youtube-url>` on Linux so the YouTube playback flow waits for Yomitan to load before creating the overlay window, avoiding the broken lookup popup state that previously required a manual overlay refresh.
|
||||||
|
|
||||||
|
## v0.9.1 (2026-03-24)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Release: Reduced packaged release size by excluding duplicate `extraResources` payload and pruning docs, tests, sourcemaps, and other source-only files from Electron bundles.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Overlay: Restored controller navigation and lookup/mining controls while the subtitle sidebar is open, while keeping true modal dialogs blocking controller actions.
|
||||||
|
- Tokenizer: Fixed subtitle annotation clearing so explanatory contrast endings like `んですけど` are excluded consistently across the shared tokenizer filter and annotation stage.
|
||||||
|
|
||||||
## v0.9.0 (2026-03-23)
|
## v0.9.0 (2026-03-23)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
id: TASK-231
|
||||||
|
title: Restore controller input while subtitle sidebar is open
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- '@codex'
|
||||||
|
created_date: '2026-03-24 00:15'
|
||||||
|
updated_date: '2026-03-24 00:15'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- controller
|
||||||
|
- subtitle-sidebar
|
||||||
|
- overlay
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/renderer/renderer.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/renderer/controller-interaction-blocking.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/renderer/controller-interaction-blocking.test.ts
|
||||||
|
priority: high
|
||||||
|
ordinal: 54900
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
When keyboard-only mode is active, opening the subtitle sidebar should not disable controller navigation and lookup/mining controls. Restore controller input while the sidebar is open, while keeping true modal dialogs blocking controller actions.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Opening the subtitle sidebar does not block controller input for keyboard-only mode actions.
|
||||||
|
- [x] #2 Controller-select/debug and other true modal dialogs still block controller actions while open.
|
||||||
|
- [x] #3 Focused regression coverage exists for the sidebar-open controller gating rule.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
|
||||||
|
Root cause: renderer gamepad polling used the broad `isAnyModalOpen()` check as its interaction gate, and that list includes `subtitleSidebarModalOpen`. The subtitle sidebar is non-modal for controller usage, so gamepad input was being suppressed whenever the sidebar was visible.
|
||||||
|
|
||||||
|
Fixed by extracting a dedicated controller-interaction blocking helper that excludes the subtitle sidebar but keeps the existing blocking behavior for true modal dialogs.
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Restored controller input while the subtitle sidebar is open by switching gamepad polling to a dedicated modal-blocking rule that leaves the sidebar controller-passive. Added a regression test covering the sidebar-open exception and preserving hard blocks for actual modal dialogs.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
id: TASK-232
|
||||||
|
title: Trim release package size by pruning duplicate and source-only assets
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- '@codex'
|
||||||
|
created_date: '2026-03-24 12:05'
|
||||||
|
updated_date: '2026-03-24 12:30'
|
||||||
|
labels:
|
||||||
|
- release
|
||||||
|
- packaging
|
||||||
|
priority: medium
|
||||||
|
ordinal: 54700
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/package.json
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/release-workflow.test.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/core/services/texthooker.ts
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Reduce packaged release artifact size without changing user-visible functionality by pruning files that are duplicated between `app.asar` and `extraResources`, excluding source/test/doc-only trees from Electron packaging, and trimming obviously non-runtime vendored payload.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Electron packaging excludes repo content that is source-only, test-only, docs-only, or duplicated by `extraResources`.
|
||||||
|
- [x] #2 Release packaging tests cover the new exclusion rules.
|
||||||
|
- [x] #3 Verification includes at least targeted release-packaging tests and one packaging-oriented validation step.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
|
||||||
|
Completed scope:
|
||||||
|
- Exclude `assets`, `plugin`, and `vendor/yomitan-jlpt-vocab` from `files` because they are already staged via `extraResources`.
|
||||||
|
- Exclude `dist` sourcemaps/tests, repo docs/tests/packaging metadata, and stats source leftovers from `files`.
|
||||||
|
- Exclude non-runtime `vendor/texthooker-ui` payload such as `public/`, `.vscode/`, and package metadata.
|
||||||
|
- Exclude Linux musl libsql binary from packaged app payload for AppImage-focused savings.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `bun test src/release-workflow.test.ts`
|
||||||
|
- `bun run build`
|
||||||
|
- `node_modules/.bin/electron-builder --linux dir --publish never`
|
||||||
|
- `node_modules/.bin/electron-builder --linux AppImage --publish never`
|
||||||
|
|
||||||
|
Observed result:
|
||||||
|
- `release/linux-unpacked/resources/app.asar` dropped from about `100 MB` to `29 MB`.
|
||||||
|
- `release/SubMiner-0.9.0.AppImage` dropped from about `256 MB` to `194 MB`.
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Trimmed Electron packaging so release artifacts no longer bundle duplicated `extraResources`, source/test/doc-only repo content, non-runtime `texthooker-ui` files, or the Linux musl libsql binary. Added release-packaging regression coverage and verified the Linux package shrink with fresh local builds.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
id: TASK-233
|
||||||
|
title: Cut patch release v0.9.1 for package size pruning
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- '@codex'
|
||||||
|
created_date: '2026-03-24 12:40'
|
||||||
|
updated_date: '2026-03-24 12:55'
|
||||||
|
labels:
|
||||||
|
- release
|
||||||
|
- patch
|
||||||
|
dependencies:
|
||||||
|
- TASK-232
|
||||||
|
references:
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/package.json
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/CHANGELOG.md
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/release/release-notes.md
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/docs-site/changelog.md
|
||||||
|
priority: high
|
||||||
|
ordinal: 54800
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Publish a patch release for the packaging-size cleanup by bumping the app version to `0.9.1`, generating committed release metadata, and keeping release-facing docs/changelog surfaces aligned.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Repository version metadata is updated to `0.9.1`.
|
||||||
|
- [x] #2 `CHANGELOG.md`, `release/release-notes.md`, and `docs-site/changelog.md` contain the committed `v0.9.1` release line and the consumed fragment is removed.
|
||||||
|
- [x] #3 Release-readiness verification passes for changelog, docs, tests, and build lanes.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
|
||||||
|
Completed:
|
||||||
|
- Bumped `package.json` to `0.9.1`.
|
||||||
|
- Ran `bun run changelog:build --version 0.9.1 --date 2026-03-24`, which generated `CHANGELOG.md` + `release/release-notes.md` and consumed both pending release fragments.
|
||||||
|
- Synced `docs-site/changelog.md` with the generated `v0.9.1` release line.
|
||||||
|
- Confirmed no additional README/docs wording changes were needed beyond changelog surfaces.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `bun run changelog:lint`
|
||||||
|
- `bun run changelog:check --version 0.9.1`
|
||||||
|
- `bun run verify:config-example`
|
||||||
|
- `bun run typecheck`
|
||||||
|
- `bun run test:fast`
|
||||||
|
- `bun run test:env`
|
||||||
|
- `bun run build`
|
||||||
|
- `bun run docs:test`
|
||||||
|
- `bun run docs:build`
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Prepared patch release `v0.9.1` locally. Version metadata, committed changelog artifacts, release notes, and docs-site changelog are aligned, and the release gate is green. Pending manual release actions are the release-prep commit, `git tag v0.9.1`, and push/tag publication.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
id: TASK-234
|
||||||
|
title: 'Address PR #35 latest CodeRabbit review round'
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-03-26 03:59'
|
||||||
|
updated_date: '2026-03-26 04:01'
|
||||||
|
labels:
|
||||||
|
- review-comments
|
||||||
|
- coderabbit
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/cli/args.test.ts
|
||||||
|
- >-
|
||||||
|
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/cli-command-prechecks.test.ts
|
||||||
|
- >-
|
||||||
|
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/youtube-playback-launch.ts
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Assess and implement the latest actionable CodeRabbit feedback on PR #35 for the Windows YouTube playback flow. Scope includes fixing the overlapping youtubePlay cleanup race in main runtime state and any low-risk follow-up test/clarity comments from the same review round.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Overlapping youtubePlay requests no longer let an older flow clear active quit-on-disconnect/app-owned-flow state for a newer flow.
|
||||||
|
- [x] #2 Latest low-risk CodeRabbit test and clarity follow-ups for this PR round are addressed or intentionally rejected based on code verification.
|
||||||
|
- [x] #3 Relevant tests covering the touched areas pass locally.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Update runYoutubePlaybackFlowMain in src/main.ts to use a per-request generation guard around shared YouTube flow state so overlapping requests cannot clear the active timer, armed flag, or app-owned-flow marker for a newer request.
|
||||||
|
2. Address verified low-risk latest-round follow-ups: add direct startup-prereq assertions in src/cli/args.test.ts, extend side-effect assertions in src/main/runtime/cli-command-prechecks.test.ts, and rename the youtube-playback-launch polling variable for clarity.
|
||||||
|
3. Run targeted Bun tests for the touched areas and record results in the task notes/final summary.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented per-request youtubePlaybackFlowGeneration guard in src/main.ts so superseded youtubePlay flows cannot clear the active arm timer, armed flag, or app-owned-flow state for a newer request.
|
||||||
|
|
||||||
|
Added explicit startup-prereq assertions in src/cli/args.test.ts and stronger warmup/log side-effect assertions in src/main/runtime/cli-command-prechecks.test.ts for the latest CodeRabbit follow-ups.
|
||||||
|
|
||||||
|
Renamed youtube-playback-launch polling variable from pathChanged to pathDiffersFromInitial for accuracy without behavior change.
|
||||||
|
|
||||||
|
Verification: bun test src/cli/args.test.ts; bun test src/main/runtime/cli-command-prechecks.test.ts; bun test src/main/runtime/youtube-playback-launch.test.ts; bun run typecheck.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Addressed the latest PR #35 CodeRabbit round by making YouTube playback flow cleanup generation-safe in src/main.ts. Overlapping youtubePlay requests now isolate timer/armed/app-owned-flow cleanup to the currently active request so an older flow cannot clear state for its replacement.
|
||||||
|
|
||||||
|
Also folded in the latest low-risk follow-ups: args tests now assert that youtube playback requires overlay startup prerequisites, cli-command precheck tests now assert warmup/log side effects for the youtube transition, and youtube-playback-launch.ts uses a clearer variable name for the initial-path comparison.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- bun test src/cli/args.test.ts
|
||||||
|
- bun test src/main/runtime/cli-command-prechecks.test.ts
|
||||||
|
- bun test src/main/runtime/youtube-playback-launch.test.ts
|
||||||
|
- bun run typecheck
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
id: TASK-235
|
||||||
|
title: 'Address PR #35 autoplay retry CodeRabbit follow-up'
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-03-26 04:30'
|
||||||
|
updated_date: '2026-03-26 04:31'
|
||||||
|
labels:
|
||||||
|
- review-comments
|
||||||
|
- coderabbit
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Assess and implement the latest CodeRabbit follow-up on PR #35 concerning stale autoplay-ready fallback retries interfering with a new app-owned YouTube playback flow in main.ts.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Starting a new app-owned YouTube playback flow invalidates any pending autoplay-ready fallback retries from older playback state before mpv prep begins.
|
||||||
|
- [x] #2 Relevant verification for the touched main.ts autoplay retry logic passes locally.
|
||||||
|
- [x] #3 Task notes/final summary capture the fix and verification.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add a helper in src/main.ts that invalidates pending autoplay-ready fallback retry state by clearing the tracked media path and advancing the autoplay generation counter.
|
||||||
|
2. Invoke that helper at the start of runYoutubePlaybackFlowMain before app-owned YouTube playback takes over so stale retries cannot unpause reused playback.
|
||||||
|
3. Run relevant verification for the touched main.ts path and record results in the task notes/final summary.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Added invalidatePendingAutoplayReadyFallbacks() in src/main.ts to clear the tracked autoplay-ready media path and advance the autoplay generation before a new app-owned YouTube flow claims playback. This invalidates stale fallback retry closures even when the reused playback path is the same.
|
||||||
|
|
||||||
|
Verification: bun test src/main/runtime/mpv-main-event-actions.test.ts; bun test src/main/runtime/startup-autoplay-release-policy.test.ts; bun run typecheck.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Addressed the latest PR #35 CodeRabbit follow-up by invalidating pending autoplay-ready fallback retries before a new app-owned YouTube playback flow takes over in src/main.ts. The new helper clears the tracked autoplay media path and advances the autoplay generation counter, so retry closures from older playback state cannot later unpause the newly prepared flow when reusing the same media path.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- bun test src/main/runtime/mpv-main-event-actions.test.ts
|
||||||
|
- bun test src/main/runtime/startup-autoplay-release-policy.test.ts
|
||||||
|
- bun run typecheck
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: tokenizer
|
|
||||||
|
|
||||||
- Fixed subtitle annotation clearing so explanatory contrast endings like `んですけど` are excluded consistently across the shared tokenizer filter and annotation stage.
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.9.1 (2026-03-24)
|
||||||
|
- Reduced packaged release size by excluding duplicate `extraResources` payload and pruning docs, tests, sourcemaps, and other source-only files from Electron bundles.
|
||||||
|
- Restored controller navigation and lookup/mining controls while the subtitle sidebar is open, while keeping true modal dialogs blocking controller actions.
|
||||||
|
- Fixed subtitle annotation clearing so explanatory contrast endings like `んですけど` are excluded consistently across the shared tokenizer filter and annotation stage.
|
||||||
|
|
||||||
## v0.9.0 (2026-03-23)
|
## v0.9.0 (2026-03-23)
|
||||||
- Added an app-owned YouTube subtitle flow with absPlayer-style timedtext parsing that auto-loads the default primary subtitle plus a best-effort secondary at startup and resumes once the primary is ready.
|
- Added an app-owned YouTube subtitle flow with absPlayer-style timedtext parsing that auto-loads the default primary subtitle plus a best-effort secondary at startup and resumes once the primary is ready.
|
||||||
- Added a manual YouTube subtitle picker on `Ctrl+Alt+C` so subtitle selection can be retried on demand during active YouTube playback.
|
- Added a manual YouTube subtitle picker on `Ctrl+Alt+C` so subtitle selection can be retried on demand during active YouTube playback.
|
||||||
|
|||||||
28
package.json
28
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.9.0",
|
"version": "0.9.2",
|
||||||
"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",
|
||||||
@@ -166,20 +166,44 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"**/*",
|
"**/*",
|
||||||
|
"!assets{,/**/*}",
|
||||||
"!src{,/**/*}",
|
"!src{,/**/*}",
|
||||||
"!launcher{,/**/*}",
|
"!launcher{,/**/*}",
|
||||||
|
"!docs{,/**/*}",
|
||||||
|
"!tests{,/**/*}",
|
||||||
|
"!packaging{,/**/*}",
|
||||||
|
"!README.md",
|
||||||
|
"!CHANGELOG.md",
|
||||||
|
"!AGENTS.md",
|
||||||
|
"!CLAUDE.md",
|
||||||
"!stats/src{,/**/*}",
|
"!stats/src{,/**/*}",
|
||||||
"!stats/index.html",
|
"!stats/index.html",
|
||||||
|
"!stats/public{,/**/*}",
|
||||||
|
"!stats/package.json",
|
||||||
|
"!stats/tsconfig.json",
|
||||||
|
"!stats/vite.config.ts",
|
||||||
"!docs-site{,/**/*}",
|
"!docs-site{,/**/*}",
|
||||||
"!changes{,/**/*}",
|
"!changes{,/**/*}",
|
||||||
"!backlog{,/**/*}",
|
"!backlog{,/**/*}",
|
||||||
"!.tmp{,/**/*}",
|
"!.tmp{,/**/*}",
|
||||||
"!release-*{,/**/*}",
|
"!release-*{,/**/*}",
|
||||||
|
"!dist/**/*.map",
|
||||||
|
"!dist/**/*.test.*",
|
||||||
|
"!dist/**/__tests__{,/**/*}",
|
||||||
|
"!scripts/**/*.test.*",
|
||||||
|
"!plugin{,/**/*}",
|
||||||
"!vendor/subminer-yomitan{,/**/*}",
|
"!vendor/subminer-yomitan{,/**/*}",
|
||||||
|
"!vendor/yomitan-jlpt-vocab{,/**/*}",
|
||||||
"!vendor/texthooker-ui/src{,/**/*}",
|
"!vendor/texthooker-ui/src{,/**/*}",
|
||||||
"!vendor/texthooker-ui/node_modules{,/**/*}",
|
"!vendor/texthooker-ui/node_modules{,/**/*}",
|
||||||
"!vendor/texthooker-ui/.svelte-kit{,/**/*}",
|
"!vendor/texthooker-ui/.svelte-kit{,/**/*}",
|
||||||
"!vendor/texthooker-ui/package-lock.json"
|
"!vendor/texthooker-ui/.vscode{,/**/*}",
|
||||||
|
"!vendor/texthooker-ui/public{,/**/*}",
|
||||||
|
"!vendor/texthooker-ui/README.md",
|
||||||
|
"!vendor/texthooker-ui/package.json",
|
||||||
|
"!vendor/texthooker-ui/package-lock.json",
|
||||||
|
"!vendor/texthooker-ui/tsconfig*.json",
|
||||||
|
"!node_modules/@libsql/linux-x64-musl{,/**/*}"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
commandNeedsOverlayStartupPrereqs,
|
||||||
|
commandNeedsOverlayRuntime,
|
||||||
hasExplicitCommand,
|
hasExplicitCommand,
|
||||||
isHeadlessInitialCommand,
|
isHeadlessInitialCommand,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
@@ -70,6 +72,13 @@ test('parseArgs captures youtube startup forwarding flags', () => {
|
|||||||
assert.equal(shouldStartApp(args), true);
|
assert.equal(shouldStartApp(args), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('youtube playback does not use generic overlay-runtime bootstrap classification', () => {
|
||||||
|
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
|
||||||
|
|
||||||
|
assert.equal(commandNeedsOverlayRuntime(args), false);
|
||||||
|
assert.equal(commandNeedsOverlayStartupPrereqs(args), true);
|
||||||
|
});
|
||||||
|
|
||||||
test('parseArgs handles jellyfin item listing controls', () => {
|
test('parseArgs handles jellyfin item listing controls', () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
'--jellyfin-items',
|
'--jellyfin-items',
|
||||||
@@ -141,6 +150,9 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(shouldStartApp(help), false);
|
assert.equal(shouldStartApp(help), false);
|
||||||
assert.equal(shouldRunSettingsOnlyStartup(help), false);
|
assert.equal(shouldRunSettingsOnlyStartup(help), false);
|
||||||
|
|
||||||
|
const youtubePlay = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
|
||||||
|
assert.equal(commandNeedsOverlayStartupPrereqs(youtubePlay), true);
|
||||||
|
|
||||||
const anilistStatus = parseArgs(['--anilist-status']);
|
const anilistStatus = parseArgs(['--anilist-status']);
|
||||||
assert.equal(anilistStatus.anilistStatus, true);
|
assert.equal(anilistStatus.anilistStatus, true);
|
||||||
assert.equal(hasExplicitCommand(anilistStatus), true);
|
assert.equal(hasExplicitCommand(anilistStatus), true);
|
||||||
|
|||||||
@@ -499,7 +499,10 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
|||||||
args.triggerFieldGrouping ||
|
args.triggerFieldGrouping ||
|
||||||
args.triggerSubsync ||
|
args.triggerSubsync ||
|
||||||
args.markAudioCard ||
|
args.markAudioCard ||
|
||||||
args.openRuntimeOptions ||
|
args.openRuntimeOptions
|
||||||
Boolean(args.youtubePlay)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function commandNeedsOverlayStartupPrereqs(args: CliArgs): boolean {
|
||||||
|
return commandNeedsOverlayRuntime(args) || Boolean(args.youtubePlay);
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,10 +225,7 @@ test('handleCliCommand starts youtube playback flow on initial launch', () => {
|
|||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:generate']);
|
||||||
'initializeOverlayRuntime',
|
|
||||||
'youtube:https://youtube.com/watch?v=abc:generate',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleCliCommand defaults youtube mode to download when omitted', () => {
|
test('handleCliCommand defaults youtube mode to download when omitted', () => {
|
||||||
@@ -240,10 +237,24 @@ test('handleCliCommand defaults youtube mode to download when omitted', () => {
|
|||||||
|
|
||||||
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
|
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:download']);
|
||||||
'initializeOverlayRuntime',
|
});
|
||||||
'youtube:https://youtube.com/watch?v=abc:download',
|
|
||||||
]);
|
test('handleCliCommand reuses initialized overlay runtime for second-instance youtube playback', () => {
|
||||||
|
const { deps, calls } = createDeps({
|
||||||
|
isOverlayRuntimeInitialized: () => true,
|
||||||
|
runYoutubePlaybackFlow: async (request) => {
|
||||||
|
calls.push(`youtube:${request.url}:${request.mode}:${request.source}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleCliCommand(
|
||||||
|
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
|
||||||
|
'second-instance',
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:download:second-instance']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleCliCommand reports youtube playback flow failures to logs and OSD', async () => {
|
test('handleCliCommand reports youtube playback flow failures to logs and OSD', async () => {
|
||||||
|
|||||||
41
src/core/services/overlay-window-bounds.test.ts
Normal file
41
src/core/services/overlay-window-bounds.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
||||||
|
|
||||||
|
test('normalizeOverlayWindowBoundsForPlatform returns original geometry outside Windows', () => {
|
||||||
|
const geometry = { x: 150, y: 90, width: 1200, height: 675 };
|
||||||
|
assert.deepEqual(normalizeOverlayWindowBoundsForPlatform(geometry, 'linux', null), geometry);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOverlayWindowBoundsForPlatform returns original geometry on Windows when screen is unavailable', () => {
|
||||||
|
const geometry = { x: 150, y: 90, width: 1200, height: 675 };
|
||||||
|
assert.deepEqual(normalizeOverlayWindowBoundsForPlatform(geometry, 'win32', null), geometry);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOverlayWindowBoundsForPlatform converts Windows physical pixels to DIP', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeOverlayWindowBoundsForPlatform(
|
||||||
|
{
|
||||||
|
x: 150,
|
||||||
|
y: 75,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
'win32',
|
||||||
|
{
|
||||||
|
screenToDipRect: (_window, rect) => ({
|
||||||
|
x: Math.round(rect.x / 1.5),
|
||||||
|
y: Math.round(rect.y / 1.5),
|
||||||
|
width: Math.round(rect.width / 1.5),
|
||||||
|
height: Math.round(rect.height / 1.5),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
x: 100,
|
||||||
|
y: 50,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
25
src/core/services/overlay-window-bounds.ts
Normal file
25
src/core/services/overlay-window-bounds.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { WindowGeometry } from '../../types';
|
||||||
|
|
||||||
|
type ScreenDipConverter = {
|
||||||
|
screenToDipRect: (
|
||||||
|
window: Electron.BrowserWindow | null,
|
||||||
|
rect: Electron.Rectangle,
|
||||||
|
) => Electron.Rectangle;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeOverlayWindowBoundsForPlatform(
|
||||||
|
geometry: WindowGeometry,
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
screen: ScreenDipConverter | null,
|
||||||
|
): WindowGeometry {
|
||||||
|
if (platform !== 'win32' || !screen) {
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return screen.screenToDipRect(null, {
|
||||||
|
x: geometry.x,
|
||||||
|
y: geometry.y,
|
||||||
|
width: geometry.width,
|
||||||
|
height: geometry.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserWindow, type Session } from 'electron';
|
import { BrowserWindow, screen, type Session } from 'electron';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { WindowGeometry } from '../../types';
|
import { WindowGeometry } from '../../types';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type OverlayWindowKind,
|
type OverlayWindowKind,
|
||||||
} from './overlay-window-input';
|
} from './overlay-window-input';
|
||||||
import { buildOverlayWindowOptions } from './overlay-window-options';
|
import { buildOverlayWindowOptions } from './overlay-window-options';
|
||||||
|
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
||||||
|
|
||||||
const logger = createLogger('main:overlay-window');
|
const logger = createLogger('main:overlay-window');
|
||||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||||
@@ -33,12 +34,7 @@ export function updateOverlayWindowBounds(
|
|||||||
window: BrowserWindow | null,
|
window: BrowserWindow | null,
|
||||||
): void {
|
): void {
|
||||||
if (!geometry || !window || window.isDestroyed()) return;
|
if (!geometry || !window || window.isDestroyed()) return;
|
||||||
window.setBounds({
|
window.setBounds(normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen));
|
||||||
x: geometry.x,
|
|
||||||
y: geometry.y,
|
|
||||||
width: geometry.width,
|
|
||||||
height: geometry.height,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
|
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
|
||||||
|
|||||||
76
src/core/services/youtube/playback-resolve.test.ts
Normal file
76
src/core/services/youtube/playback-resolve.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { resolveYoutubePlaybackUrl } from './playback-resolve';
|
||||||
|
|
||||||
|
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-playback-resolve-'));
|
||||||
|
try {
|
||||||
|
return await fn(dir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
||||||
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
|
const script = `#!/usr/bin/env node
|
||||||
|
process.stdout.write(${JSON.stringify(payload)});
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
fs.chmodSync(scriptPath, 0o755);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nnode "${scriptPath}"\r\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFakeYtDlp<T>(payload: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
return await withTempDir(async (root) => {
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
makeFakeYtDlpScript(binDir, payload);
|
||||||
|
const fakeCommandPath =
|
||||||
|
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||||
|
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
process.env.SUBMINER_YTDLP_BIN = fakeCommandPath;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
if (originalCommand === undefined) {
|
||||||
|
delete process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('resolveYoutubePlaybackUrl returns the first playable URL line', async () => {
|
||||||
|
await withFakeYtDlp(
|
||||||
|
'\nhttps://manifest.googlevideo.com/api/manifest/hls_playlist/test\nhttps://ignored.example/video\n',
|
||||||
|
async () => {
|
||||||
|
const result = await resolveYoutubePlaybackUrl('https://www.youtube.com/watch?v=abc123');
|
||||||
|
assert.equal(result, 'https://manifest.googlevideo.com/api/manifest/hls_playlist/test');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveYoutubePlaybackUrl rejects when yt-dlp returns no URL', async () => {
|
||||||
|
await withFakeYtDlp('\n', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
resolveYoutubePlaybackUrl('https://www.youtube.com/watch?v=abc123'),
|
||||||
|
/returned empty output/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveYoutubePlaybackUrl rejects when yt-dlp output exceeds capture limit', async () => {
|
||||||
|
await withFakeYtDlp(`${'x'.repeat(1024 * 1024 + 1)}\n`, async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
resolveYoutubePlaybackUrl('https://www.youtube.com/watch?v=abc123'),
|
||||||
|
/exceeded 1048576 bytes/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
src/core/services/youtube/playback-resolve.ts
Normal file
108
src/core/services/youtube/playback-resolve.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
|
||||||
|
const DEFAULT_PLAYBACK_FORMAT = 'b';
|
||||||
|
const MAX_CAPTURE_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
function terminateCaptureProcess(proc: ReturnType<typeof spawn>): void {
|
||||||
|
if (proc.killed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
proc.kill('SIGKILL');
|
||||||
|
} catch {
|
||||||
|
proc.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCapture(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
timeoutMs = YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS,
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let settled = false;
|
||||||
|
const cleanup = (): void => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
proc.stdout.removeAllListeners('data');
|
||||||
|
proc.stderr.removeAllListeners('data');
|
||||||
|
proc.removeAllListeners('error');
|
||||||
|
proc.removeAllListeners('close');
|
||||||
|
};
|
||||||
|
const rejectOnce = (error: Error): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
const resolveOnce = (result: { stdout: string; stderr: string }): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const appendChunk = (
|
||||||
|
current: string,
|
||||||
|
chunk: unknown,
|
||||||
|
streamName: 'stdout' | 'stderr',
|
||||||
|
): string => {
|
||||||
|
const next = current + String(chunk);
|
||||||
|
if (Buffer.byteLength(next, 'utf8') > MAX_CAPTURE_BYTES) {
|
||||||
|
terminateCaptureProcess(proc);
|
||||||
|
rejectOnce(new Error(`yt-dlp ${streamName} exceeded ${MAX_CAPTURE_BYTES} bytes`));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
terminateCaptureProcess(proc);
|
||||||
|
rejectOnce(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
proc.stdout.setEncoding('utf8');
|
||||||
|
proc.stderr.setEncoding('utf8');
|
||||||
|
proc.stdout.on('data', (chunk) => {
|
||||||
|
stdout = appendChunk(stdout, chunk, 'stdout');
|
||||||
|
});
|
||||||
|
proc.stderr.on('data', (chunk) => {
|
||||||
|
stderr = appendChunk(stderr, chunk, 'stderr');
|
||||||
|
});
|
||||||
|
proc.once('error', (error) => {
|
||||||
|
rejectOnce(error);
|
||||||
|
});
|
||||||
|
proc.once('close', (code) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code === 0) {
|
||||||
|
resolveOnce({ stdout, stderr });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectOnce(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveYoutubePlaybackUrl(
|
||||||
|
targetUrl: string,
|
||||||
|
format = DEFAULT_PLAYBACK_FORMAT,
|
||||||
|
): Promise<string> {
|
||||||
|
const ytDlpCommand = process.env.SUBMINER_YTDLP_BIN?.trim() || 'yt-dlp';
|
||||||
|
const { stdout } = await runCapture(ytDlpCommand, [
|
||||||
|
'--get-url',
|
||||||
|
'--no-warnings',
|
||||||
|
'-f',
|
||||||
|
format,
|
||||||
|
targetUrl,
|
||||||
|
]);
|
||||||
|
const playbackUrl =
|
||||||
|
stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0) ?? '';
|
||||||
|
if (!playbackUrl) {
|
||||||
|
throw new Error('yt-dlp returned empty output while resolving YouTube playback URL');
|
||||||
|
}
|
||||||
|
return playbackUrl;
|
||||||
|
}
|
||||||
142
src/main.ts
142
src/main.ts
@@ -105,6 +105,7 @@ import { createLogger, setLogLevel, type LogLevelSource } from './logger';
|
|||||||
import { resolveDefaultLogFilePath } from './logger';
|
import { resolveDefaultLogFilePath } from './logger';
|
||||||
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
||||||
import {
|
import {
|
||||||
|
commandNeedsOverlayStartupPrereqs,
|
||||||
commandNeedsOverlayRuntime,
|
commandNeedsOverlayRuntime,
|
||||||
isHeadlessInitialCommand,
|
isHeadlessInitialCommand,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
@@ -315,6 +316,7 @@ import {
|
|||||||
acquireYoutubeSubtitleTrack,
|
acquireYoutubeSubtitleTrack,
|
||||||
acquireYoutubeSubtitleTracks,
|
acquireYoutubeSubtitleTracks,
|
||||||
} from './core/services/youtube/generate';
|
} from './core/services/youtube/generate';
|
||||||
|
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
||||||
import { retimeYoutubeSubtitle } from './core/services/youtube/retime';
|
import { retimeYoutubeSubtitle } from './core/services/youtube/retime';
|
||||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||||
import { startStatsServer } from './core/services/stats-server';
|
import { startStatsServer } from './core/services/stats-server';
|
||||||
@@ -346,6 +348,9 @@ import {
|
|||||||
resolveWindowsMpvShortcutPaths,
|
resolveWindowsMpvShortcutPaths,
|
||||||
} from './main/runtime/windows-mpv-shortcuts';
|
} from './main/runtime/windows-mpv-shortcuts';
|
||||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||||
|
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
||||||
|
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
|
||||||
|
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
|
||||||
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
||||||
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
|
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
|
||||||
import {
|
import {
|
||||||
@@ -496,12 +501,19 @@ let anilistUpdateInFlightState = createInitialAnilistUpdateInFlightState();
|
|||||||
const anilistAttemptedUpdateKeys = new Set<string>();
|
const anilistAttemptedUpdateKeys = new Set<string>();
|
||||||
let anilistCachedAccessToken: string | null = null;
|
let anilistCachedAccessToken: string | null = null;
|
||||||
let jellyfinPlayQuitOnDisconnectArmed = false;
|
let jellyfinPlayQuitOnDisconnectArmed = false;
|
||||||
|
let youtubePlayQuitOnDisconnectArmed = false;
|
||||||
|
let youtubePlayQuitOnDisconnectArmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let youtubePlaybackFlowGeneration = 0;
|
||||||
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
|
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
|
||||||
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
|
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
|
||||||
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
|
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
|
||||||
const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
|
const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
|
||||||
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
|
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
|
||||||
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
|
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
|
||||||
|
const YOUTUBE_MPV_CONNECT_TIMEOUT_MS = 3000;
|
||||||
|
const YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS = 10000;
|
||||||
|
const YOUTUBE_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
|
||||||
|
const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b';
|
||||||
const MPV_JELLYFIN_DEFAULT_ARGS = [
|
const MPV_JELLYFIN_DEFAULT_ARGS = [
|
||||||
'--sub-auto=fuzzy',
|
'--sub-auto=fuzzy',
|
||||||
'--sub-file-paths=.;subs;subtitles',
|
'--sub-file-paths=.;subs;subtitles',
|
||||||
@@ -940,44 +952,135 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
|||||||
log: (message: string) => logger.info(message),
|
log: (message: string) => logger.info(message),
|
||||||
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
|
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
|
||||||
});
|
});
|
||||||
|
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
requestPath: async () => {
|
||||||
|
const client = appState.mpvClient;
|
||||||
|
if (!client) return null;
|
||||||
|
const value = await client.requestProperty('path').catch(() => null);
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
},
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
const client = appState.mpvClient;
|
||||||
|
if (!client) return null;
|
||||||
|
return await client.requestProperty(name);
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||||
|
},
|
||||||
|
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||||
|
});
|
||||||
|
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
|
||||||
|
getMpvClient: () => appState.mpvClient,
|
||||||
|
now: () => Date.now(),
|
||||||
|
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearYoutubePlayQuitOnDisconnectArmTimer(): void {
|
||||||
|
if (youtubePlayQuitOnDisconnectArmTimer) {
|
||||||
|
clearTimeout(youtubePlayQuitOnDisconnectArmTimer);
|
||||||
|
youtubePlayQuitOnDisconnectArmTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidatePendingAutoplayReadyFallbacks(): void {
|
||||||
|
autoPlayReadySignalMediaPath = null;
|
||||||
|
autoPlayReadySignalGeneration += 1;
|
||||||
|
}
|
||||||
|
|
||||||
async function runYoutubePlaybackFlowMain(request: {
|
async function runYoutubePlaybackFlowMain(request: {
|
||||||
url: string;
|
url: string;
|
||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||||
source: CliCommandSource;
|
source: CliCommandSource;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
const flowGeneration = ++youtubePlaybackFlowGeneration;
|
||||||
|
invalidatePendingAutoplayReadyFallbacks();
|
||||||
youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(true);
|
youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(true);
|
||||||
|
let flowCompleted = false;
|
||||||
try {
|
try {
|
||||||
|
clearYoutubePlayQuitOnDisconnectArmTimer();
|
||||||
|
youtubePlayQuitOnDisconnectArmed = false;
|
||||||
|
await ensureYoutubePlaybackRuntimeReady();
|
||||||
|
let playbackUrl = request.url;
|
||||||
|
let launchedWindowsMpv = false;
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
try {
|
||||||
|
playbackUrl = await resolveYoutubePlaybackUrl(request.url, YOUTUBE_DIRECT_PLAYBACK_FORMAT);
|
||||||
|
logger.info('Resolved direct YouTube playback URL for Windows MPV startup.');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to resolve direct YouTube playback URL; falling back to page URL: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (process.platform === 'win32' && !appState.mpvClient?.connected) {
|
if (process.platform === 'win32' && !appState.mpvClient?.connected) {
|
||||||
const launchResult = launchWindowsMpv(
|
const launchResult = launchWindowsMpv(
|
||||||
[request.url],
|
[playbackUrl],
|
||||||
createWindowsMpvLaunchDeps({
|
createWindowsMpvLaunchDeps({
|
||||||
showError: (title, content) => dialog.showErrorBox(title, content),
|
showError: (title, content) => dialog.showErrorBox(title, content),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
'--pause=yes',
|
'--pause=yes',
|
||||||
|
'--ytdl=yes',
|
||||||
|
`--ytdl-format=${YOUTUBE_MPV_YTDL_FORMAT}`,
|
||||||
'--sub-auto=no',
|
'--sub-auto=no',
|
||||||
'--sid=no',
|
'--sub-file-paths=.;subs;subtitles',
|
||||||
'--secondary-sid=no',
|
'--sid=auto',
|
||||||
'--script-opts=subminer-auto_start_pause_until_ready=no',
|
'--secondary-sid=auto',
|
||||||
|
'--secondary-sub-visibility=no',
|
||||||
|
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
|
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
|
`--log-file=${DEFAULT_MPV_LOG_PATH}`,
|
||||||
`--input-ipc-server=${appState.mpvSocketPath}`,
|
`--input-ipc-server=${appState.mpvSocketPath}`,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
launchedWindowsMpv = launchResult.ok;
|
||||||
|
if (launchResult.ok) {
|
||||||
|
logger.info(`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`);
|
||||||
|
}
|
||||||
if (!launchResult.ok) {
|
if (!launchResult.ok) {
|
||||||
logger.warn('Unable to bootstrap Windows mpv for YouTube playback.');
|
logger.warn('Unable to bootstrap Windows mpv for YouTube playback.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!appState.mpvClient?.connected) {
|
const connected = await waitForYoutubeMpvConnected(
|
||||||
appState.mpvClient?.connect();
|
launchedWindowsMpv ? YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS : YOUTUBE_MPV_CONNECT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
if (!connected) {
|
||||||
|
throw new Error(
|
||||||
|
launchedWindowsMpv
|
||||||
|
? 'MPV not connected after auto-launch. Ensure mpv is installed and can open the requested YouTube URL.'
|
||||||
|
: 'MPV not connected. Start mpv with the SubMiner profile or retry after mpv finishes starting.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (request.source === 'initial') {
|
||||||
|
youtubePlayQuitOnDisconnectArmTimer = setTimeout(() => {
|
||||||
|
if (youtubePlaybackFlowGeneration !== flowGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
youtubePlayQuitOnDisconnectArmed = true;
|
||||||
|
youtubePlayQuitOnDisconnectArmTimer = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
const mediaReady = await prepareYoutubePlaybackInMpv({ url: playbackUrl });
|
||||||
|
if (!mediaReady) {
|
||||||
|
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
|
||||||
}
|
}
|
||||||
await youtubeFlowRuntime.runYoutubePlaybackFlow({
|
await youtubeFlowRuntime.runYoutubePlaybackFlow({
|
||||||
url: request.url,
|
url: request.url,
|
||||||
mode: request.mode,
|
mode: request.mode,
|
||||||
});
|
});
|
||||||
|
flowCompleted = true;
|
||||||
logger.info(`YouTube playback flow completed from ${request.source}.`);
|
logger.info(`YouTube playback flow completed from ${request.source}.`);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (youtubePlaybackFlowGeneration === flowGeneration) {
|
||||||
|
if (!flowCompleted) {
|
||||||
|
clearYoutubePlayQuitOnDisconnectArmTimer();
|
||||||
|
youtubePlayQuitOnDisconnectArmed = false;
|
||||||
|
}
|
||||||
youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(false);
|
youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let firstRunSetupMessage: string | null = null;
|
let firstRunSetupMessage: string | null = null;
|
||||||
@@ -1281,6 +1384,10 @@ function maybeSignalPluginAutoplayReady(
|
|||||||
payload: SubtitleData,
|
payload: SubtitleData,
|
||||||
options?: { forceWhilePaused?: boolean },
|
options?: { forceWhilePaused?: boolean },
|
||||||
): void {
|
): void {
|
||||||
|
if (youtubePrimarySubtitleNotificationRuntime.isAppOwnedFlowInFlight()) {
|
||||||
|
logger.debug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!payload.text.trim()) {
|
if (!payload.text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2829,6 +2936,10 @@ const {
|
|||||||
annotationSubtitleWsService.stop();
|
annotationSubtitleWsService.stop();
|
||||||
},
|
},
|
||||||
stopTexthookerService: () => texthookerService.stop(),
|
stopTexthookerService: () => texthookerService.stop(),
|
||||||
|
getMainOverlayWindow: () => overlayManager.getMainWindow(),
|
||||||
|
clearMainOverlayWindow: () => overlayManager.setMainWindow(null),
|
||||||
|
getModalOverlayWindow: () => overlayManager.getModalWindow(),
|
||||||
|
clearModalOverlayWindow: () => overlayManager.setModalWindow(null),
|
||||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||||
clearYomitanParserState: () => {
|
clearYomitanParserState: () => {
|
||||||
appState.yomitanParserWindow = null;
|
appState.yomitanParserWindow = null;
|
||||||
@@ -3432,7 +3543,7 @@ const handleCliCommand = createCliCommandRuntimeHandler({
|
|||||||
setTexthookerOnlyMode: (enabled) => {
|
setTexthookerOnlyMode: (enabled) => {
|
||||||
appState.texthookerOnlyMode = enabled;
|
appState.texthookerOnlyMode = enabled;
|
||||||
},
|
},
|
||||||
commandNeedsOverlayRuntime: (inputArgs) => commandNeedsOverlayRuntime(inputArgs),
|
commandNeedsOverlayStartupPrereqs: (inputArgs) => commandNeedsOverlayStartupPrereqs(inputArgs),
|
||||||
startBackgroundWarmups: () => startBackgroundWarmups(),
|
startBackgroundWarmups: () => startBackgroundWarmups(),
|
||||||
logInfo: (message: string) => logger.info(message),
|
logInfo: (message: string) => logger.info(message),
|
||||||
},
|
},
|
||||||
@@ -3475,15 +3586,27 @@ function ensureOverlayStartupPrereqs(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureYoutubePlaybackRuntimeReady(): Promise<void> {
|
||||||
|
ensureOverlayStartupPrereqs();
|
||||||
|
await ensureYomitanExtensionLoaded();
|
||||||
|
if (!appState.overlayRuntimeInitialized) {
|
||||||
|
initializeOverlayRuntime();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions();
|
||||||
|
}
|
||||||
|
|
||||||
const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
|
const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
|
||||||
getInitialArgs: () => appState.initialArgs,
|
getInitialArgs: () => appState.initialArgs,
|
||||||
isBackgroundMode: () => appState.backgroundMode,
|
isBackgroundMode: () => appState.backgroundMode,
|
||||||
shouldEnsureTrayOnStartup: () => process.platform === 'win32',
|
shouldEnsureTrayOnStartup: () =>
|
||||||
|
shouldEnsureTrayOnStartupForInitialArgs(process.platform, appState.initialArgs),
|
||||||
shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args),
|
shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args),
|
||||||
ensureTray: () => ensureTray(),
|
ensureTray: () => ensureTray(),
|
||||||
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
||||||
hasImmersionTracker: () => Boolean(appState.immersionTracker),
|
hasImmersionTracker: () => Boolean(appState.immersionTracker),
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
|
commandNeedsOverlayStartupPrereqs: (args) => commandNeedsOverlayStartupPrereqs(args),
|
||||||
commandNeedsOverlayRuntime: (args) => commandNeedsOverlayRuntime(args),
|
commandNeedsOverlayRuntime: (args) => commandNeedsOverlayRuntime(args),
|
||||||
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
|
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
|
||||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||||
@@ -3512,7 +3635,8 @@ const {
|
|||||||
>({
|
>({
|
||||||
bindMpvMainEventHandlersMainDeps: {
|
bindMpvMainEventHandlersMainDeps: {
|
||||||
appState,
|
appState,
|
||||||
getQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed,
|
getQuitOnDisconnectArmed: () =>
|
||||||
|
jellyfinPlayQuitOnDisconnectArmed || youtubePlayQuitOnDisconnectArmed,
|
||||||
scheduleQuitCheck: (callback) => {
|
scheduleQuitCheck: (callback) => {
|
||||||
setTimeout(callback, 500);
|
setTimeout(callback, 500);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||||
|
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
|
||||||
|
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
|
||||||
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
|
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
|
||||||
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
|
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
|
||||||
stopWindowTracker: () => calls.push('stop-tracker'),
|
stopWindowTracker: () => calls.push('stop-tracker'),
|
||||||
@@ -38,7 +40,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
assert.equal(calls.length, 26);
|
assert.equal(calls.length, 28);
|
||||||
assert.equal(calls[0], 'destroy-tray');
|
assert.equal(calls[0], 'destroy-tray');
|
||||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
unregisterAllGlobalShortcuts: () => void;
|
unregisterAllGlobalShortcuts: () => void;
|
||||||
stopSubtitleWebsocket: () => void;
|
stopSubtitleWebsocket: () => void;
|
||||||
stopTexthookerService: () => void;
|
stopTexthookerService: () => void;
|
||||||
|
destroyMainOverlayWindow: () => void;
|
||||||
|
destroyModalOverlayWindow: () => void;
|
||||||
destroyYomitanParserWindow: () => void;
|
destroyYomitanParserWindow: () => void;
|
||||||
clearYomitanParserState: () => void;
|
clearYomitanParserState: () => void;
|
||||||
stopWindowTracker: () => void;
|
stopWindowTracker: () => void;
|
||||||
@@ -34,6 +36,8 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
deps.unregisterAllGlobalShortcuts();
|
deps.unregisterAllGlobalShortcuts();
|
||||||
deps.stopSubtitleWebsocket();
|
deps.stopSubtitleWebsocket();
|
||||||
deps.stopTexthookerService();
|
deps.stopTexthookerService();
|
||||||
|
deps.destroyMainOverlayWindow();
|
||||||
|
deps.destroyModalOverlayWindow();
|
||||||
deps.destroyYomitanParserWindow();
|
deps.destroyYomitanParserWindow();
|
||||||
deps.clearYomitanParserState();
|
deps.clearYomitanParserState();
|
||||||
deps.stopWindowTracker();
|
deps.stopWindowTracker();
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||||
|
getMainOverlayWindow: () => ({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||||
|
}),
|
||||||
|
clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'),
|
||||||
|
getModalOverlayWindow: () => ({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
destroy: () => calls.push('destroy-modal-overlay-window'),
|
||||||
|
}),
|
||||||
|
clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'),
|
||||||
|
|
||||||
getYomitanParserWindow: () => ({
|
getYomitanParserWindow: () => ({
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
@@ -61,6 +71,10 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
|||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
assert.ok(calls.includes('destroy-tray'));
|
assert.ok(calls.includes('destroy-tray'));
|
||||||
|
assert.ok(calls.includes('destroy-main-overlay-window'));
|
||||||
|
assert.ok(calls.includes('clear-main-overlay-window'));
|
||||||
|
assert.ok(calls.includes('destroy-modal-overlay-window'));
|
||||||
|
assert.ok(calls.includes('clear-modal-overlay-window'));
|
||||||
assert.ok(calls.includes('destroy-yomitan-window'));
|
assert.ok(calls.includes('destroy-yomitan-window'));
|
||||||
assert.ok(calls.includes('flush-mpv-log'));
|
assert.ok(calls.includes('flush-mpv-log'));
|
||||||
assert.ok(calls.includes('destroy-socket'));
|
assert.ok(calls.includes('destroy-socket'));
|
||||||
@@ -85,6 +99,16 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
|||||||
unregisterAllGlobalShortcuts: () => {},
|
unregisterAllGlobalShortcuts: () => {},
|
||||||
stopSubtitleWebsocket: () => {},
|
stopSubtitleWebsocket: () => {},
|
||||||
stopTexthookerService: () => {},
|
stopTexthookerService: () => {},
|
||||||
|
getMainOverlayWindow: () => ({
|
||||||
|
isDestroyed: () => true,
|
||||||
|
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||||
|
}),
|
||||||
|
clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'),
|
||||||
|
getModalOverlayWindow: () => ({
|
||||||
|
isDestroyed: () => true,
|
||||||
|
destroy: () => calls.push('destroy-modal-overlay-window'),
|
||||||
|
}),
|
||||||
|
clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'),
|
||||||
getYomitanParserWindow: () => ({
|
getYomitanParserWindow: () => ({
|
||||||
isDestroyed: () => true,
|
isDestroyed: () => true,
|
||||||
destroy: () => calls.push('destroy-yomitan-window'),
|
destroy: () => calls.push('destroy-yomitan-window'),
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
unregisterAllGlobalShortcuts: () => void;
|
unregisterAllGlobalShortcuts: () => void;
|
||||||
stopSubtitleWebsocket: () => void;
|
stopSubtitleWebsocket: () => void;
|
||||||
stopTexthookerService: () => void;
|
stopTexthookerService: () => void;
|
||||||
|
getMainOverlayWindow: () => DestroyableWindow | null;
|
||||||
|
clearMainOverlayWindow: () => void;
|
||||||
|
getModalOverlayWindow: () => DestroyableWindow | null;
|
||||||
|
clearModalOverlayWindow: () => void;
|
||||||
|
|
||||||
getYomitanParserWindow: () => DestroyableWindow | null;
|
getYomitanParserWindow: () => DestroyableWindow | null;
|
||||||
clearYomitanParserState: () => void;
|
clearYomitanParserState: () => void;
|
||||||
@@ -60,6 +64,20 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
||||||
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
||||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||||
|
destroyMainOverlayWindow: () => {
|
||||||
|
const window = deps.getMainOverlayWindow();
|
||||||
|
if (!window) return;
|
||||||
|
if (window.isDestroyed()) return;
|
||||||
|
window.destroy();
|
||||||
|
deps.clearMainOverlayWindow();
|
||||||
|
},
|
||||||
|
destroyModalOverlayWindow: () => {
|
||||||
|
const window = deps.getModalOverlayWindow();
|
||||||
|
if (!window) return;
|
||||||
|
if (window.isDestroyed()) return;
|
||||||
|
window.destroy();
|
||||||
|
deps.clearModalOverlayWindow();
|
||||||
|
},
|
||||||
destroyYomitanParserWindow: () => {
|
destroyYomitanParserWindow: () => {
|
||||||
const window = deps.getYomitanParserWindow();
|
const window = deps.getYomitanParserWindow();
|
||||||
if (!window) return;
|
if (!window) return;
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ test('cli prechecks main deps builder maps transition handlers', () => {
|
|||||||
const deps = createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler({
|
const deps = createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler({
|
||||||
isTexthookerOnlyMode: () => true,
|
isTexthookerOnlyMode: () => true,
|
||||||
setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`),
|
setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`),
|
||||||
commandNeedsOverlayRuntime: () => true,
|
commandNeedsOverlayStartupPrereqs: () => true,
|
||||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||||
startBackgroundWarmups: () => calls.push('warmups'),
|
startBackgroundWarmups: () => calls.push('warmups'),
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
})();
|
})();
|
||||||
|
|
||||||
assert.equal(deps.isTexthookerOnlyMode(), true);
|
assert.equal(deps.isTexthookerOnlyMode(), true);
|
||||||
assert.equal(deps.commandNeedsOverlayRuntime({} as never), true);
|
assert.equal(deps.commandNeedsOverlayStartupPrereqs({} as never), true);
|
||||||
deps.setTexthookerOnlyMode(false);
|
deps.setTexthookerOnlyMode(false);
|
||||||
deps.ensureOverlayStartupPrereqs();
|
deps.ensureOverlayStartupPrereqs();
|
||||||
deps.startBackgroundWarmups();
|
deps.startBackgroundWarmups();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { CliArgs } from '../../cli/args';
|
|||||||
export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(deps: {
|
export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(deps: {
|
||||||
isTexthookerOnlyMode: () => boolean;
|
isTexthookerOnlyMode: () => boolean;
|
||||||
setTexthookerOnlyMode: (enabled: boolean) => void;
|
setTexthookerOnlyMode: (enabled: boolean) => void;
|
||||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
|
||||||
ensureOverlayStartupPrereqs: () => void;
|
ensureOverlayStartupPrereqs: () => void;
|
||||||
startBackgroundWarmups: () => void;
|
startBackgroundWarmups: () => void;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
@@ -11,7 +11,8 @@ export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(dep
|
|||||||
return () => ({
|
return () => ({
|
||||||
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
||||||
setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled),
|
setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled),
|
||||||
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
|
commandNeedsOverlayStartupPrereqs: (args: CliArgs) =>
|
||||||
|
deps.commandNeedsOverlayStartupPrereqs(args),
|
||||||
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
|
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
|
||||||
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
|
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ test('texthooker precheck no-ops when mode is disabled', () => {
|
|||||||
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
|
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
|
||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
setTexthookerOnlyMode: () => {},
|
setTexthookerOnlyMode: () => {},
|
||||||
commandNeedsOverlayRuntime: () => true,
|
commandNeedsOverlayStartupPrereqs: () => true,
|
||||||
ensureOverlayStartupPrereqs: () => {},
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
startBackgroundWarmups: () => {
|
startBackgroundWarmups: () => {
|
||||||
warmups += 1;
|
warmups += 1;
|
||||||
@@ -29,7 +29,7 @@ test('texthooker precheck disables mode and warms up on start command', () => {
|
|||||||
setTexthookerOnlyMode: (enabled) => {
|
setTexthookerOnlyMode: (enabled) => {
|
||||||
mode = enabled;
|
mode = enabled;
|
||||||
},
|
},
|
||||||
commandNeedsOverlayRuntime: () => false,
|
commandNeedsOverlayStartupPrereqs: () => false,
|
||||||
ensureOverlayStartupPrereqs: () => {
|
ensureOverlayStartupPrereqs: () => {
|
||||||
prereqs += 1;
|
prereqs += 1;
|
||||||
},
|
},
|
||||||
@@ -55,7 +55,7 @@ test('texthooker precheck no-ops for texthooker command', () => {
|
|||||||
setTexthookerOnlyMode: (enabled) => {
|
setTexthookerOnlyMode: (enabled) => {
|
||||||
mode = enabled;
|
mode = enabled;
|
||||||
},
|
},
|
||||||
commandNeedsOverlayRuntime: () => true,
|
commandNeedsOverlayStartupPrereqs: () => true,
|
||||||
ensureOverlayStartupPrereqs: () => {},
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
startBackgroundWarmups: () => {},
|
startBackgroundWarmups: () => {},
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
@@ -64,3 +64,33 @@ test('texthooker precheck no-ops for texthooker command', () => {
|
|||||||
handlePrecheck({ start: true, texthooker: true } as never);
|
handlePrecheck({ start: true, texthooker: true } as never);
|
||||||
assert.equal(mode, true);
|
assert.equal(mode, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('texthooker precheck transitions for youtube playback startup prereqs', () => {
|
||||||
|
let mode = true;
|
||||||
|
let prereqs = 0;
|
||||||
|
let warmups = 0;
|
||||||
|
let logs = 0;
|
||||||
|
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
|
||||||
|
isTexthookerOnlyMode: () => mode,
|
||||||
|
setTexthookerOnlyMode: (enabled) => {
|
||||||
|
mode = enabled;
|
||||||
|
},
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => true,
|
||||||
|
ensureOverlayStartupPrereqs: () => {
|
||||||
|
prereqs += 1;
|
||||||
|
},
|
||||||
|
startBackgroundWarmups: () => {
|
||||||
|
warmups += 1;
|
||||||
|
},
|
||||||
|
logInfo: () => {
|
||||||
|
logs += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePrecheck({ youtubePlay: 'https://youtube.com/watch?v=abc', texthooker: false } as never);
|
||||||
|
|
||||||
|
assert.equal(mode, false);
|
||||||
|
assert.equal(prereqs, 1);
|
||||||
|
assert.equal(warmups, 1);
|
||||||
|
assert.equal(logs, 1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { CliArgs } from '../../cli/args';
|
|||||||
export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
|
export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
|
||||||
isTexthookerOnlyMode: () => boolean;
|
isTexthookerOnlyMode: () => boolean;
|
||||||
setTexthookerOnlyMode: (enabled: boolean) => void;
|
setTexthookerOnlyMode: (enabled: boolean) => void;
|
||||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
|
||||||
ensureOverlayStartupPrereqs: () => void;
|
ensureOverlayStartupPrereqs: () => void;
|
||||||
startBackgroundWarmups: () => void;
|
startBackgroundWarmups: () => void;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
@@ -12,7 +12,7 @@ export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
|
|||||||
if (
|
if (
|
||||||
deps.isTexthookerOnlyMode() &&
|
deps.isTexthookerOnlyMode() &&
|
||||||
!args.texthooker &&
|
!args.texthooker &&
|
||||||
(args.start || deps.commandNeedsOverlayRuntime(args))
|
(args.start || deps.commandNeedsOverlayStartupPrereqs(args))
|
||||||
) {
|
) {
|
||||||
deps.ensureOverlayStartupPrereqs();
|
deps.ensureOverlayStartupPrereqs();
|
||||||
deps.setTexthookerOnlyMode(false);
|
deps.setTexthookerOnlyMode(false);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ test('cli command runtime handler applies precheck and forwards command with con
|
|||||||
handleTexthookerOnlyModeTransitionMainDeps: {
|
handleTexthookerOnlyModeTransitionMainDeps: {
|
||||||
isTexthookerOnlyMode: () => true,
|
isTexthookerOnlyMode: () => true,
|
||||||
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
||||||
commandNeedsOverlayRuntime: () => true,
|
commandNeedsOverlayStartupPrereqs: () => true,
|
||||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||||
startBackgroundWarmups: () => calls.push('warmups'),
|
startBackgroundWarmups: () => calls.push('warmups'),
|
||||||
logInfo: (message) => calls.push(`log:${message}`),
|
logInfo: (message) => calls.push(`log:${message}`),
|
||||||
@@ -40,7 +40,7 @@ test('cli command runtime handler prepares overlay prerequisites before overlay
|
|||||||
handleTexthookerOnlyModeTransitionMainDeps: {
|
handleTexthookerOnlyModeTransitionMainDeps: {
|
||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
||||||
commandNeedsOverlayRuntime: () => true,
|
commandNeedsOverlayStartupPrereqs: () => true,
|
||||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||||
startBackgroundWarmups: () => calls.push('warmups'),
|
startBackgroundWarmups: () => calls.push('warmups'),
|
||||||
logInfo: (message) => calls.push(`log:${message}`),
|
logInfo: (message) => calls.push(`log:${message}`),
|
||||||
@@ -58,3 +58,28 @@ test('cli command runtime handler prepares overlay prerequisites before overlay
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['prereqs', 'context', 'cli:initial:ctx']);
|
assert.deepEqual(calls, ['prereqs', 'context', 'cli:initial:ctx']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cli command runtime handler skips generic overlay prerequisites for youtube playback', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const handler = createCliCommandRuntimeHandler({
|
||||||
|
handleTexthookerOnlyModeTransitionMainDeps: {
|
||||||
|
isTexthookerOnlyMode: () => false,
|
||||||
|
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => false,
|
||||||
|
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||||
|
startBackgroundWarmups: () => calls.push('warmups'),
|
||||||
|
logInfo: (message) => calls.push(`log:${message}`),
|
||||||
|
},
|
||||||
|
createCliCommandContext: () => {
|
||||||
|
calls.push('context');
|
||||||
|
return { id: 'ctx' };
|
||||||
|
},
|
||||||
|
handleCliCommandRuntimeServiceWithContext: (_args, source, context) => {
|
||||||
|
calls.push(`cli:${source}:${context.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handler({ youtubePlay: 'https://youtube.com/watch?v=abc' } as never);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['context', 'cli:initial:ctx']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function createCliCommandRuntimeHandler<TCliContext>(deps: {
|
|||||||
handleTexthookerOnlyModeTransitionHandler(args);
|
handleTexthookerOnlyModeTransitionHandler(args);
|
||||||
if (
|
if (
|
||||||
!deps.handleTexthookerOnlyModeTransitionMainDeps.isTexthookerOnlyMode() &&
|
!deps.handleTexthookerOnlyModeTransitionMainDeps.isTexthookerOnlyMode() &&
|
||||||
deps.handleTexthookerOnlyModeTransitionMainDeps.commandNeedsOverlayRuntime(args)
|
deps.handleTexthookerOnlyModeTransitionMainDeps.commandNeedsOverlayStartupPrereqs(args)
|
||||||
) {
|
) {
|
||||||
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
|
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
|||||||
unregisterAllGlobalShortcuts: () => {},
|
unregisterAllGlobalShortcuts: () => {},
|
||||||
stopSubtitleWebsocket: () => {},
|
stopSubtitleWebsocket: () => {},
|
||||||
stopTexthookerService: () => {},
|
stopTexthookerService: () => {},
|
||||||
|
getMainOverlayWindow: () => null,
|
||||||
|
clearMainOverlayWindow: () => {},
|
||||||
|
getModalOverlayWindow: () => null,
|
||||||
|
clearModalOverlayWindow: () => {},
|
||||||
getYomitanParserWindow: () => null,
|
getYomitanParserWindow: () => null,
|
||||||
clearYomitanParserState: () => {},
|
clearYomitanParserState: () => {},
|
||||||
getWindowTracker: () => null,
|
getWindowTracker: () => null,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ test('initial args handler no-ops without initial args', () => {
|
|||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
hasImmersionTracker: () => false,
|
hasImmersionTracker: () => false,
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => false,
|
||||||
commandNeedsOverlayRuntime: () => false,
|
commandNeedsOverlayRuntime: () => false,
|
||||||
ensureOverlayStartupPrereqs: () => {},
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
@@ -40,6 +41,7 @@ test('initial args handler ensures tray in background mode', () => {
|
|||||||
isTexthookerOnlyMode: () => true,
|
isTexthookerOnlyMode: () => true,
|
||||||
hasImmersionTracker: () => false,
|
hasImmersionTracker: () => false,
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => false,
|
||||||
commandNeedsOverlayRuntime: () => false,
|
commandNeedsOverlayRuntime: () => false,
|
||||||
ensureOverlayStartupPrereqs: () => {},
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
@@ -69,6 +71,7 @@ test('initial args handler auto-connects mpv when needed', () => {
|
|||||||
connectCalls += 1;
|
connectCalls += 1;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => false,
|
||||||
commandNeedsOverlayRuntime: () => false,
|
commandNeedsOverlayRuntime: () => false,
|
||||||
ensureOverlayStartupPrereqs: () => {},
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
@@ -95,6 +98,7 @@ test('initial args handler forwards args to cli handler', () => {
|
|||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
hasImmersionTracker: () => false,
|
hasImmersionTracker: () => false,
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => false,
|
||||||
commandNeedsOverlayRuntime: () => false,
|
commandNeedsOverlayRuntime: () => false,
|
||||||
ensureOverlayStartupPrereqs: () => {
|
ensureOverlayStartupPrereqs: () => {
|
||||||
seenSources.push('prereqs');
|
seenSources.push('prereqs');
|
||||||
@@ -125,6 +129,7 @@ test('initial args handler bootstraps overlay before initial overlay-runtime com
|
|||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
hasImmersionTracker: () => false,
|
hasImmersionTracker: () => false,
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
|
commandNeedsOverlayStartupPrereqs: (inputArgs) => inputArgs === args,
|
||||||
commandNeedsOverlayRuntime: (inputArgs) => inputArgs === args,
|
commandNeedsOverlayRuntime: (inputArgs) => inputArgs === args,
|
||||||
ensureOverlayStartupPrereqs: () => {
|
ensureOverlayStartupPrereqs: () => {
|
||||||
calls.push('prereqs');
|
calls.push('prereqs');
|
||||||
@@ -144,6 +149,38 @@ test('initial args handler bootstraps overlay before initial overlay-runtime com
|
|||||||
assert.deepEqual(calls, ['prereqs', 'init-overlay', 'cli:initial']);
|
assert.deepEqual(calls, ['prereqs', 'init-overlay', 'cli:initial']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('initial args handler prepares prereqs but skips eager overlay bootstrap for youtube playback', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const args = { youtubePlay: 'https://youtube.com/watch?v=abc' } as never;
|
||||||
|
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||||
|
getInitialArgs: () => args,
|
||||||
|
isBackgroundMode: () => false,
|
||||||
|
shouldEnsureTrayOnStartup: () => false,
|
||||||
|
shouldRunHeadlessInitialCommand: () => false,
|
||||||
|
ensureTray: () => {},
|
||||||
|
isTexthookerOnlyMode: () => false,
|
||||||
|
hasImmersionTracker: () => false,
|
||||||
|
getMpvClient: () => null,
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => true,
|
||||||
|
commandNeedsOverlayRuntime: () => false,
|
||||||
|
ensureOverlayStartupPrereqs: () => {
|
||||||
|
calls.push('prereqs');
|
||||||
|
},
|
||||||
|
isOverlayRuntimeInitialized: () => false,
|
||||||
|
initializeOverlayRuntime: () => {
|
||||||
|
calls.push('init-overlay');
|
||||||
|
},
|
||||||
|
logInfo: () => {},
|
||||||
|
handleCliCommand: (_args, source) => {
|
||||||
|
calls.push(`cli:${source}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleInitialArgs();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['prereqs', 'cli:initial']);
|
||||||
|
});
|
||||||
|
|
||||||
test('initial args handler can ensure tray outside background mode when requested', () => {
|
test('initial args handler can ensure tray outside background mode when requested', () => {
|
||||||
let ensuredTray = false;
|
let ensuredTray = false;
|
||||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||||
@@ -157,6 +194,7 @@ test('initial args handler can ensure tray outside background mode when requeste
|
|||||||
isTexthookerOnlyMode: () => true,
|
isTexthookerOnlyMode: () => true,
|
||||||
hasImmersionTracker: () => false,
|
hasImmersionTracker: () => false,
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => false,
|
||||||
commandNeedsOverlayRuntime: () => false,
|
commandNeedsOverlayRuntime: () => false,
|
||||||
ensureOverlayStartupPrereqs: () => {},
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
@@ -188,6 +226,7 @@ test('initial args handler skips tray and mpv auto-connect for headless refresh'
|
|||||||
connectCalls += 1;
|
connectCalls += 1;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => true,
|
||||||
commandNeedsOverlayRuntime: () => true,
|
commandNeedsOverlayRuntime: () => true,
|
||||||
ensureOverlayStartupPrereqs: () => {},
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function createHandleInitialArgsHandler(deps: {
|
|||||||
isTexthookerOnlyMode: () => boolean;
|
isTexthookerOnlyMode: () => boolean;
|
||||||
hasImmersionTracker: () => boolean;
|
hasImmersionTracker: () => boolean;
|
||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
|
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
|
||||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||||
ensureOverlayStartupPrereqs: () => void;
|
ensureOverlayStartupPrereqs: () => void;
|
||||||
isOverlayRuntimeInitialized: () => boolean;
|
isOverlayRuntimeInitialized: () => boolean;
|
||||||
@@ -43,8 +44,10 @@ export function createHandleInitialArgsHandler(deps: {
|
|||||||
mpvClient.connect();
|
mpvClient.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!runHeadless && deps.commandNeedsOverlayRuntime(initialArgs)) {
|
if (!runHeadless && deps.commandNeedsOverlayStartupPrereqs(initialArgs)) {
|
||||||
deps.ensureOverlayStartupPrereqs();
|
deps.ensureOverlayStartupPrereqs();
|
||||||
|
}
|
||||||
|
if (!runHeadless && deps.commandNeedsOverlayRuntime(initialArgs)) {
|
||||||
if (!deps.isOverlayRuntimeInitialized()) {
|
if (!deps.isOverlayRuntimeInitialized()) {
|
||||||
deps.initializeOverlayRuntime();
|
deps.initializeOverlayRuntime();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
|||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
hasImmersionTracker: () => true,
|
hasImmersionTracker: () => true,
|
||||||
getMpvClient: () => mpvClient,
|
getMpvClient: () => mpvClient,
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => true,
|
||||||
commandNeedsOverlayRuntime: () => true,
|
commandNeedsOverlayRuntime: () => true,
|
||||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
@@ -30,6 +31,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
|||||||
assert.equal(deps.isTexthookerOnlyMode(), false);
|
assert.equal(deps.isTexthookerOnlyMode(), false);
|
||||||
assert.equal(deps.hasImmersionTracker(), true);
|
assert.equal(deps.hasImmersionTracker(), true);
|
||||||
assert.equal(deps.getMpvClient(), mpvClient);
|
assert.equal(deps.getMpvClient(), mpvClient);
|
||||||
|
assert.equal(deps.commandNeedsOverlayStartupPrereqs(args), true);
|
||||||
assert.equal(deps.commandNeedsOverlayRuntime(args), true);
|
assert.equal(deps.commandNeedsOverlayRuntime(args), true);
|
||||||
assert.equal(deps.isOverlayRuntimeInitialized(), false);
|
assert.equal(deps.isOverlayRuntimeInitialized(), false);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
|||||||
isTexthookerOnlyMode: () => boolean;
|
isTexthookerOnlyMode: () => boolean;
|
||||||
hasImmersionTracker: () => boolean;
|
hasImmersionTracker: () => boolean;
|
||||||
getMpvClient: () => { connected: boolean; connect: () => void } | null;
|
getMpvClient: () => { connected: boolean; connect: () => void } | null;
|
||||||
|
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
|
||||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||||
ensureOverlayStartupPrereqs: () => void;
|
ensureOverlayStartupPrereqs: () => void;
|
||||||
isOverlayRuntimeInitialized: () => boolean;
|
isOverlayRuntimeInitialized: () => boolean;
|
||||||
@@ -25,6 +26,8 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
|||||||
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
||||||
hasImmersionTracker: () => deps.hasImmersionTracker(),
|
hasImmersionTracker: () => deps.hasImmersionTracker(),
|
||||||
getMpvClient: () => deps.getMpvClient(),
|
getMpvClient: () => deps.getMpvClient(),
|
||||||
|
commandNeedsOverlayStartupPrereqs: (args: CliArgs) =>
|
||||||
|
deps.commandNeedsOverlayStartupPrereqs(args),
|
||||||
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
|
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
|
||||||
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
|
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
|
||||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ test('initial args runtime handler composes main deps and runs initial command f
|
|||||||
connected: false,
|
connected: false,
|
||||||
connect: () => calls.push('connect'),
|
connect: () => calls.push('connect'),
|
||||||
}),
|
}),
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => false,
|
||||||
commandNeedsOverlayRuntime: () => false,
|
commandNeedsOverlayRuntime: () => false,
|
||||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
@@ -48,6 +49,7 @@ test('initial args runtime handler skips mpv auto-connect for stats mode', () =>
|
|||||||
connected: false,
|
connected: false,
|
||||||
connect: () => calls.push('connect'),
|
connect: () => calls.push('connect'),
|
||||||
}),
|
}),
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => false,
|
||||||
commandNeedsOverlayRuntime: () => false,
|
commandNeedsOverlayRuntime: () => false,
|
||||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
@@ -75,6 +77,7 @@ test('initial args runtime handler skips tray and mpv auto-connect for headless
|
|||||||
connected: false,
|
connected: false,
|
||||||
connect: () => calls.push('connect'),
|
connect: () => calls.push('connect'),
|
||||||
}),
|
}),
|
||||||
|
commandNeedsOverlayStartupPrereqs: () => true,
|
||||||
commandNeedsOverlayRuntime: () => true,
|
commandNeedsOverlayRuntime: () => true,
|
||||||
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
|
|||||||
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
|
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
|
||||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||||
hasInitialJellyfinPlayArg: () => true,
|
hasInitialPlaybackQuitOnDisconnectArg: () => true,
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
|
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||||
isQuitOnDisconnectArmed: () => true,
|
isQuitOnDisconnectArmed: () => true,
|
||||||
scheduleQuitCheck: (callback) => {
|
scheduleQuitCheck: (callback) => {
|
||||||
calls.push('schedule');
|
calls.push('schedule');
|
||||||
@@ -36,8 +37,9 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', ()
|
|||||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||||
hasInitialJellyfinPlayArg: () => true,
|
hasInitialPlaybackQuitOnDisconnectArg: () => true,
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
|
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||||
isQuitOnDisconnectArmed: () => true,
|
isQuitOnDisconnectArmed: () => true,
|
||||||
scheduleQuitCheck: () => {
|
scheduleQuitCheck: () => {
|
||||||
calls.push('schedule');
|
calls.push('schedule');
|
||||||
@@ -52,6 +54,49 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', ()
|
|||||||
assert.deepEqual(calls, ['presence-refresh', 'sync-overlay-mpv-sub']);
|
assert.deepEqual(calls, ['presence-refresh', 'sync-overlay-mpv-sub']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('mpv connection handler quits standalone youtube playback even after overlay runtime init', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const handler = createHandleMpvConnectionChangeHandler({
|
||||||
|
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
|
||||||
|
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||||
|
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||||
|
hasInitialPlaybackQuitOnDisconnectArg: () => true,
|
||||||
|
isOverlayRuntimeInitialized: () => true,
|
||||||
|
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => true,
|
||||||
|
isQuitOnDisconnectArmed: () => true,
|
||||||
|
scheduleQuitCheck: (callback) => {
|
||||||
|
calls.push('schedule');
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
isMpvConnected: () => false,
|
||||||
|
quitApp: () => calls.push('quit'),
|
||||||
|
});
|
||||||
|
|
||||||
|
handler({ connected: false });
|
||||||
|
assert.deepEqual(calls, ['presence-refresh', 'report-stop', 'schedule', 'quit']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mpv connection handler keeps overlay-initialized non-youtube sessions alive', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const handler = createHandleMpvConnectionChangeHandler({
|
||||||
|
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
|
||||||
|
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||||
|
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||||
|
hasInitialPlaybackQuitOnDisconnectArg: () => true,
|
||||||
|
isOverlayRuntimeInitialized: () => true,
|
||||||
|
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||||
|
isQuitOnDisconnectArmed: () => true,
|
||||||
|
scheduleQuitCheck: () => {
|
||||||
|
calls.push('schedule');
|
||||||
|
},
|
||||||
|
isMpvConnected: () => false,
|
||||||
|
quitApp: () => calls.push('quit'),
|
||||||
|
});
|
||||||
|
|
||||||
|
handler({ connected: false });
|
||||||
|
assert.deepEqual(calls, ['presence-refresh', 'report-stop']);
|
||||||
|
});
|
||||||
|
|
||||||
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
|
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const handler = createHandleMpvSubtitleTimingHandler({
|
const handler = createHandleMpvSubtitleTimingHandler({
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ export function createHandleMpvConnectionChangeHandler(deps: {
|
|||||||
reportJellyfinRemoteStopped: () => void;
|
reportJellyfinRemoteStopped: () => void;
|
||||||
refreshDiscordPresence: () => void;
|
refreshDiscordPresence: () => void;
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
syncOverlayMpvSubtitleSuppression: () => void;
|
||||||
hasInitialJellyfinPlayArg: () => boolean;
|
hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
|
||||||
isOverlayRuntimeInitialized: () => boolean;
|
isOverlayRuntimeInitialized: () => boolean;
|
||||||
|
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => boolean;
|
||||||
isQuitOnDisconnectArmed: () => boolean;
|
isQuitOnDisconnectArmed: () => boolean;
|
||||||
scheduleQuitCheck: (callback: () => void) => void;
|
scheduleQuitCheck: (callback: () => void) => void;
|
||||||
isMpvConnected: () => boolean;
|
isMpvConnected: () => boolean;
|
||||||
@@ -36,8 +37,13 @@ export function createHandleMpvConnectionChangeHandler(deps: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deps.reportJellyfinRemoteStopped();
|
deps.reportJellyfinRemoteStopped();
|
||||||
if (!deps.hasInitialJellyfinPlayArg()) return;
|
if (!deps.hasInitialPlaybackQuitOnDisconnectArg()) return;
|
||||||
if (deps.isOverlayRuntimeInitialized()) return;
|
if (
|
||||||
|
deps.isOverlayRuntimeInitialized() &&
|
||||||
|
!deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!deps.isQuitOnDisconnectArmed()) return;
|
if (!deps.isQuitOnDisconnectArmed()) return;
|
||||||
deps.scheduleQuitCheck(() => {
|
deps.scheduleQuitCheck(() => {
|
||||||
if (deps.isMpvConnected()) return;
|
if (deps.isMpvConnected()) return;
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
||||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||||
hasInitialJellyfinPlayArg: () => false,
|
hasInitialPlaybackQuitOnDisconnectArg: () => false,
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
|
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||||
isQuitOnDisconnectArmed: () => false,
|
isQuitOnDisconnectArmed: () => false,
|
||||||
scheduleQuitCheck: () => {
|
scheduleQuitCheck: () => {
|
||||||
calls.push('schedule-quit-check');
|
calls.push('schedule-quit-check');
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
syncOverlayMpvSubtitleSuppression: () => void;
|
||||||
resetSubtitleSidebarEmbeddedLayout: () => void;
|
resetSubtitleSidebarEmbeddedLayout: () => void;
|
||||||
scheduleCharacterDictionarySync?: () => void;
|
scheduleCharacterDictionarySync?: () => void;
|
||||||
hasInitialJellyfinPlayArg: () => boolean;
|
hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
|
||||||
isOverlayRuntimeInitialized: () => boolean;
|
isOverlayRuntimeInitialized: () => boolean;
|
||||||
|
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => boolean;
|
||||||
isQuitOnDisconnectArmed: () => boolean;
|
isQuitOnDisconnectArmed: () => boolean;
|
||||||
scheduleQuitCheck: (callback: () => void) => void;
|
scheduleQuitCheck: (callback: () => void) => void;
|
||||||
isMpvConnected: () => boolean;
|
isMpvConnected: () => boolean;
|
||||||
@@ -77,8 +78,11 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||||
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
|
hasInitialPlaybackQuitOnDisconnectArg: () =>
|
||||||
|
deps.hasInitialPlaybackQuitOnDisconnectArg(),
|
||||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||||
|
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
|
||||||
|
deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized(),
|
||||||
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
|
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
|
||||||
scheduleQuitCheck: (callback) => deps.scheduleQuitCheck(callback),
|
scheduleQuitCheck: (callback) => deps.scheduleQuitCheck(callback),
|
||||||
isMpvConnected: () => deps.isMpvConnected(),
|
isMpvConnected: () => deps.isMpvConnected(),
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||||
})();
|
})();
|
||||||
|
|
||||||
assert.equal(deps.hasInitialJellyfinPlayArg(), true);
|
assert.equal(deps.hasInitialPlaybackQuitOnDisconnectArg(), true);
|
||||||
assert.equal(deps.isOverlayRuntimeInitialized(), true);
|
assert.equal(deps.isOverlayRuntimeInitialized(), true);
|
||||||
assert.equal(deps.isQuitOnDisconnectArmed(), true);
|
assert.equal(deps.isQuitOnDisconnectArmed(), true);
|
||||||
assert.equal(deps.isMpvConnected(), true);
|
assert.equal(deps.isMpvConnected(), true);
|
||||||
@@ -158,3 +158,59 @@ test('mpv main event main deps wire subtitle callbacks without suppression gate'
|
|||||||
deps.setCurrentSubText('sub');
|
deps.setCurrentSubText('sub');
|
||||||
assert.equal(typeof deps.setCurrentSubText, 'function');
|
assert.equal(typeof deps.setCurrentSubText, 'function');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('flushPlaybackPositionOnMediaPathClear ignores disconnected mpv time-pos reads', async () => {
|
||||||
|
const recorded: number[] = [];
|
||||||
|
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||||
|
appState: {
|
||||||
|
initialArgs: null,
|
||||||
|
overlayRuntimeInitialized: true,
|
||||||
|
mpvClient: {
|
||||||
|
connected: false,
|
||||||
|
currentTimePos: 42,
|
||||||
|
requestProperty: async () => {
|
||||||
|
throw new Error('disconnected');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
immersionTracker: {
|
||||||
|
recordPlaybackPosition: (time: number) => {
|
||||||
|
recorded.push(time);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subtitleTimingTracker: null,
|
||||||
|
currentMediaPath: '',
|
||||||
|
currentSubText: '',
|
||||||
|
currentSubAssText: '',
|
||||||
|
playbackPaused: null,
|
||||||
|
previousSecondarySubVisibility: false,
|
||||||
|
},
|
||||||
|
getQuitOnDisconnectArmed: () => false,
|
||||||
|
scheduleQuitCheck: () => {},
|
||||||
|
quitApp: () => {},
|
||||||
|
reportJellyfinRemoteStopped: () => {},
|
||||||
|
syncOverlayMpvSubtitleSuppression: () => {},
|
||||||
|
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||||
|
logSubtitleTimingError: () => {},
|
||||||
|
broadcastToOverlayWindows: () => {},
|
||||||
|
onSubtitleChange: () => {},
|
||||||
|
ensureImmersionTrackerInitialized: () => {},
|
||||||
|
updateCurrentMediaPath: () => {},
|
||||||
|
restoreMpvSubVisibility: () => {},
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => {},
|
||||||
|
getCurrentAnilistMediaKey: () => null,
|
||||||
|
resetAnilistMediaTracking: () => {},
|
||||||
|
maybeProbeAnilistDuration: () => {},
|
||||||
|
ensureAnilistMediaGuess: () => {},
|
||||||
|
syncImmersionMediaState: () => {},
|
||||||
|
updateCurrentMediaTitle: () => {},
|
||||||
|
resetAnilistMediaGuessState: () => {},
|
||||||
|
reportJellyfinRemoteProgress: () => {},
|
||||||
|
updateSubtitleRenderMetrics: () => {},
|
||||||
|
refreshDiscordPresence: () => {},
|
||||||
|
})();
|
||||||
|
|
||||||
|
deps.flushPlaybackPositionOnMediaPathClear?.('');
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(recorded, [42]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { MergedToken, SubtitleData } from '../../types';
|
|||||||
|
|
||||||
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||||
appState: {
|
appState: {
|
||||||
initialArgs?: { jellyfinPlay?: unknown } | null;
|
initialArgs?: { jellyfinPlay?: unknown; youtubePlay?: unknown } | null;
|
||||||
overlayRuntimeInitialized: boolean;
|
overlayRuntimeInitialized: boolean;
|
||||||
mpvClient:
|
mpvClient:
|
||||||
| {
|
| {
|
||||||
@@ -79,8 +79,11 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
return () => ({
|
return () => ({
|
||||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||||
hasInitialJellyfinPlayArg: () => Boolean(deps.appState.initialArgs?.jellyfinPlay),
|
hasInitialPlaybackQuitOnDisconnectArg: () =>
|
||||||
|
Boolean(deps.appState.initialArgs?.jellyfinPlay || deps.appState.initialArgs?.youtubePlay),
|
||||||
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
|
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||||
|
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
|
||||||
|
Boolean(deps.appState.initialArgs?.youtubePlay),
|
||||||
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
|
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
|
||||||
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
|
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
|
||||||
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
|
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
|
||||||
@@ -187,16 +190,25 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
if (!mpvClient?.requestProperty) {
|
if (!mpvClient?.requestProperty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void mpvClient.requestProperty('time-pos').then((timePos) => {
|
void mpvClient
|
||||||
|
.requestProperty('time-pos')
|
||||||
|
.then((timePos) => {
|
||||||
const currentPath = (deps.appState.currentMediaPath ?? '').trim();
|
const currentPath = (deps.appState.currentMediaPath ?? '').trim();
|
||||||
if (currentPath.length > 0 && currentPath !== mediaPath) {
|
if (currentPath.length > 0 && currentPath !== mediaPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resolvedTime = Number(timePos);
|
const resolvedTime = Number(timePos);
|
||||||
if (Number.isFinite(currentKnownTime) && Number.isFinite(resolvedTime) && currentKnownTime === resolvedTime) {
|
if (
|
||||||
|
Number.isFinite(currentKnownTime) &&
|
||||||
|
Number.isFinite(resolvedTime) &&
|
||||||
|
currentKnownTime === resolvedTime
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writePlaybackPositionFromMpv(resolvedTime);
|
writePlaybackPositionFromMpv(resolvedTime);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// mpv can disconnect while clearing media; keep the last cached position.
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
|
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
|
||||||
|
|||||||
20
src/main/runtime/startup-tray-policy.test.ts
Normal file
20
src/main/runtime/startup-tray-policy.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { shouldEnsureTrayOnStartupForInitialArgs } from './startup-tray-policy';
|
||||||
|
|
||||||
|
test('startup tray policy enables tray on Windows by default', () => {
|
||||||
|
assert.equal(shouldEnsureTrayOnStartupForInitialArgs('win32', null), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup tray policy skips tray for direct youtube playback on Windows', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldEnsureTrayOnStartupForInitialArgs('win32', {
|
||||||
|
youtubePlay: 'https://www.youtube.com/watch?v=abc',
|
||||||
|
} as never),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup tray policy skips tray outside Windows', () => {
|
||||||
|
assert.equal(shouldEnsureTrayOnStartupForInitialArgs('linux', null), false);
|
||||||
|
});
|
||||||
14
src/main/runtime/startup-tray-policy.ts
Normal file
14
src/main/runtime/startup-tray-policy.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { CliArgs } from '../../cli/args';
|
||||||
|
|
||||||
|
export function shouldEnsureTrayOnStartupForInitialArgs(
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
initialArgs: CliArgs | null,
|
||||||
|
): boolean {
|
||||||
|
if (platform !== 'win32') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (initialArgs?.youtubePlay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -378,6 +378,73 @@ test('youtube flow does not report failure when subtitle track binds before cue
|
|||||||
assert.deepEqual(failures, []);
|
assert.deepEqual(failures, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('youtube flow does not fail when mpv reports sub-text as unavailable after track bind', async () => {
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => new Map(),
|
||||||
|
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {},
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
throw new Error("Failed to read MPV property 'sub-text': property unavailable");
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'primary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/auto-ja-orig.vtt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {
|
||||||
|
throw new Error('should not refresh when sub-text is unavailable');
|
||||||
|
},
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
reportSubtitleFailure: (message) => {
|
||||||
|
failures.push(message);
|
||||||
|
},
|
||||||
|
warn: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||||
|
|
||||||
|
assert.deepEqual(failures, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('youtube flow retries secondary subtitle selection until mpv reports the expected secondary sid', async () => {
|
test('youtube flow retries secondary subtitle selection until mpv reports the expected secondary sid', async () => {
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
const waits: number[] = [];
|
const waits: number[] = [];
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ async function injectDownloadedSubtitles(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSubText = await deps.requestMpvProperty('sub-text');
|
const currentSubText = await deps.requestMpvProperty('sub-text').catch(() => null);
|
||||||
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
|
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
|
||||||
deps.refreshCurrentSubtitle(currentSubText);
|
deps.refreshCurrentSubtitle(currentSubText);
|
||||||
}
|
}
|
||||||
|
|||||||
291
src/main/runtime/youtube-playback-launch.test.ts
Normal file
291
src/main/runtime/youtube-playback-launch.test.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createPrepareYoutubePlaybackInMpvHandler } from './youtube-playback-launch';
|
||||||
|
|
||||||
|
function createWaitStub() {
|
||||||
|
return async (_ms: number): Promise<void> => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('prepare youtube playback skips load when current path already matches exact URL', async () => {
|
||||||
|
const commands: Array<Array<string>> = [];
|
||||||
|
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
requestPath: async () => 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
requestProperty: async () => [{ type: 'video', id: 1 }],
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
wait: createWaitStub(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await prepare({ url: 'https://www.youtube.com/watch?v=abc123' });
|
||||||
|
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'sub-auto', 'no'],
|
||||||
|
['set_property', 'sid', 'no'],
|
||||||
|
['set_property', 'secondary-sid', 'no'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepare youtube playback treats matching video IDs as already loaded', async () => {
|
||||||
|
const commands: Array<Array<string>> = [];
|
||||||
|
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
requestPath: async () => 'https://youtu.be/abc123?t=5',
|
||||||
|
requestProperty: async () => [{ type: 'video', id: 1 }],
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
wait: createWaitStub(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await prepare({ url: 'https://www.youtube.com/watch?v=abc123' });
|
||||||
|
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'sub-auto', 'no'],
|
||||||
|
['set_property', 'sid', 'no'],
|
||||||
|
['set_property', 'secondary-sid', 'no'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepare youtube playback does not mark matching target ready until tracks exist', async () => {
|
||||||
|
const commands: Array<Array<string>> = [];
|
||||||
|
let requestCount = 0;
|
||||||
|
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
requestPath: async () => {
|
||||||
|
requestCount += 1;
|
||||||
|
return 'https://www.youtube.com/watch?v=abc123';
|
||||||
|
},
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
if (name !== 'track-list') return null;
|
||||||
|
return requestCount >= 3 ? [{ type: 'video', id: 1 }] : [];
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
wait: createWaitStub(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await prepare({
|
||||||
|
url: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
timeoutMs: 1500,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'sub-auto', 'no'],
|
||||||
|
['set_property', 'sid', 'no'],
|
||||||
|
['set_property', 'secondary-sid', 'no'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepare youtube playback replaces media and waits for path switch', async () => {
|
||||||
|
const commands: Array<Array<string>> = [];
|
||||||
|
const observedPaths = [
|
||||||
|
'/videos/episode01.mkv',
|
||||||
|
'/videos/episode01.mkv',
|
||||||
|
'https://www.youtube.com/watch?v=newvid',
|
||||||
|
];
|
||||||
|
const observedTrackLists = [null, [], [{ type: 'video', id: 1 }]];
|
||||||
|
let requestCount = 0;
|
||||||
|
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
requestPath: async () => {
|
||||||
|
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
|
||||||
|
requestCount += 1;
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
if (name !== 'track-list') return null;
|
||||||
|
return observedTrackLists[Math.min(requestCount - 1, observedTrackLists.length - 1)] ?? [];
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
wait: createWaitStub(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await prepare({
|
||||||
|
url: 'https://www.youtube.com/watch?v=newvid',
|
||||||
|
timeoutMs: 1500,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'sub-auto', 'no'],
|
||||||
|
['set_property', 'sid', 'no'],
|
||||||
|
['set_property', 'secondary-sid', 'no'],
|
||||||
|
['loadfile', 'https://www.youtube.com/watch?v=newvid', 'replace'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepare youtube playback returns false after timeout when path never updates', async () => {
|
||||||
|
const commands: Array<Array<string>> = [];
|
||||||
|
let nowTick = 0;
|
||||||
|
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
requestPath: async () => '/videos/episode01.mkv',
|
||||||
|
requestProperty: async () => [],
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
wait: createWaitStub(),
|
||||||
|
now: () => {
|
||||||
|
nowTick += 100;
|
||||||
|
return nowTick;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await prepare({
|
||||||
|
url: 'https://www.youtube.com/watch?v=never-switches',
|
||||||
|
timeoutMs: 350,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(ok, false);
|
||||||
|
assert.deepEqual(commands[4], [
|
||||||
|
'loadfile',
|
||||||
|
'https://www.youtube.com/watch?v=never-switches',
|
||||||
|
'replace',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepare youtube playback waits for playable media tracks after youtube path matches', async () => {
|
||||||
|
const commands: Array<Array<string>> = [];
|
||||||
|
const observedPaths = [
|
||||||
|
'/videos/episode01.mkv',
|
||||||
|
'https://www.youtube.com/watch?v=newvid',
|
||||||
|
'https://www.youtube.com/watch?v=newvid',
|
||||||
|
];
|
||||||
|
const observedTrackLists = [[], [], [{ type: 'audio', id: 1 }]];
|
||||||
|
let requestCount = 0;
|
||||||
|
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
requestPath: async () => {
|
||||||
|
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
|
||||||
|
requestCount += 1;
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
if (name !== 'track-list') return null;
|
||||||
|
return observedTrackLists[Math.min(requestCount - 1, observedTrackLists.length - 1)] ?? [];
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
wait: createWaitStub(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await prepare({
|
||||||
|
url: 'https://www.youtube.com/watch?v=newvid',
|
||||||
|
timeoutMs: 1500,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.deepEqual(commands[4], ['loadfile', 'https://www.youtube.com/watch?v=newvid', 'replace']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepare youtube playback accepts a non-youtube resolved path once playable tracks exist', async () => {
|
||||||
|
const commands: Array<Array<string>> = [];
|
||||||
|
const observedPaths = [
|
||||||
|
'/videos/episode01.mkv',
|
||||||
|
'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc',
|
||||||
|
];
|
||||||
|
const observedTrackLists = [[], [{ type: 'video', id: 1 }, { type: 'audio', id: 2 }]];
|
||||||
|
let requestCount = 0;
|
||||||
|
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
requestPath: async () => {
|
||||||
|
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
|
||||||
|
requestCount += 1;
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
if (name !== 'track-list') return null;
|
||||||
|
return observedTrackLists[Math.min(requestCount - 1, observedTrackLists.length - 1)] ?? [];
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
wait: createWaitStub(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await prepare({
|
||||||
|
url: 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc',
|
||||||
|
timeoutMs: 1500,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.deepEqual(commands[4], [
|
||||||
|
'loadfile',
|
||||||
|
'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc',
|
||||||
|
'replace',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepare youtube playback does not accept a different youtube video after path change', async () => {
|
||||||
|
const commands: Array<Array<string>> = [];
|
||||||
|
let nowTick = 0;
|
||||||
|
const observedPaths = [
|
||||||
|
'/videos/episode01.mkv',
|
||||||
|
'https://www.youtube.com/watch?v=wrongvid',
|
||||||
|
'https://www.youtube.com/watch?v=wrongvid',
|
||||||
|
];
|
||||||
|
let requestCount = 0;
|
||||||
|
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
requestPath: async () => {
|
||||||
|
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
|
||||||
|
requestCount += 1;
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
if (name !== 'track-list') return null;
|
||||||
|
return [{ type: 'video', id: 1 }];
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
wait: createWaitStub(),
|
||||||
|
now: () => {
|
||||||
|
nowTick += 100;
|
||||||
|
return nowTick;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await prepare({
|
||||||
|
url: 'https://www.youtube.com/watch?v=targetvid',
|
||||||
|
timeoutMs: 350,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(ok, false);
|
||||||
|
assert.deepEqual(commands[4], [
|
||||||
|
'loadfile',
|
||||||
|
'https://www.youtube.com/watch?v=targetvid',
|
||||||
|
'replace',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepare youtube playback accepts a fresh-start path change when the direct target matches exactly', async () => {
|
||||||
|
const commands: Array<Array<string>> = [];
|
||||||
|
const observedPaths = [
|
||||||
|
'',
|
||||||
|
'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc',
|
||||||
|
];
|
||||||
|
const observedTrackLists = [[], [{ type: 'video', id: 1 }, { type: 'audio', id: 2 }]];
|
||||||
|
let requestCount = 0;
|
||||||
|
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
requestPath: async () => {
|
||||||
|
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
|
||||||
|
requestCount += 1;
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
if (name !== 'track-list') return null;
|
||||||
|
return observedTrackLists[Math.min(requestCount - 1, observedTrackLists.length - 1)] ?? [];
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
wait: createWaitStub(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await prepare({
|
||||||
|
url: 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc',
|
||||||
|
timeoutMs: 1500,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.deepEqual(commands[4], [
|
||||||
|
'loadfile',
|
||||||
|
'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc',
|
||||||
|
'replace',
|
||||||
|
]);
|
||||||
|
});
|
||||||
169
src/main/runtime/youtube-playback-launch.ts
Normal file
169
src/main/runtime/youtube-playback-launch.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { isYoutubeMediaPath } from './youtube-playback';
|
||||||
|
|
||||||
|
type YoutubePlaybackLaunchInput = {
|
||||||
|
url: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type YoutubePlaybackLaunchDeps = {
|
||||||
|
requestPath: () => Promise<string | null>;
|
||||||
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
|
sendMpvCommand: (command: Array<string>) => void;
|
||||||
|
wait: (ms: number) => Promise<void>;
|
||||||
|
now?: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePath(value: string | null | undefined): string {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractYoutubeVideoId(url: string): string | null {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsed.hostname.toLowerCase();
|
||||||
|
const path = parsed.pathname.replace(/^\/+/, '');
|
||||||
|
|
||||||
|
if (host === 'youtu.be' || host.endsWith('.youtu.be')) {
|
||||||
|
const id = path.split('/')[0]?.trim() || '';
|
||||||
|
return id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const youtubeHost =
|
||||||
|
host === 'youtube.com' ||
|
||||||
|
host.endsWith('.youtube.com') ||
|
||||||
|
host === 'youtube-nocookie.com' ||
|
||||||
|
host.endsWith('.youtube-nocookie.com');
|
||||||
|
if (!youtubeHost) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.pathname === '/watch') {
|
||||||
|
const id = parsed.searchParams.get('v')?.trim() || '';
|
||||||
|
return id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('shorts/') || path.startsWith('embed/')) {
|
||||||
|
const id = path.split('/')[1]?.trim() || '';
|
||||||
|
return id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetsSameYoutubeVideo(currentPath: string, targetUrl: string): boolean {
|
||||||
|
const currentId = extractYoutubeVideoId(currentPath);
|
||||||
|
const targetId = extractYoutubeVideoId(targetUrl);
|
||||||
|
if (!currentId || !targetId) return false;
|
||||||
|
return currentId === targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathMatchesYoutubeTarget(currentPath: string, targetUrl: string): boolean {
|
||||||
|
if (!currentPath) return false;
|
||||||
|
if (currentPath === targetUrl) return true;
|
||||||
|
return targetsSameYoutubeVideo(currentPath, targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPlayableMediaTracks(trackListRaw: unknown): boolean {
|
||||||
|
if (!Array.isArray(trackListRaw)) return false;
|
||||||
|
return trackListRaw.some((track) => {
|
||||||
|
if (!track || typeof track !== 'object') return false;
|
||||||
|
const type = String((track as Record<string, unknown>).type || '').trim().toLowerCase();
|
||||||
|
return type === 'video' || type === 'audio';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendPlaybackPrepCommands(sendMpvCommand: (command: Array<string>) => void): void {
|
||||||
|
sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||||
|
sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||||
|
sendMpvCommand(['set_property', 'sid', 'no']);
|
||||||
|
sendMpvCommand(['set_property', 'secondary-sid', 'no']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPrepareYoutubePlaybackInMpvHandler(deps: YoutubePlaybackLaunchDeps) {
|
||||||
|
const now = deps.now ?? (() => Date.now());
|
||||||
|
return async (input: YoutubePlaybackLaunchInput): Promise<boolean> => {
|
||||||
|
const targetUrl = input.url.trim();
|
||||||
|
if (!targetUrl) return false;
|
||||||
|
|
||||||
|
const timeoutMs = Math.max(200, input.timeoutMs ?? 5000);
|
||||||
|
const pollIntervalMs = Math.max(25, input.pollIntervalMs ?? 100);
|
||||||
|
|
||||||
|
let previousPath = '';
|
||||||
|
try {
|
||||||
|
previousPath = normalizePath(await deps.requestPath());
|
||||||
|
} catch {
|
||||||
|
// Ignore transient path request failures and continue with bootstrap commands.
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPlaybackPrepCommands(deps.sendMpvCommand);
|
||||||
|
|
||||||
|
const alreadyTarget = pathMatchesYoutubeTarget(previousPath, targetUrl);
|
||||||
|
if (alreadyTarget) {
|
||||||
|
if (!deps.requestProperty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const trackList = await deps.requestProperty('track-list');
|
||||||
|
if (hasPlayableMediaTracks(trackList)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep polling; mpv can report the target path before tracks are ready.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = now() + timeoutMs;
|
||||||
|
while (now() < deadline) {
|
||||||
|
await deps.wait(pollIntervalMs);
|
||||||
|
let currentPath = '';
|
||||||
|
try {
|
||||||
|
currentPath = normalizePath(await deps.requestPath());
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!currentPath) continue;
|
||||||
|
if (pathMatchesYoutubeTarget(currentPath, targetUrl)) {
|
||||||
|
if (!deps.requestProperty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const trackList = await deps.requestProperty('track-list');
|
||||||
|
if (hasPlayableMediaTracks(trackList)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue polling until media tracks are actually available.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pathDiffersFromInitial = currentPath !== previousPath;
|
||||||
|
const matchesChangedTarget =
|
||||||
|
currentPath === targetUrl ||
|
||||||
|
(isYoutubeMediaPath(currentPath) &&
|
||||||
|
isYoutubeMediaPath(targetUrl) &&
|
||||||
|
pathMatchesYoutubeTarget(currentPath, targetUrl));
|
||||||
|
if (pathDiffersFromInitial && matchesChangedTarget) {
|
||||||
|
if (deps.requestProperty) {
|
||||||
|
try {
|
||||||
|
const trackList = await deps.requestProperty('track-list');
|
||||||
|
if (hasPlayableMediaTracks(trackList)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue polling until media tracks are actually available.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -181,5 +181,6 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
|||||||
}
|
}
|
||||||
schedulePendingCheck();
|
schedulePendingCheck();
|
||||||
},
|
},
|
||||||
|
isAppOwnedFlowInFlight: (): boolean => appOwnedFlowInFlight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,30 @@ test('release packaging keeps default file inclusion and excludes large source-o
|
|||||||
assert.ok(files.includes('!release-*{,/**/*}'));
|
assert.ok(files.includes('!release-*{,/**/*}'));
|
||||||
assert.ok(files.includes('!vendor/subminer-yomitan{,/**/*}'));
|
assert.ok(files.includes('!vendor/subminer-yomitan{,/**/*}'));
|
||||||
assert.ok(files.includes('!vendor/texthooker-ui/src{,/**/*}'));
|
assert.ok(files.includes('!vendor/texthooker-ui/src{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!assets{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!plugin{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!vendor/yomitan-jlpt-vocab{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!docs{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!tests{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!packaging{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!README.md'));
|
||||||
|
assert.ok(files.includes('!CHANGELOG.md'));
|
||||||
|
assert.ok(files.includes('!AGENTS.md'));
|
||||||
|
assert.ok(files.includes('!CLAUDE.md'));
|
||||||
|
assert.ok(files.includes('!stats/public{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!stats/package.json'));
|
||||||
|
assert.ok(files.includes('!stats/tsconfig.json'));
|
||||||
|
assert.ok(files.includes('!stats/vite.config.ts'));
|
||||||
|
assert.ok(files.includes('!dist/**/*.map'));
|
||||||
|
assert.ok(files.includes('!dist/**/*.test.*'));
|
||||||
|
assert.ok(files.includes('!dist/**/__tests__{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!scripts/**/*.test.*'));
|
||||||
|
assert.ok(files.includes('!vendor/texthooker-ui/public{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!vendor/texthooker-ui/.vscode{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!vendor/texthooker-ui/README.md'));
|
||||||
|
assert.ok(files.includes('!vendor/texthooker-ui/package.json'));
|
||||||
|
assert.ok(files.includes('!vendor/texthooker-ui/tsconfig*.json'));
|
||||||
|
assert.ok(files.includes('!node_modules/@libsql/linux-x64-musl{,/**/*}'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('config example generation runs directly from source without unrelated bundle prerequisites', () => {
|
test('config example generation runs directly from source without unrelated bundle prerequisites', () => {
|
||||||
|
|||||||
36
src/renderer/controller-interaction-blocking.test.ts
Normal file
36
src/renderer/controller-interaction-blocking.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||||
|
|
||||||
|
test('subtitle sidebar stays controller-passive while other modals block controller input', () => {
|
||||||
|
assert.equal(
|
||||||
|
isControllerInteractionBlocked({
|
||||||
|
controllerSelectModalOpen: false,
|
||||||
|
controllerDebugModalOpen: false,
|
||||||
|
jimakuModalOpen: false,
|
||||||
|
kikuModalOpen: false,
|
||||||
|
runtimeOptionsModalOpen: false,
|
||||||
|
subsyncModalOpen: false,
|
||||||
|
youtubePickerModalOpen: false,
|
||||||
|
sessionHelpModalOpen: false,
|
||||||
|
subtitleSidebarModalOpen: true,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isControllerInteractionBlocked({
|
||||||
|
controllerSelectModalOpen: false,
|
||||||
|
controllerDebugModalOpen: false,
|
||||||
|
jimakuModalOpen: false,
|
||||||
|
kikuModalOpen: false,
|
||||||
|
runtimeOptionsModalOpen: true,
|
||||||
|
subsyncModalOpen: false,
|
||||||
|
youtubePickerModalOpen: false,
|
||||||
|
sessionHelpModalOpen: false,
|
||||||
|
subtitleSidebarModalOpen: false,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
24
src/renderer/controller-interaction-blocking.ts
Normal file
24
src/renderer/controller-interaction-blocking.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
type ControllerInteractionModalState = {
|
||||||
|
controllerSelectModalOpen: boolean;
|
||||||
|
controllerDebugModalOpen: boolean;
|
||||||
|
jimakuModalOpen: boolean;
|
||||||
|
kikuModalOpen: boolean;
|
||||||
|
runtimeOptionsModalOpen: boolean;
|
||||||
|
subsyncModalOpen: boolean;
|
||||||
|
youtubePickerModalOpen: boolean;
|
||||||
|
sessionHelpModalOpen: boolean;
|
||||||
|
subtitleSidebarModalOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isControllerInteractionBlocked(state: ControllerInteractionModalState): boolean {
|
||||||
|
return (
|
||||||
|
state.controllerSelectModalOpen ||
|
||||||
|
state.controllerDebugModalOpen ||
|
||||||
|
state.jimakuModalOpen ||
|
||||||
|
state.kikuModalOpen ||
|
||||||
|
state.runtimeOptionsModalOpen ||
|
||||||
|
state.subsyncModalOpen ||
|
||||||
|
state.youtubePickerModalOpen ||
|
||||||
|
state.sessionHelpModalOpen
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -759,6 +759,76 @@ test('restorePointerInteractionState re-enables subtitle hover when pointer is a
|
|||||||
}
|
}
|
||||||
handlers.restorePointerInteractionState();
|
handlers.restorePointerInteractionState();
|
||||||
|
|
||||||
|
assert.equal(ctx.state.isOverSubtitle, true);
|
||||||
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(ignoreCalls, [
|
||||||
|
{ ignore: false, forward: undefined },
|
||||||
|
{ ignore: false, forward: undefined },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pointer tracking enables overlay interaction as soon as the cursor reaches subtitles', () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
|
ctx.platform.shouldToggleMouseIgnore = true;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'hidden',
|
||||||
|
display: 'none',
|
||||||
|
opacity: '0',
|
||||||
|
}),
|
||||||
|
focus: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||||
|
const bucket = documentListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
documentListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
elementFromPoint: () => ctx.dom.subtitleContainer,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupPointerTracking();
|
||||||
|
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||||
|
listener({ clientX: 120, clientY: 240 });
|
||||||
|
}
|
||||||
|
|
||||||
assert.equal(ctx.state.isOverSubtitle, true);
|
assert.equal(ctx.state.isOverSubtitle, true);
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
@@ -768,6 +838,82 @@ test('restorePointerInteractionState re-enables subtitle hover when pointer is a
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pointer tracking restores click-through after the cursor leaves subtitles', () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
|
let hoveredElement: unknown = ctx.dom.subtitleContainer;
|
||||||
|
ctx.platform.shouldToggleMouseIgnore = true;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'hidden',
|
||||||
|
display: 'none',
|
||||||
|
opacity: '0',
|
||||||
|
}),
|
||||||
|
focus: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
||||||
|
const bucket = documentListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
documentListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
elementFromPoint: () => hoveredElement,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.setupPointerTracking();
|
||||||
|
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||||
|
listener({ clientX: 120, clientY: 240 });
|
||||||
|
}
|
||||||
|
|
||||||
|
hoveredElement = null;
|
||||||
|
for (const listener of documentListeners.get('mousemove') ?? []) {
|
||||||
|
listener({ clientX: 640, clientY: 360 });
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(ctx.state.isOverSubtitle, false);
|
||||||
|
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
|
||||||
|
assert.deepEqual(ignoreCalls, [
|
||||||
|
{ ignore: false, forward: undefined },
|
||||||
|
{ ignore: true, forward: true },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('restorePointerInteractionState keeps overlay interactive until first real pointer move can resync hover', () => {
|
test('restorePointerInteractionState keeps overlay interactive until first real pointer move can resync hover', () => {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export function createMouseHandlers(
|
|||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
sendMpvCommand: (command: (string | number)[]) => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
type HoverPointState = {
|
||||||
|
overPrimarySubtitle: boolean;
|
||||||
|
overSecondarySubtitle: boolean;
|
||||||
|
isOverSubtitle: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
let yomitanPopupVisible = false;
|
let yomitanPopupVisible = false;
|
||||||
let hoverPauseRequestId = 0;
|
let hoverPauseRequestId = 0;
|
||||||
let popupPauseRequestId = 0;
|
let popupPauseRequestId = 0;
|
||||||
@@ -45,7 +51,7 @@ export function createMouseHandlers(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncHoverStateFromPoint(clientX: number, clientY: number): boolean {
|
function getHoverStateFromPoint(clientX: number, clientY: number): HoverPointState {
|
||||||
const hoveredElement =
|
const hoveredElement =
|
||||||
typeof document.elementFromPoint === 'function'
|
typeof document.elementFromPoint === 'function'
|
||||||
? document.elementFromPoint(clientX, clientY)
|
? document.elementFromPoint(clientX, clientY)
|
||||||
@@ -56,13 +62,52 @@ export function createMouseHandlers(
|
|||||||
ctx.dom.secondarySubContainer,
|
ctx.dom.secondarySubContainer,
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.state.isOverSubtitle = overPrimarySubtitle || overSecondarySubtitle;
|
return {
|
||||||
|
overPrimarySubtitle,
|
||||||
|
overSecondarySubtitle,
|
||||||
|
isOverSubtitle: overPrimarySubtitle || overSecondarySubtitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHoverStateFromPoint(clientX: number, clientY: number): HoverPointState {
|
||||||
|
const hoverState = getHoverStateFromPoint(clientX, clientY);
|
||||||
|
|
||||||
|
ctx.state.isOverSubtitle = hoverState.isOverSubtitle;
|
||||||
ctx.dom.secondarySubContainer.classList.toggle(
|
ctx.dom.secondarySubContainer.classList.toggle(
|
||||||
'secondary-sub-hover-active',
|
'secondary-sub-hover-active',
|
||||||
overSecondarySubtitle,
|
hoverState.overSecondarySubtitle,
|
||||||
);
|
);
|
||||||
|
|
||||||
return ctx.state.isOverSubtitle;
|
return hoverState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHoverStateFromTrackedPointer(event: MouseEvent | PointerEvent): void {
|
||||||
|
if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isDragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasOverSubtitle = ctx.state.isOverSubtitle;
|
||||||
|
const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains(
|
||||||
|
'secondary-sub-hover-active',
|
||||||
|
);
|
||||||
|
const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY);
|
||||||
|
|
||||||
|
if (!wasOverSubtitle && hoverState.isOverSubtitle) {
|
||||||
|
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasOverSubtitle && !hoverState.isOverSubtitle) {
|
||||||
|
void handleMouseLeave(undefined, wasOverSecondarySubtitle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
hoverState.isOverSubtitle &&
|
||||||
|
hoverState.overSecondarySubtitle !== wasOverSecondarySubtitle
|
||||||
|
) {
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restorePointerInteractionState(): void {
|
function restorePointerInteractionState(): void {
|
||||||
@@ -293,10 +338,12 @@ export function createMouseHandlers(
|
|||||||
function setupPointerTracking(): void {
|
function setupPointerTracking(): void {
|
||||||
document.addEventListener('mousemove', (event: MouseEvent) => {
|
document.addEventListener('mousemove', (event: MouseEvent) => {
|
||||||
updatePointerPosition(event);
|
updatePointerPosition(event);
|
||||||
|
syncHoverStateFromTrackedPointer(event);
|
||||||
maybeResyncPointerHoverState(event);
|
maybeResyncPointerHoverState(event);
|
||||||
});
|
});
|
||||||
document.addEventListener('pointermove', (event: PointerEvent) => {
|
document.addEventListener('pointermove', (event: PointerEvent) => {
|
||||||
updatePointerPosition(event);
|
updatePointerPosition(event);
|
||||||
|
syncHoverStateFromTrackedPointer(event);
|
||||||
maybeResyncPointerHoverState(event);
|
maybeResyncPointerHoverState(event);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { createJimakuModal } from './modals/jimaku.js';
|
|||||||
import { createKikuModal } from './modals/kiku.js';
|
import { createKikuModal } from './modals/kiku.js';
|
||||||
import { createSessionHelpModal } from './modals/session-help.js';
|
import { createSessionHelpModal } from './modals/session-help.js';
|
||||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||||
|
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||||
import { createSubsyncModal } from './modals/subsync.js';
|
import { createSubsyncModal } from './modals/subsync.js';
|
||||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||||
@@ -88,6 +89,10 @@ function isAnyModalOpen(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isControllerInputBlocked(): boolean {
|
||||||
|
return isControllerInteractionBlocked(ctx.state);
|
||||||
|
}
|
||||||
|
|
||||||
function syncSettingsModalSubtitleSuppression(): void {
|
function syncSettingsModalSubtitleSuppression(): void {
|
||||||
const suppressSubtitles = isAnySettingsModalOpen();
|
const suppressSubtitles = isAnySettingsModalOpen();
|
||||||
document.body.classList.toggle('settings-modal-open', suppressSubtitles);
|
document.body.classList.toggle('settings-modal-open', suppressSubtitles);
|
||||||
@@ -323,7 +328,7 @@ function startControllerPolling(): void {
|
|||||||
},
|
},
|
||||||
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
||||||
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
||||||
getInteractionBlocked: () => isAnyModalOpen(),
|
getInteractionBlocked: () => isControllerInputBlocked(),
|
||||||
toggleKeyboardMode: () => keyboardHandlers.handleKeyboardModeToggleRequested(),
|
toggleKeyboardMode: () => keyboardHandlers.handleKeyboardModeToggleRequested(),
|
||||||
toggleLookup: () => keyboardHandlers.handleLookupWindowToggleRequested(),
|
toggleLookup: () => keyboardHandlers.handleLookupWindowToggleRequested(),
|
||||||
closeLookup: () => {
|
closeLookup: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user