mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-12 04:19:25 -07:00
Compare commits
5 Commits
24667ad6c9
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
b24d9d7487
|
|||
| 3a01cffc6b | |||
|
eddf6f0456
|
|||
|
f6c024d61e
|
|||
| 6749ff843c |
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -334,6 +334,14 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build changelog artifacts for release
|
||||||
|
run: |
|
||||||
|
if find changes -maxdepth 1 -name '*.md' -not -name README.md -print -quit | grep -q .; then
|
||||||
|
bun run changelog:build --version "${{ steps.version.outputs.VERSION }}"
|
||||||
|
else
|
||||||
|
echo "No pending changelog fragments found."
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Verify changelog is ready for tagged release
|
- name: Verify changelog is ready for tagged release
|
||||||
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
|
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
|
|||||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.8.0 (2026-03-22)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Docs: Refreshed the vendored Texthooker docs/index.html bundle to match the latest local build artifacts.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Anki: Known-word cache refreshes now reconcile Anki changes incrementally instead of wiping and rebuilding on startup, mined cards can append their word into the cache immediately through a new default-enabled config flag, and explicit refreshes now run through `subminer doctor --refresh-known-words`.
|
||||||
|
- Subtitle: Restored known-word coloring and JLPT underlines for subtitle tokens like `大体` when the subtitle token is kanji but the known-word cache only matches the kana reading.
|
||||||
|
- Stats: Episode progress in the anime page now uses the last ended playback position instead of cumulative active watch time, avoiding distorted percentages after rewatches or repeated sessions.
|
||||||
|
- Stats: Anime episode progress now keeps the latest known playback position through active-session checkpoints and stale-session recovery, so recently watched episodes no longer lose their progress percentage.
|
||||||
|
- Stats: Anime episode progress now falls back to the latest retained subtitle/event timing when a session is missing a persisted playback-position checkpoint, so older watch sessions no longer get stuck at `0%` progress.
|
||||||
|
- Overlay: Kept subtitle sidebar cue tracking stable across transitions by avoiding cue-line regression on subtitle timing edge cases and stale text updates.
|
||||||
|
- Overlay: Improved sidebar config by documenting and exposing layout mode and typography options (`layout`, `fontFamily`, `fontSize`) in the generated documentation flow.
|
||||||
|
- Overlay: Added `subtitleSidebar.autoOpen` (default `false`) to open the subtitle sidebar once during overlay startup when the sidebar feature is enabled.
|
||||||
|
- Overlay: Made subtitle sidebar resume/start positioning jump directly to the first resolved active cue instead of smooth-scrolling through the full list, while keeping smooth auto-follow for later cue changes.
|
||||||
|
|
||||||
## v0.7.0 (2026-03-19)
|
## v0.7.0 (2026-03-19)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
id: TASK-143
|
id: TASK-143
|
||||||
title: Keep character dictionary auto-sync non-blocking during startup
|
title: Keep character dictionary auto-sync non-blocking during startup
|
||||||
status: Done
|
status: In Progress
|
||||||
assignee: []
|
assignee:
|
||||||
|
- codex
|
||||||
created_date: '2026-03-09 01:45'
|
created_date: '2026-03-09 01:45'
|
||||||
updated_date: '2026-03-18 05:28'
|
updated_date: '2026-03-20 09:22'
|
||||||
labels:
|
labels:
|
||||||
- dictionary
|
- dictionary
|
||||||
- startup
|
- startup
|
||||||
@@ -33,8 +34,20 @@ Keep character dictionary auto-sync running in parallel during startup without d
|
|||||||
- [x] #3 Regression coverage verifies auto-sync builds before the gate and only mutates Yomitan after the gate resolves.
|
- [x] #3 Regression coverage verifies auto-sync builds before the gate and only mutates Yomitan after the gate resolves.
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add a regression test for startup autoplay release surviving delayed mpv readiness or late subtitle refresh after dictionary sync.
|
||||||
|
2. Harden the autoplay-ready release path so paused startup keeps retrying until mpv is actually released or media changes, without resuming user-paused playback later.
|
||||||
|
3. Keep the existing character-dictionary revisit fixes and paused-startup OSD fixes aligned with the autoplay change, then run targeted runtime tests and typecheck.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
Added a small current-media tokenization gate in main runtime. Media changes reset the gate, the first tokenization-ready event marks it ready, and auto-sync now waits on that gate only before Yomitan dictionary inspection/import/settings updates. Snapshot generation and merged ZIP build still run immediately in parallel.
|
Added a small current-media tokenization gate in main runtime. Media changes reset the gate, the first tokenization-ready event marks it ready, and auto-sync now waits on that gate only before Yomitan dictionary inspection/import/settings updates. Snapshot generation and merged ZIP build still run immediately in parallel.
|
||||||
|
|
||||||
|
2026-03-20: User reports startup remains paused after annotations/tokenization are visible and only resumes after character-dictionary generation/import finishes. Investigating autoplay-ready release regression vs dictionary sync completion refresh.
|
||||||
|
|
||||||
|
2026-03-20: Added startup autoplay retry-budget helper so paused startup retries cover the full plugin gate window instead of only ~2.8s. Verification: bun test src/main/runtime/startup-autoplay-release-policy.test.ts src/main/runtime/character-dictionary-auto-sync.test.ts src/main/runtime/startup-osd-sequencer.test.ts src/main/runtime/character-dictionary-auto-sync-completion.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; bun run test:smoke:dist; runtime-compat verifier passed at .tmp/skill-verification/subminer-verify-20260320-022106-nM28Nk. Pending real installed-app/mpv validation.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
id: TASK-192
|
||||||
|
title: Fix stale anime cover art after AniList reassignment
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-03-20 00:12'
|
||||||
|
updated_date: '2026-03-20 00:14'
|
||||||
|
labels:
|
||||||
|
- stats
|
||||||
|
- immersion-tracker
|
||||||
|
- anilist
|
||||||
|
milestone: m-1
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- src/core/services/immersion-tracker-service.ts
|
||||||
|
- src/core/services/immersion-tracker/query.ts
|
||||||
|
- src/core/services/immersion-tracker-service.test.ts
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Fix the stats anime-detail cover image path so reassigning an anime to a different AniList entry replaces the stored cover art bytes instead of keeping the previous image blob under updated metadata.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Reassigning an anime to a different AniList entry stores the new cover art bytes for that anime's videos
|
||||||
|
- [x] #2 Shared blob deduplication still works when multiple videos in the anime use the same new cover image
|
||||||
|
- [x] #3 Focused regression coverage proves stale cover blobs are replaced on reassignment
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add a failing regression test that reassigns an anime twice with different downloaded cover bytes and asserts the resolved cover updates.
|
||||||
|
2. Update cover-art upsert logic so new blob bytes generate a new shared hash instead of reusing an existing hash for the row.
|
||||||
|
3. Run the focused immersion tracker service test file and record the result.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
2026-03-20: Created during live debugging of a user-reported stale anime profile picture after changing the AniList entry from the stats UI.
|
||||||
|
2026-03-20: Root cause was in `upsertCoverArt(...)`. When a row already had `cover_blob_hash`, a later AniList reassignment with a freshly downloaded cover reused the existing hash instead of hashing the new bytes, so the blob store kept serving the old image while metadata changed.
|
||||||
|
2026-03-20: Added a regression in `src/core/services/immersion-tracker-service.test.ts` that reassigns the same anime twice with different fetched image bytes and asserts the resolved anime cover changes to the second blob while both videos still deduplicate to one shared hash.
|
||||||
|
2026-03-20: Fixed `src/core/services/immersion-tracker/query.ts` so incoming cover blob bytes compute a fresh hash before falling back to an existing row hash. Existing hashes are now reused only when no new bytes were fetched.
|
||||||
|
2026-03-20: Verification commands run:
|
||||||
|
- `bun test src/core/services/immersion-tracker-service.test.ts`
|
||||||
|
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker-service.test.ts`
|
||||||
|
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker-service.test.ts`
|
||||||
|
2026-03-20: Verification results:
|
||||||
|
- focused service test: passed
|
||||||
|
- verifier lane selection: `core`
|
||||||
|
- verifier result: passed (`bun run typecheck`, `bun run test:fast`)
|
||||||
|
- verifier artifacts: `.tmp/skill-verification/subminer-verify-20260320-001433-IZLFqs/`
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Fixed stale anime cover art after AniList reassignment by correcting cover-blob hash replacement in the immersion tracker storage layer. Reassignments now store the new fetched image bytes instead of reusing the previous blob hash from the row, while still deduplicating the updated image across videos in the same anime.
|
||||||
|
|
||||||
|
Added focused regression coverage that reproduces the exact failure mode: same anime reassigned twice with different cover downloads, with the second image expected to replace the first. Verified with the touched service test file plus the SubMiner `core` verification lane.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
id: TASK-211
|
||||||
|
title: Recover anime episode progress from subtitle timing when checkpoints are missing
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- '@Codex'
|
||||||
|
created_date: '2026-03-20 10:15'
|
||||||
|
updated_date: '2026-03-20 10:22'
|
||||||
|
labels:
|
||||||
|
- stats
|
||||||
|
- bug
|
||||||
|
milestone: m-1
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- src/core/services/immersion-tracker/query.ts
|
||||||
|
- src/core/services/immersion-tracker/__tests__/query.test.ts
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Anime episode progress can still show `0%` for older sessions that have watch-time and subtitle timing but no persisted `ended_media_ms` checkpoint. Recover progress from the latest retained subtitle/event segment end so already-recorded sessions render a useful progress percentage.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `getAnimeEpisodes` returns the latest known session position even when `ended_media_ms` is null but subtitle/event timing exists.
|
||||||
|
- [x] Existing ended-session metrics and aggregation totals do not regress.
|
||||||
|
- [x] Regression coverage locks the fallback behavior.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
Added a query-side fallback for anime episode progress: when the newest session for a video has no persisted `ended_media_ms`, `getAnimeEpisodes` now uses the latest retained subtitle-line or session-event `segment_end_ms` from that same session. This recovers useful progress for already-recorded sessions that have timing data but predate or missed checkpoint persistence.
|
||||||
|
|
||||||
|
Verification: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` passed. `bun run typecheck` passed.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
id: TASK-212
|
||||||
|
title: Fix mac texthooker helper startup blocking mpv launch
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-20 08:27'
|
||||||
|
updated_date: '2026-03-20 08:45'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- macos
|
||||||
|
- startup
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/startup.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/plugin/subminer/process.lua
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
`subminer` mpv auto-start on mac can stall before the video is usable because the helper process launched with `--texthooker` still runs heavy app-ready startup. Recent logs show the helper loading the Yomitan Chromium extension, emitting `Permission 'contextMenus' is unknown` warnings, then hitting Chromium runtime errors before SubMiner signals readiness back to the mpv plugin. The texthooker helper should take the minimal startup path needed to serve texthooker traffic without loading overlay/window-only startup work that can crash or delay readiness.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Launching SubMiner with `--texthooker` avoids heavy app-ready startup work that is not required for texthooker helper mode.
|
||||||
|
- [x] #2 A regression test covers texthooker helper startup so it fails if Yomitan extension loading is reintroduced on that path.
|
||||||
|
- [x] #3 The change preserves existing startup behavior for non-texthooker app launches.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Follow-up: user confirmed the root issue is the plugin auto-start ordering. Adjust mpv plugin sequencing so `--start` launches before any separate `--texthooker` helper, then verify plugin regressions still pass.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Fixed the mac mpv startup hang caused by the `--texthooker` helper taking the full app-ready path. `runAppReadyRuntime` now fast-paths texthooker-only mode through minimal startup (`reloadConfig` plus CLI handling) so it no longer loads Yomitan or first-run setup work before serving texthooker traffic. Added regression coverage in `src/core/services/app-ready.test.ts`, then verified with `bun test src/core/services/app-ready.test.ts src/core/services/startup.test.ts`, `bun test src/cli/args.test.ts src/main/early-single-instance.test.ts src/main/runtime/stats-cli-command.test.ts`, and `bun run typecheck`.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
id: TASK-213
|
||||||
|
title: Show character dictionary progress during paused startup waits
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-20 08:59'
|
||||||
|
updated_date: '2026-03-20 09:22'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- ux
|
||||||
|
- dictionary
|
||||||
|
- startup
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- >-
|
||||||
|
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/startup-osd-sequencer.ts
|
||||||
|
- >-
|
||||||
|
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync-notifications.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
During startup on mpv auto-start, character dictionary regeneration/update can be active while playback remains paused. The current startup OSD sequencer buffers dictionary progress behind annotation-loading OSD, which leaves the user with no visible dictionary-specific progress while the pause is active. Adjust the startup OSD sequencing so dictionary progress can surface once tokenization is ready during the paused startup window, without regressing later ready/failure handling.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 When tokenization is ready during startup, later character dictionary progress updates are shown on OSD even if annotation-loading state is still active.
|
||||||
|
- [ ] #2 Startup OSD completion/failure behavior for character dictionary sync remains coherent after the new progress ordering.
|
||||||
|
- [ ] #3 Regression coverage exercises the paused startup sequencing for dictionary progress.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
2026-03-20: Confirmed issue is broader than OSD-only. Paused-startup OSD fixes remain relevant, but current user report also points at a regression in non-blocking startup playback release (tracked in TASK-143).
|
||||||
|
|
||||||
|
2026-03-20: OSD sequencing fix remains in local patch alongside TASK-143 regression fix. Covered by startup-osd-sequencer tests; pending installed-app/mpv validation before task finalization.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
id: TASK-214
|
||||||
|
title: Jump subtitle sidebar directly to resume position on first resolved cue
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-21 11:15'
|
||||||
|
updated_date: '2026-03-21 11:15'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- ux
|
||||||
|
- overlay
|
||||||
|
- subtitles
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.test.ts
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
When playback starts from a resumed timestamp while the subtitle sidebar is open, the sidebar currently smooth-scrolls from the top of the cue list to the resumed cue. Change the first resolved active-cue positioning to jump immediately to the resume location while preserving smooth auto-follow for later playback-driven cue advances.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 The first active cue resolved after open/resume uses an instant jump instead of smooth-scrolling through the list.
|
||||||
|
- [x] #2 Normal subtitle-sidebar auto-follow remains smooth after the first active cue has been positioned.
|
||||||
|
- [x] #3 Regression coverage distinguishes the initial jump behavior from later smooth auto-follow updates.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
2026-03-21: Fixed by treating the first auto-scroll from `previousActiveCueIndex < 0` as `behavior: 'auto'` in the subtitle sidebar scroll helper. Added renderer regression coverage for initial jump plus later smooth follow.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
id: TASK-215
|
||||||
|
title: Add startup auto-open option for subtitle sidebar
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-21 11:35'
|
||||||
|
updated_date: '2026-03-21 11:35'
|
||||||
|
labels:
|
||||||
|
- feature
|
||||||
|
- ux
|
||||||
|
- overlay
|
||||||
|
- subtitles
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/types.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/config/definitions/defaults-subtitle.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/config/resolve/subtitle-domains.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/renderer.ts
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Add a subtitle sidebar config option that auto-opens the sidebar once during overlay startup. The option should default to `false`, only apply when the sidebar feature is enabled, and should not force the sidebar back open later in the same session after manual close or later visibility changes.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 `subtitleSidebar.autoOpen` is available in config with default `false`.
|
||||||
|
- [x] #2 When enabled, overlay startup opens the subtitle sidebar once after initial sidebar config/snapshot load.
|
||||||
|
- [x] #3 Regression coverage covers config resolution and startup-only auto-open behavior.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
2026-03-21: Added `subtitleSidebar.autoOpen` to types/defaults/config registry and resolver. Renderer bootstrap now calls a startup-only subtitle sidebar helper after the initial snapshot refresh. Modal regression coverage verifies startup auto-open requires both `enabled` and `autoOpen`.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
id: TASK-216
|
||||||
|
title: 'Address PR #28 CodeRabbit follow-ups on subtitle sidebar'
|
||||||
|
status: Completed
|
||||||
|
assignee:
|
||||||
|
- '@codex'
|
||||||
|
created_date: '2026-03-21 00:00'
|
||||||
|
updated_date: '2026-03-21 00:00'
|
||||||
|
labels:
|
||||||
|
- pr-review
|
||||||
|
- subtitle-sidebar
|
||||||
|
- renderer
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- src/main/runtime/subtitle-prefetch-init.ts
|
||||||
|
- src/main/runtime/subtitle-prefetch-init.test.ts
|
||||||
|
- src/renderer/handlers/mouse.ts
|
||||||
|
- src/renderer/handlers/mouse.test.ts
|
||||||
|
- src/renderer/modals/subtitle-sidebar.ts
|
||||||
|
- src/renderer/modals/subtitle-sidebar.test.ts
|
||||||
|
- src/renderer/style.css
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Validate the CodeRabbit follow-ups on PR #28 for the subtitle sidebar workstream, implement the confirmed fixes, and verify the touched runtime and renderer paths.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Review comments that described real regressions are fixed in code
|
||||||
|
- [x] #2 Focused regression coverage exists for the fixed behaviors
|
||||||
|
- [x] #3 Targeted typecheck and runtime-compat verification pass
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Completed follow-up fixes for PR #28:
|
||||||
|
- Cleared parsed subtitle cues on subtitle prefetch init failure so stale snapshot cache entries do not survive a failed refresh.
|
||||||
|
- Treated primary and secondary subtitle containers as one hover region so moving between them does not resume playback mid-transition.
|
||||||
|
- Kept the subtitle sidebar closed when disabled, serialized snapshot polling with timeouts, made cue rows keyboard-activatable, resolved stale cue selection fallback, and resumed hover-paused playback when the modal closes.
|
||||||
|
|
||||||
|
Regression coverage added:
|
||||||
|
- `src/main/runtime/subtitle-prefetch-init.test.ts`
|
||||||
|
- `src/renderer/handlers/mouse.test.ts`
|
||||||
|
- `src/renderer/modals/subtitle-sidebar.test.ts`
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `bun test src/main/runtime/subtitle-prefetch-init.test.ts`
|
||||||
|
- `bun test src/renderer/handlers/mouse.test.ts`
|
||||||
|
- `bun test src/renderer/modals/subtitle-sidebar.test.ts`
|
||||||
|
- `bun run typecheck`
|
||||||
|
- `bun run test:runtime:compat`
|
||||||
|
|
||||||
|
2026-03-21: Reopened to assess a newer CodeRabbit review pass on PR #28 and address any remaining valid action items before push/reply.
|
||||||
|
|
||||||
|
2026-03-21: Addressed the latest CodeRabbit follow-up pass in commit d70c6448 after rebasing onto the updated remote branch tip.
|
||||||
|
|
||||||
|
2026-03-21: Reopened for the latest CodeRabbit round on commit d70c6448; current actionable item is the invalid ctx.state.isOverSubtitleSidebar assignment in subtitle-sidebar.ts.
|
||||||
|
|
||||||
|
2026-03-22: Addressed the live hover-state and startup mouse-ignore follow-ups from the latest CodeRabbit pass. `handleMouseLeave()` now clears `isOverSubtitle` and drops `secondary-sub-hover-active` when leaving the secondary subtitle container toward the primary container, and renderer startup now calls `syncOverlayMouseIgnoreState(ctx)` instead of forcing `setIgnoreMouseEvents(true, { forward: true })`. The sidebar IPC hover catch and CSS spacing comments were already satisfied in the current tree.
|
||||||
|
|
||||||
|
2026-03-22: Regenerated `bun.lock` from a clean install so the `electron-builder-squirrel-windows` override now resolves at `26.8.2` in the lockfile alongside `app-builder-lib@26.8.2`.
|
||||||
|
|
||||||
|
2026-03-21: Finished the remaining cleanup pass from the latest review. `subtitleSidebar.layout` now uses enum validation, `SubtitleCue` is re-exported from `src/types.ts` as the single public type path, and the subtitle sidebar resize listener now has unload cleanup wired through the renderer.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Implemented the confirmed PR #28 CodeRabbit follow-ups for subtitle sidebar behavior and added regression coverage plus verification for the touched renderer and runtime paths.
|
||||||
|
|
||||||
|
Handled the latest CodeRabbit review pass for PR #28: accepted zero sidebar opacity, closed/inerted the sidebar when refresh sees config disabled, moved poll rescheduling out of finally, caught hover pause IPC failures, and fixed the stylelint spacing issue.
|
||||||
|
|
||||||
|
Verification: bun test src/config/resolve/subtitle-sidebar.test.ts; bun test src/renderer/modals/subtitle-sidebar.test.ts; bun test src/renderer/handlers/mouse.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; SubMiner verifier lanes config + runtime-compat (including test:runtime:compat and test:smoke:dist).
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
id: TASK-217
|
||||||
|
title: Fix embedded overlay passthrough sync between subtitle and sidebar
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-03-21 23:16'
|
||||||
|
updated_date: '2026-03-21 23:28'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- overlay
|
||||||
|
- macos
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- src/renderer/handlers/mouse.ts
|
||||||
|
- src/renderer/modals/subtitle-sidebar.ts
|
||||||
|
- src/renderer/renderer.ts
|
||||||
|
documentation:
|
||||||
|
- docs/workflow/verification.md
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
On macOS, when both the subtitle overlay and embedded subtitle sidebar are visible, mouse passthrough to mpv can remain stale until the user hovers the sidebar. After closing the sidebar, passthrough can likewise remain stale until the user hovers the subtitle again. Fix the overlay input-state synchronization so passthrough reflects the current hover/open state immediately instead of relying on the last hover target.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 When the embedded subtitle sidebar is open and the pointer is not over subtitle or sidebar content, the overlay returns to mouse passthrough immediately without requiring a sidebar hover cycle.
|
||||||
|
- [x] #2 When transitioning between subtitle hover and sidebar hover states on macOS embedded sidebar mode, mouse ignore state stays in sync with the currently interactive region.
|
||||||
|
- [x] #3 Closing the embedded subtitle sidebar restores the correct passthrough state based on remaining subtitle hover/modal state without requiring an additional hover.
|
||||||
|
- [x] #4 Regression tests cover the passthrough synchronization behavior.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add a shared renderer-side passthrough sync helper that derives whether the overlay should ignore mouse events from subtitle hover, embedded sidebar visibility/hover, popup visibility, and modal state.
|
||||||
|
2. Replace direct embedded-sidebar passthrough toggles in subtitle hover/sidebar handlers with calls to the shared sync helper so state is recomputed on every transition.
|
||||||
|
3. Add regression tests for macOS embedded sidebar mode covering sidebar-open idle passthrough, subtitle-to-sidebar transitions, and sidebar-close restore behavior.
|
||||||
|
4. Run targeted renderer tests for mouse/sidebar passthrough coverage, then summarize any residual risk.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Added shared renderer overlay mouse-ignore recompute so subtitle hover, embedded sidebar hover/open/close, and popup idle transitions all derive passthrough from current state instead of last hover target.
|
||||||
|
|
||||||
|
Added regression coverage for embedded sidebar idle passthrough on subtitle leave and for sidebar-close recompute behavior.
|
||||||
|
|
||||||
|
Verification: `bun run typecheck` passed; `bun test src/renderer/handlers/mouse.test.ts` passed; `bun test src/renderer/modals/subtitle-sidebar.test.ts` passed; core verification wrapper artifact at `.tmp/skill-verification/subminer-verify-20260321-162743-XhSBxw` hit an unrelated `bun run test:fast` failure in `scripts/update-aur-package.test.ts` because macOS system bash lacks `mapfile`.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Fixed stale embedded-sidebar passthrough sync on macOS by introducing a shared renderer mouse-ignore recompute path and tracking sidebar-hover state separately from subtitle hover. Subtitle hover leave, sidebar hover enter/leave, sidebar open, and sidebar close now all recompute passthrough from the current overlay state instead of waiting for a later hover event to repair it. Added regression tests covering subtitle-leave passthrough while the embedded sidebar is open but idle, plus sidebar-close restore behavior based on remaining subtitle hover state.
|
||||||
|
|
||||||
|
Tests run:
|
||||||
|
- `bun run typecheck`
|
||||||
|
- `bun test src/renderer/handlers/mouse.test.ts`
|
||||||
|
- `bun test src/renderer/modals/subtitle-sidebar.test.ts`
|
||||||
|
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/renderer/state.ts src/renderer/overlay-mouse-ignore.ts src/renderer/handlers/mouse.ts src/renderer/handlers/mouse.test.ts src/renderer/modals/subtitle-sidebar.ts src/renderer/modals/subtitle-sidebar.test.ts`
|
||||||
|
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/renderer/state.ts src/renderer/overlay-mouse-ignore.ts src/renderer/handlers/mouse.ts src/renderer/handlers/mouse.test.ts src/renderer/modals/subtitle-sidebar.ts src/renderer/modals/subtitle-sidebar.test.ts` (typecheck passed; `test:fast` blocked by unrelated `scripts/update-aur-package.test.ts` failure on macOS Bash 3.2 lacking `mapfile`)
|
||||||
|
|
||||||
|
Risk: the classifier flagged this as a real-runtime candidate, so actual Electron/mpv macOS pointer behavior was not exercised in a live runtime during this turn.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Known-word cache refreshes now reconcile Anki changes incrementally instead of wiping and rebuilding on startup, mined cards can append their word into the cache immediately through a new default-enabled config flag, and explicit refreshes now run through `subminer doctor --refresh-known-words`.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: subtitle
|
|
||||||
|
|
||||||
- Restored known-word coloring and JLPT underlines for subtitle tokens like `大体` when the subtitle token is kanji but the known-word cache only matches the kana reading.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Episode progress in the anime page now uses the last ended playback position instead of cumulative active watch time, avoiding distorted percentages after rewatches or repeated sessions.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Anime episode progress now keeps the latest known playback position through active-session checkpoints and stale-session recovery, so recently watched episodes no longer lose their progress percentage.
|
|
||||||
5
changes/2026-03-22-subtitle-sidebar-config.md
Normal file
5
changes/2026-03-22-subtitle-sidebar-config.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type: changed
|
||||||
|
area: subtitle sidebar
|
||||||
|
|
||||||
|
- Added subtitle sidebar state and behavior updates, including startup-auto-open controls and resume positioning improvements.
|
||||||
|
- Fixed subtitle prefetch and embedded overlay passthrough sync between sidebar and overlay subtitle rendering.
|
||||||
@@ -284,6 +284,30 @@
|
|||||||
} // Secondary setting.
|
} // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Subtitle Sidebar
|
||||||
|
// Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
||||||
|
// Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.
|
||||||
|
// ==========================================
|
||||||
|
"subtitleSidebar": {
|
||||||
|
"enabled": false, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false
|
||||||
|
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
||||||
|
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
||||||
|
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
||||||
|
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
||||||
|
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
||||||
|
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
|
||||||
|
"opacity": 0.95, // Base opacity applied to the sidebar shell.
|
||||||
|
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
|
||||||
|
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
|
||||||
|
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text.
|
||||||
|
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
|
||||||
|
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
|
||||||
|
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
|
||||||
|
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
|
||||||
|
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
|
||||||
|
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Shared AI Provider
|
// Shared AI Provider
|
||||||
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.8.0 (2026-03-22)
|
||||||
|
- Refreshed the vendored Texthooker docs/index.html bundle to match the latest local build artifacts.
|
||||||
|
- Added incremental known-word cache refresh behavior so mined cards can append cache entries immediately and `subminer doctor --refresh-known-words` is now the explicit full refresh path.
|
||||||
|
- Fixed known-word/JLPT subtitle styling so tokens like `大体` keep expected coloring even when only the kana reading is in cache.
|
||||||
|
- Fixed anime progress to use last ended playback position and keep latest known checkpoint across sessions, preventing stale or zero percent regressions.
|
||||||
|
- Kept subtitle sidebar cue tracking stable across transitions and improved sidebar configuration documentation for `layout`, `fontFamily`, and `fontSize`.
|
||||||
|
- Added `subtitleSidebar.autoOpen` to open the subtitle sidebar at startup when enabled.
|
||||||
|
- Improved sidebar resume/start behavior to jump directly to the active cue on resume while preserving auto-follow smooth motion.
|
||||||
|
|
||||||
## v0.7.0 (2026-03-19)
|
## v0.7.0 (2026-03-19)
|
||||||
- Added a full local immersion dashboard release line with Overview, Library, Trends, Vocabulary, and Sessions drill-down views backed by SQLite tracking data.
|
- Added a full local immersion dashboard release line with Overview, Library, Trends, Vocabulary, and Sessions drill-down views backed by SQLite tracking data.
|
||||||
- Added browser-first stats workflows: `subminer stats`, background stats daemon controls (`-b` / `-s`), stats cleanup, and dashboard-side mining actions with media enrichment.
|
- Added browser-first stats workflows: `subminer stats`, background stats daemon controls (`-b` / `-s`), stats cleanup, and dashboard-side mining actions with media enrichment.
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ SubMiner watches the active config file (`config.jsonc` or `config.json`) while
|
|||||||
Hot-reloadable fields:
|
Hot-reloadable fields:
|
||||||
|
|
||||||
- `subtitleStyle`
|
- `subtitleStyle`
|
||||||
|
- `subtitleSidebar`
|
||||||
- `keybindings`
|
- `keybindings`
|
||||||
- `shortcuts`
|
- `shortcuts`
|
||||||
- `secondarySub.defaultMode`
|
- `secondarySub.defaultMode`
|
||||||
@@ -88,6 +89,7 @@ The configuration file includes several main sections:
|
|||||||
**Subtitle Display**
|
**Subtitle Display**
|
||||||
|
|
||||||
- [**Subtitle Style**](#subtitle-style) - Appearance customization
|
- [**Subtitle Style**](#subtitle-style) - Appearance customization
|
||||||
|
- [**Subtitle Sidebar**](#subtitle-sidebar) - Parsed cue list sidebar modal
|
||||||
- [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning
|
- [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning
|
||||||
- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support
|
- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support
|
||||||
|
|
||||||
@@ -337,6 +339,46 @@ Secondary subtitle defaults: `fontFamily: "Inter, Noto Sans, Helvetica Neue, san
|
|||||||
|
|
||||||
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
|
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
|
||||||
|
|
||||||
|
### Subtitle Sidebar
|
||||||
|
|
||||||
|
Configure the parsed-subtitle sidebar modal.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subtitleSidebar": {
|
||||||
|
"enabled": true,
|
||||||
|
"layout": "overlay",
|
||||||
|
"toggleKey": "Backslash",
|
||||||
|
"pauseVideoOnHover": false,
|
||||||
|
"autoScroll": true,
|
||||||
|
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
||||||
|
"fontSize": 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Values | Description |
|
||||||
|
| --------------------------- | ---------------- | -------------------------------------------------------------------------------- |
|
||||||
|
| `enabled` | boolean | Enable subtitle sidebar support (`false` by default) |
|
||||||
|
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
|
||||||
|
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
|
||||||
|
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list |
|
||||||
|
| `autoScroll` | boolean | Keep the active cue in view while playback advances |
|
||||||
|
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
|
||||||
|
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.78`) |
|
||||||
|
| `backgroundColor` | string | Sidebar shell background color |
|
||||||
|
| `textColor` | hex color | Default cue text color |
|
||||||
|
| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text |
|
||||||
|
| `fontSize` | number | Base sidebar cue font size in CSS pixels (default: `16`) |
|
||||||
|
| `timestampColor` | hex color | Cue timestamp color |
|
||||||
|
| `activeLineColor` | hex color | Active cue text color |
|
||||||
|
| `activeLineBackgroundColor` | string | Active cue background color |
|
||||||
|
| `hoverLineBackgroundColor` | string | Hovered cue background color |
|
||||||
|
|
||||||
|
The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog.
|
||||||
|
|
||||||
|
`embedded` layout is intended to act like a split-pane view: it reserves player space with a right-side video margin and keeps interaction in both the player area and sidebar. If you see unexpected offset behavior in your environment, switch back to `overlay` to isolate sidebar placement.
|
||||||
|
|
||||||
`jlptColors` keys are:
|
`jlptColors` keys are:
|
||||||
|
|
||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
|
|||||||
@@ -284,6 +284,30 @@
|
|||||||
} // Secondary setting.
|
} // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Subtitle Sidebar
|
||||||
|
// Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
||||||
|
// Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.
|
||||||
|
// ==========================================
|
||||||
|
"subtitleSidebar": {
|
||||||
|
"enabled": false, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false
|
||||||
|
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
||||||
|
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
||||||
|
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
||||||
|
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
||||||
|
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
||||||
|
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
|
||||||
|
"opacity": 0.95, // Base opacity applied to the sidebar shell.
|
||||||
|
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
|
||||||
|
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
|
||||||
|
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text.
|
||||||
|
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
|
||||||
|
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
|
||||||
|
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
|
||||||
|
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
|
||||||
|
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
|
||||||
|
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Shared AI Provider
|
// Shared AI Provider
|
||||||
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
||||||
|
|||||||
@@ -68,10 +68,13 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
|||||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||||
|
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
||||||
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
||||||
|
|
||||||
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
|
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
|
||||||
|
|
||||||
|
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
|
||||||
|
|
||||||
## Controller Shortcuts
|
## Controller Shortcuts
|
||||||
|
|
||||||
These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration.
|
These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration.
|
||||||
@@ -133,4 +136,4 @@ The `keybindings` array overrides or extends the overlay's built-in key handling
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Both `shortcuts` and `keybindings` are [hot-reloadable](/configuration#hot-reload-behavior) — changes take effect without restarting SubMiner.
|
Both `shortcuts`, `keybindings`, and `subtitleSidebar` are [hot-reloadable](/configuration#hot-reload-behavior) — changes take effect without restarting SubMiner.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
4. Bump `package.json` to the release version.
|
4. Bump `package.json` to the release version.
|
||||||
5. Build release metadata before tagging:
|
5. Build release metadata before tagging:
|
||||||
`bun run changelog:build --version <version> --date <yyyy-mm-dd>`
|
`bun run changelog:build --version <version> --date <yyyy-mm-dd>`
|
||||||
|
- Release CI now also auto-runs this step when releasing directly from a tag and `changes/*.md` fragments remain.
|
||||||
6. Review `CHANGELOG.md` and `release/release-notes.md`.
|
6. Review `CHANGELOG.md` and `release/release-notes.md`.
|
||||||
7. Run release gate locally:
|
7. Run release gate locally:
|
||||||
`bun run changelog:check --version <version>`
|
`bun run changelog:check --version <version>`
|
||||||
@@ -29,6 +30,8 @@ Notes:
|
|||||||
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
||||||
- `changelog:check` now rejects tag/package version mismatches.
|
- `changelog:check` now rejects tag/package version mismatches.
|
||||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
|
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
|
||||||
|
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes.
|
||||||
- Do not tag while `changes/*.md` fragments still exist.
|
- Do not tag while `changes/*.md` fragments still exist.
|
||||||
|
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
||||||
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
||||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
<!-- read_when: changing known-word cache lifecycle, stats cache semantics, or Anki sync behavior -->
|
|
||||||
|
|
||||||
# Incremental Known-Word Cache Sync
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Stop rebuilding the entire known-word cache on startup or routine refreshes. Keep the cache correct through incremental reconciliation on the configured sync cadence, with an immediate append path for freshly mined cards.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Persist per-note extracted known-word snapshots beside the existing global `words` list.
|
|
||||||
- Replace startup refresh with load-only behavior.
|
|
||||||
- Make timed refresh diff current Anki note IDs against cached note IDs, then apply add/remove/edit deltas.
|
|
||||||
- Add `ankiConnect.knownWords.addMinedWordsImmediately`, default `true`.
|
|
||||||
- Keep full rebuild out of normal lifecycle; reserve it for explicit doctor tooling.
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
Persist versioned cache state with:
|
|
||||||
|
|
||||||
- `words`: deduplicated global known-word set for stats/UI consumers
|
|
||||||
- `notes`: record of `noteId -> extractedWords[]`
|
|
||||||
- `refreshedAtMs`
|
|
||||||
- `scope`
|
|
||||||
|
|
||||||
The in-memory manager derives the global set from the per-note snapshots during sync updates so deletes and edits can remove stale words safely.
|
|
||||||
|
|
||||||
## Sync Behavior
|
|
||||||
|
|
||||||
- Startup: load persisted state only
|
|
||||||
- Interval tick or explicit refresh command: run incremental sync
|
|
||||||
- Incremental sync:
|
|
||||||
- query tracked note IDs for configured deck scope
|
|
||||||
- remove note snapshots for note IDs that disappeared
|
|
||||||
- fetch `notesInfo` for note IDs that are new or need field reconciliation
|
|
||||||
- compare extracted words per note and update the global set
|
|
||||||
|
|
||||||
## Immediate Mining Path
|
|
||||||
|
|
||||||
When SubMiner already has fresh `noteInfo` after mining or updating a note, append/update that note snapshot immediately if `addMinedWordsImmediately` is enabled.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- focused cache manager tests for add/delete/edit reconciliation
|
|
||||||
- focused integration/config tests for startup behavior and new config flag
|
|
||||||
- config verification lane because defaults/schema/example change
|
|
||||||
@@ -48,6 +48,64 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StatsTestArgOverrides = {
|
||||||
|
stats?: boolean;
|
||||||
|
statsBackground?: boolean;
|
||||||
|
statsCleanup?: boolean;
|
||||||
|
statsCleanupVocab?: boolean;
|
||||||
|
statsCleanupLifetime?: boolean;
|
||||||
|
statsStop?: boolean;
|
||||||
|
logLevel?: LauncherCommandContext['args']['logLevel'];
|
||||||
|
};
|
||||||
|
|
||||||
|
function createStatsTestHarness(overrides: StatsTestArgOverrides = {}) {
|
||||||
|
const context = createContext();
|
||||||
|
const forwarded: string[][] = [];
|
||||||
|
const removedPaths: string[] = [];
|
||||||
|
const createTempDir = (_prefix: string) => {
|
||||||
|
const created = `/tmp/subminer-stats-test`;
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
const joinPath = (...parts: string[]) => parts.join('/');
|
||||||
|
const removeDir = (targetPath: string) => {
|
||||||
|
removedPaths.push(targetPath);
|
||||||
|
};
|
||||||
|
const runAppCommandAttachedStub = async (
|
||||||
|
_appPath: string,
|
||||||
|
appArgs: string[],
|
||||||
|
_logLevel: LauncherCommandContext['args']['logLevel'],
|
||||||
|
_label: string,
|
||||||
|
) => {
|
||||||
|
forwarded.push(appArgs);
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
const waitForStatsResponseStub = async () => ({ ok: true, url: 'http://127.0.0.1:5175' });
|
||||||
|
|
||||||
|
context.args = {
|
||||||
|
...context.args,
|
||||||
|
stats: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
forwarded,
|
||||||
|
removedPaths,
|
||||||
|
createTempDir,
|
||||||
|
joinPath,
|
||||||
|
removeDir,
|
||||||
|
runAppCommandAttachedStub,
|
||||||
|
waitForStatsResponseStub,
|
||||||
|
commandDeps: {
|
||||||
|
createTempDir,
|
||||||
|
joinPath,
|
||||||
|
runAppCommandAttached: runAppCommandAttachedStub,
|
||||||
|
waitForStatsResponse: waitForStatsResponseStub,
|
||||||
|
removeDir,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test('config command writes newline-terminated path via process adapter', () => {
|
test('config command writes newline-terminated path via process adapter', () => {
|
||||||
const writes: string[] = [];
|
const writes: string[] = [];
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
@@ -157,24 +215,11 @@ test('dictionary command throws if app handoff unexpectedly returns', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('stats command launches attached app command with response path', async () => {
|
test('stats command launches attached app command with response path', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
|
||||||
context.args.stats = true;
|
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||||
context.args.logLevel = 'debug';
|
|
||||||
const forwarded: string[][] = [];
|
|
||||||
|
|
||||||
const handled = await runStatsCommand(context, {
|
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
|
||||||
joinPath: (...parts) => parts.join('/'),
|
|
||||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
|
||||||
forwarded.push(appArgs);
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
|
||||||
removeDir: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(harness.forwarded, [
|
||||||
[
|
[
|
||||||
'--stats',
|
'--stats',
|
||||||
'--stats-response-path',
|
'--stats-response-path',
|
||||||
@@ -183,50 +228,34 @@ test('stats command launches attached app command with response path', async ()
|
|||||||
'debug',
|
'debug',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats background command launches attached daemon control command with response path', async () => {
|
test('stats background command launches attached daemon control command with response path', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true, statsBackground: true });
|
||||||
context.args.stats = true;
|
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||||
(context.args as typeof context.args & { statsBackground?: boolean }).statsBackground = true;
|
|
||||||
const forwarded: string[][] = [];
|
|
||||||
|
|
||||||
const handled = await runStatsCommand(context, {
|
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
|
||||||
joinPath: (...parts) => parts.join('/'),
|
|
||||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
|
||||||
forwarded.push(appArgs);
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
|
||||||
removeDir: () => {},
|
|
||||||
} as Parameters<typeof runStatsCommand>[1]);
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(harness.forwarded, [
|
||||||
[
|
[
|
||||||
'--stats-daemon-start',
|
'--stats-daemon-start',
|
||||||
'--stats-response-path',
|
'--stats-response-path',
|
||||||
'/tmp/subminer-stats-test/response.json',
|
'/tmp/subminer-stats-test/response.json',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats command waits for attached app exit after startup response', async () => {
|
test('stats command waits for attached app exit after startup response', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true });
|
||||||
context.args.stats = true;
|
|
||||||
const forwarded: string[][] = [];
|
|
||||||
const started = new Promise<number>((resolve) => setTimeout(() => resolve(0), 20));
|
const started = new Promise<number>((resolve) => setTimeout(() => resolve(0), 20));
|
||||||
|
|
||||||
const statsCommand = runStatsCommand(context, {
|
const statsCommand = runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
forwarded.push(appArgs);
|
|
||||||
return started;
|
return started;
|
||||||
},
|
},
|
||||||
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
const result = await Promise.race([
|
const result = await Promise.race([
|
||||||
statsCommand.then(() => 'resolved'),
|
statsCommand.then(() => 'resolved'),
|
||||||
@@ -237,53 +266,46 @@ test('stats command waits for attached app exit after startup response', async (
|
|||||||
|
|
||||||
const final = await statsCommand;
|
const final = await statsCommand;
|
||||||
assert.equal(final, true);
|
assert.equal(final, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(harness.forwarded, [
|
||||||
[
|
[
|
||||||
'--stats',
|
'--stats',
|
||||||
'--stats-response-path',
|
'--stats-response-path',
|
||||||
'/tmp/subminer-stats-test/response.json',
|
'/tmp/subminer-stats-test/response.json',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats command throws when attached app exits non-zero after startup response', async () => {
|
test('stats command throws when attached app exits non-zero after startup response', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true });
|
||||||
context.args.stats = true;
|
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await runStatsCommand(context, {
|
await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => {
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
return 3;
|
return 3;
|
||||||
},
|
},
|
||||||
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
}, /Stats app exited with status 3\./);
|
}, /Stats app exited with status 3\./);
|
||||||
|
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({
|
||||||
context.args.stats = true;
|
stats: true,
|
||||||
context.args.statsCleanup = true;
|
statsCleanup: true,
|
||||||
context.args.statsCleanupVocab = true;
|
statsCleanupVocab: true,
|
||||||
const forwarded: string[][] = [];
|
});
|
||||||
|
const handled = await runStatsCommand(harness.context, {
|
||||||
const handled = await runStatsCommand(context, {
|
...harness.commandDeps,
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
|
||||||
joinPath: (...parts) => parts.join('/'),
|
|
||||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
|
||||||
forwarded.push(appArgs);
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
waitForStatsResponse: async () => ({ ok: true }),
|
waitForStatsResponse: async () => ({ ok: true }),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(harness.forwarded, [
|
||||||
[
|
[
|
||||||
'--stats',
|
'--stats',
|
||||||
'--stats-response-path',
|
'--stats-response-path',
|
||||||
@@ -292,76 +314,62 @@ test('stats cleanup command forwards cleanup vocab flags to the app', async () =
|
|||||||
'--stats-cleanup-vocab',
|
'--stats-cleanup-vocab',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats stop command forwards stop flag to the app', async () => {
|
test('stats stop command forwards stop flag to the app', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
||||||
context.args.stats = true;
|
|
||||||
(context.args as typeof context.args & { statsStop?: boolean }).statsStop = true;
|
|
||||||
const forwarded: string[][] = [];
|
|
||||||
|
|
||||||
const handled = await runStatsCommand(context, {
|
const handled = await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
|
||||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
|
||||||
forwarded.push(appArgs);
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
waitForStatsResponse: async () => ({ ok: true }),
|
waitForStatsResponse: async () => ({ ok: true }),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(harness.forwarded, [
|
||||||
[
|
[
|
||||||
'--stats-daemon-stop',
|
'--stats-daemon-stop',
|
||||||
'--stats-response-path',
|
'--stats-response-path',
|
||||||
'/tmp/subminer-stats-test/response.json',
|
'/tmp/subminer-stats-test/response.json',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats stop command exits on process exit without waiting for startup response', async () => {
|
test('stats stop command exits on process exit without waiting for startup response', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
||||||
context.args.stats = true;
|
|
||||||
(context.args as typeof context.args & { statsStop?: boolean }).statsStop = true;
|
|
||||||
let waitedForResponse = false;
|
let waitedForResponse = false;
|
||||||
|
|
||||||
const handled = await runStatsCommand(context, {
|
const handled = await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => 0,
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
waitForStatsResponse: async () => {
|
waitForStatsResponse: async () => {
|
||||||
waitedForResponse = true;
|
waitedForResponse = true;
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.equal(waitedForResponse, false);
|
assert.equal(waitedForResponse, false);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
|
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({
|
||||||
context.args.stats = true;
|
stats: true,
|
||||||
context.args.statsCleanup = true;
|
statsCleanup: true,
|
||||||
context.args.statsCleanupLifetime = true;
|
statsCleanupLifetime: true,
|
||||||
const forwarded: string[][] = [];
|
});
|
||||||
|
const handled = await runStatsCommand(harness.context, {
|
||||||
const handled = await runStatsCommand(context, {
|
...harness.commandDeps,
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
|
||||||
joinPath: (...parts) => parts.join('/'),
|
|
||||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
|
||||||
forwarded.push(appArgs);
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
waitForStatsResponse: async () => ({ ok: true }),
|
waitForStatsResponse: async () => ({ ok: true }),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [
|
assert.deepEqual(harness.forwarded, [
|
||||||
[
|
[
|
||||||
'--stats',
|
'--stats',
|
||||||
'--stats-response-path',
|
'--stats-response-path',
|
||||||
@@ -370,56 +378,64 @@ test('stats cleanup command forwards lifetime rebuild flag to the app', async ()
|
|||||||
'--stats-cleanup-lifetime',
|
'--stats-cleanup-lifetime',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats command throws when stats response reports an error', async () => {
|
test('stats command throws when stats response reports an error', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true });
|
||||||
context.args.stats = true;
|
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await runStatsCommand(context, {
|
await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => 0,
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
waitForStatsResponse: async () => ({
|
waitForStatsResponse: async () => ({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'Immersion tracking is disabled in config.',
|
error: 'Immersion tracking is disabled in config.',
|
||||||
}),
|
}),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
}, /Immersion tracking is disabled in config\./);
|
}, /Immersion tracking is disabled in config\./);
|
||||||
|
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats cleanup command fails if attached app exits before startup response', async () => {
|
test('stats cleanup command fails if attached app exits before startup response', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({
|
||||||
context.args.stats = true;
|
stats: true,
|
||||||
context.args.statsCleanup = true;
|
statsCleanup: true,
|
||||||
context.args.statsCleanupVocab = true;
|
statsCleanupVocab: true,
|
||||||
|
});
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await runStatsCommand(context, {
|
await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => 2,
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
|
return 2;
|
||||||
|
},
|
||||||
waitForStatsResponse: async () => {
|
waitForStatsResponse: async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
return { ok: true, url: 'http://127.0.0.1:5175' };
|
return { ok: true, url: 'http://127.0.0.1:5175' };
|
||||||
},
|
},
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
}, /Stats app exited before startup response \(status 2\)\./);
|
}, /Stats app exited before startup response \(status 2\)\./);
|
||||||
|
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats command aborts pending response wait when app exits before startup response', async () => {
|
test('stats command aborts pending response wait when app exits before startup response', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true });
|
||||||
context.args.stats = true;
|
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await runStatsCommand(context, {
|
await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => 2,
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
|
return 2;
|
||||||
|
},
|
||||||
waitForStatsResponse: async (_responsePath, signal) =>
|
waitForStatsResponse: async (_responsePath, signal) =>
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
signal?.addEventListener(
|
signal?.addEventListener(
|
||||||
@@ -431,25 +447,24 @@ test('stats command aborts pending response wait when app exits before startup r
|
|||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
}, /Stats app exited before startup response \(status 2\)\./);
|
}, /Stats app exited before startup response \(status 2\)\./);
|
||||||
|
|
||||||
assert.equal(aborted, true);
|
assert.equal(aborted, true);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats command aborts pending response wait when attached app fails to spawn', async () => {
|
test('stats command aborts pending response wait when attached app fails to spawn', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({ stats: true });
|
||||||
context.args.stats = true;
|
|
||||||
const spawnError = new Error('spawn failed');
|
const spawnError = new Error('spawn failed');
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
async () => {
|
async () => {
|
||||||
await runStatsCommand(context, {
|
await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => {
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
throw spawnError;
|
throw spawnError;
|
||||||
},
|
},
|
||||||
waitForStatsResponse: async (_responsePath, signal) =>
|
waitForStatsResponse: async (_responsePath, signal) =>
|
||||||
@@ -463,27 +478,30 @@ test('stats command aborts pending response wait when attached app fails to spaw
|
|||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
(error: unknown) => error === spawnError,
|
(error: unknown) => error === spawnError,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(aborted, true);
|
assert.equal(aborted, true);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats cleanup command aborts pending response wait when app exits before startup response', async () => {
|
test('stats cleanup command aborts pending response wait when app exits before startup response', async () => {
|
||||||
const context = createContext();
|
const harness = createStatsTestHarness({
|
||||||
context.args.stats = true;
|
stats: true,
|
||||||
context.args.statsCleanup = true;
|
statsCleanup: true,
|
||||||
context.args.statsCleanupVocab = true;
|
statsCleanupVocab: true,
|
||||||
|
});
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await runStatsCommand(context, {
|
await runStatsCommand(harness.context, {
|
||||||
createTempDir: () => '/tmp/subminer-stats-test',
|
...harness.commandDeps,
|
||||||
joinPath: (...parts) => parts.join('/'),
|
runAppCommandAttached: async (...args) => {
|
||||||
runAppCommandAttached: async () => 2,
|
await harness.runAppCommandAttachedStub(...args);
|
||||||
|
return 2;
|
||||||
|
},
|
||||||
waitForStatsResponse: async (_responsePath, signal) =>
|
waitForStatsResponse: async (_responsePath, signal) =>
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
signal?.addEventListener(
|
signal?.addEventListener(
|
||||||
@@ -495,9 +513,9 @@ test('stats cleanup command aborts pending response wait when app exits before s
|
|||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
removeDir: () => {},
|
|
||||||
});
|
});
|
||||||
}, /Stats app exited before startup response \(status 2\)\./);
|
}, /Stats app exited before startup response \(status 2\)\./);
|
||||||
|
|
||||||
assert.equal(aborted, true);
|
assert.equal(aborted, true);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
31
package.json
31
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -77,6 +77,12 @@
|
|||||||
"build:win": "bun run build && electron-builder --win nsis zip --publish never",
|
"build:win": "bun run build && electron-builder --win nsis zip --publish never",
|
||||||
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
|
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"app-builder-lib": "26.8.2",
|
||||||
|
"electron-builder-squirrel-windows": "26.8.2",
|
||||||
|
"minimatch": "10.2.3",
|
||||||
|
"tar": "7.5.11"
|
||||||
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"anki",
|
"anki",
|
||||||
"ankiconnect",
|
"ankiconnect",
|
||||||
@@ -105,7 +111,7 @@
|
|||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "^37.10.3",
|
"electron": "^37.10.3",
|
||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "26.8.2",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
@@ -159,12 +165,21 @@
|
|||||||
"include": "build/installer.nsh"
|
"include": "build/installer.nsh"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"**/*",
|
||||||
"stats/dist/**/*",
|
"!src{,/**/*}",
|
||||||
"vendor/texthooker-ui/docs/**/*",
|
"!launcher{,/**/*}",
|
||||||
"vendor/texthooker-ui/package.json",
|
"!stats/src{,/**/*}",
|
||||||
"package.json",
|
"!stats/index.html",
|
||||||
"scripts/get-mpv-window-macos.swift"
|
"!docs-site{,/**/*}",
|
||||||
|
"!changes{,/**/*}",
|
||||||
|
"!backlog{,/**/*}",
|
||||||
|
"!.tmp{,/**/*}",
|
||||||
|
"!release-*{,/**/*}",
|
||||||
|
"!vendor/subminer-yomitan{,/**/*}",
|
||||||
|
"!vendor/texthooker-ui/src{,/**/*}",
|
||||||
|
"!vendor/texthooker-ui/node_modules{,/**/*}",
|
||||||
|
"!vendor/texthooker-ui/.svelte-kit{,/**/*}",
|
||||||
|
"!vendor/texthooker-ui/package-lock.json"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -372,12 +372,9 @@ function M.create(ctx)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
launch_overlay_with_retry(1)
|
||||||
if texthooker_enabled then
|
if texthooker_enabled then
|
||||||
ensure_texthooker_running(function()
|
ensure_texthooker_running(function() end)
|
||||||
launch_overlay_with_retry(1)
|
|
||||||
end)
|
|
||||||
else
|
|
||||||
launch_overlay_with_retry(1)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -481,31 +478,33 @@ function M.create(ctx)
|
|||||||
state.texthooker_running = false
|
state.texthooker_running = false
|
||||||
disarm_auto_play_ready_gate()
|
disarm_auto_play_ready_gate()
|
||||||
|
|
||||||
ensure_texthooker_running(function()
|
local start_args = build_command_args("start")
|
||||||
local start_args = build_command_args("start")
|
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
|
||||||
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
|
|
||||||
|
|
||||||
state.overlay_running = true
|
state.overlay_running = true
|
||||||
mp.command_native_async({
|
mp.command_native_async({
|
||||||
name = "subprocess",
|
name = "subprocess",
|
||||||
args = start_args,
|
args = start_args,
|
||||||
playback_only = false,
|
playback_only = false,
|
||||||
capture_stdout = true,
|
capture_stdout = true,
|
||||||
capture_stderr = true,
|
capture_stderr = true,
|
||||||
}, function(success, result, error)
|
}, function(success, result, error)
|
||||||
if not success or (result and result.status ~= 0) then
|
if not success or (result and result.status ~= 0) then
|
||||||
state.overlay_running = false
|
state.overlay_running = false
|
||||||
subminer_log(
|
subminer_log(
|
||||||
"error",
|
"error",
|
||||||
"process",
|
"process",
|
||||||
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
|
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
|
||||||
)
|
)
|
||||||
show_osd("Restart failed")
|
show_osd("Restart failed")
|
||||||
else
|
else
|
||||||
show_osd("Restarted successfully")
|
show_osd("Restarted successfully")
|
||||||
end
|
end
|
||||||
end)
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
if opts.texthooker_enabled then
|
||||||
|
ensure_texthooker_running(function() end)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,43 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('writeChangelogArtifacts skips changelog prepend when release section already exists', async () => {
|
||||||
|
const { writeChangelogArtifacts } = await loadModule();
|
||||||
|
const workspace = createWorkspace('write-artifacts-existing-version');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
const existingChangelog = [
|
||||||
|
'# Changelog',
|
||||||
|
'',
|
||||||
|
'## v0.4.1 (2026-03-07)',
|
||||||
|
'### Added',
|
||||||
|
'- Existing release bullet.',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
fs.mkdirSync(projectRoot, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
|
||||||
|
fs.writeFileSync(path.join(projectRoot, 'changes', '001.md'), ['type: added', 'area: overlay', '', '- Stale release fragment.'].join('\n'), 'utf8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = writeChangelogArtifacts({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.4.1',
|
||||||
|
date: '2026-03-08',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result.deletedFragmentPaths, [path.join(projectRoot, 'changes', '001.md')]);
|
||||||
|
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), false);
|
||||||
|
|
||||||
|
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
|
||||||
|
assert.equal(changelog, existingChangelog);
|
||||||
|
const releaseNotes = fs.readFileSync(path.join(projectRoot, 'release', 'release-notes.md'), 'utf8');
|
||||||
|
assert.match(releaseNotes, /## Highlights\n### Added\n- Existing release bullet\./);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('verifyChangelogReadyForRelease ignores README but rejects pending fragments and missing version sections', async () => {
|
test('verifyChangelogReadyForRelease ignores README but rejects pending fragments and missing version sections', async () => {
|
||||||
const { verifyChangelogReadyForRelease } = await loadModule();
|
const { verifyChangelogReadyForRelease } = await loadModule();
|
||||||
const workspace = createWorkspace('verify-release');
|
const workspace = createWorkspace('verify-release');
|
||||||
|
|||||||
@@ -341,12 +341,34 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
|||||||
const version = resolveVersion(options ?? {});
|
const version = resolveVersion(options ?? {});
|
||||||
const date = resolveDate(options?.date);
|
const date = resolveDate(options?.date);
|
||||||
const fragments = readChangeFragments(cwd, options?.deps);
|
const fragments = readChangeFragments(cwd, options?.deps);
|
||||||
const releaseSection = buildReleaseSection(version, date, fragments);
|
|
||||||
const existingChangelogPath = path.join(cwd, 'CHANGELOG.md');
|
const existingChangelogPath = path.join(cwd, 'CHANGELOG.md');
|
||||||
const existingChangelog = existsSync(existingChangelogPath)
|
const existingChangelog = existsSync(existingChangelogPath)
|
||||||
? readFileSync(existingChangelogPath, 'utf8')
|
? readFileSync(existingChangelogPath, 'utf8')
|
||||||
: '';
|
: '';
|
||||||
const outputPaths = resolveChangelogOutputPaths({ cwd });
|
const outputPaths = resolveChangelogOutputPaths({ cwd });
|
||||||
|
const existingReleaseSection = extractReleaseSectionBody(existingChangelog, version);
|
||||||
|
if (existingReleaseSection !== null) {
|
||||||
|
log(`Existing section found for v${version}; skipping changelog prepend.`);
|
||||||
|
for (const fragment of fragments) {
|
||||||
|
rmSync(fragment.path);
|
||||||
|
log(`Removed ${fragment.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseNotesPath = writeReleaseNotesFile(
|
||||||
|
cwd,
|
||||||
|
existingReleaseSection,
|
||||||
|
options?.deps,
|
||||||
|
);
|
||||||
|
log(`Generated ${releaseNotesPath}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletedFragmentPaths: fragments.map((fragment) => fragment.path),
|
||||||
|
outputPaths,
|
||||||
|
releaseNotesPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseSection = buildReleaseSection(version, date, fragments);
|
||||||
const nextChangelog = prependReleaseSection(existingChangelog, releaseSection, version);
|
const nextChangelog = prependReleaseSection(existingChangelog, releaseSection, version);
|
||||||
|
|
||||||
for (const outputPath of outputPaths) {
|
for (const outputPath of outputPaths) {
|
||||||
|
|||||||
157
scripts/patch-modernz.sh
Executable file
157
scripts/patch-modernz.sh
Executable file
@@ -0,0 +1,157 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TARGET="${HOME}/.config/mpv/scripts/modernz.lua"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: patch-modernz.sh [--target /path/to/modernz.lua]
|
||||||
|
|
||||||
|
Applies the local ModernZ OSC sidebar-resize patch to an existing modernz.lua.
|
||||||
|
If the target file does not exist, the script exits without changing anything.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--target)
|
||||||
|
if [[ $# -lt 2 || -z "${2:-}" || "$2" == -* ]]; then
|
||||||
|
echo "patch-modernz: --target requires a non-empty file path" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TARGET="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! -f "$TARGET" ]]; then
|
||||||
|
echo "patch-modernz: target missing, skipped: $TARGET"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q 'get_external_video_margin_ratio' "$TARGET" \
|
||||||
|
&& grep -q 'observe_cached("video-margin-ratio-right"' "$TARGET"; then
|
||||||
|
echo "patch-modernz: already patched: $TARGET"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! patch --forward --quiet "$TARGET" <<'PATCH'
|
||||||
|
--- a/modernz.lua
|
||||||
|
+++ b/modernz.lua
|
||||||
|
@@ -931,6 +931,26 @@ local function reset_margins()
|
||||||
|
set_margin_offset("osd-margin-y", 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
+local function get_external_video_margin_ratio(prop)
|
||||||
|
+ local value = mp.get_property_number(prop, 0) or 0
|
||||||
|
+ if value < 0 then return 0 end
|
||||||
|
+ if value > 0.95 then return 0.95 end
|
||||||
|
+ return value
|
||||||
|
+end
|
||||||
|
+
|
||||||
|
+local function get_layout_horizontal_bounds()
|
||||||
|
+ local margin_l = get_external_video_margin_ratio("video-margin-ratio-left")
|
||||||
|
+ local margin_r = get_external_video_margin_ratio("video-margin-ratio-right")
|
||||||
|
+ local width_ratio = math.max(0.05, 1 - margin_l - margin_r)
|
||||||
|
+ local pos_x = osc_param.playresx * margin_l
|
||||||
|
+ local width = osc_param.playresx * width_ratio
|
||||||
|
+
|
||||||
|
+ osc_param.video_margins.l = margin_l
|
||||||
|
+ osc_param.video_margins.r = margin_r
|
||||||
|
+
|
||||||
|
+ return pos_x, width
|
||||||
|
+end
|
||||||
|
+
|
||||||
|
local function update_margins()
|
||||||
|
local use_margins = get_hidetimeout() < 0 or user_opts.dynamic_margins
|
||||||
|
local top_vis = state.wc_visible
|
||||||
|
@@ -1965,8 +1985,9 @@ layouts["modern"] = function ()
|
||||||
|
local chapter_index = user_opts.show_chapter_title and mp.get_property_number("chapter", -1) >= 0
|
||||||
|
local osc_height_offset = (no_title and user_opts.notitle_osc_h_offset or 0) + ((no_chapter or not chapter_index) and user_opts.nochapter_osc_h_offset or 0)
|
||||||
|
|
||||||
|
+ local posX, layout_width = get_layout_horizontal_bounds()
|
||||||
|
local osc_geo = {
|
||||||
|
- w = osc_param.playresx,
|
||||||
|
+ w = layout_width,
|
||||||
|
h = user_opts.osc_height - osc_height_offset
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -1974,7 +1995,6 @@ layouts["modern"] = function ()
|
||||||
|
osc_param.video_margins.b = math.max(user_opts.osc_height, user_opts.fade_alpha) / osc_param.playresy
|
||||||
|
|
||||||
|
-- origin of the controllers, left/bottom corner
|
||||||
|
- local posX = 0
|
||||||
|
local posY = osc_param.playresy
|
||||||
|
|
||||||
|
osc_param.areas = {} -- delete areas
|
||||||
|
@@ -2191,8 +2211,9 @@ layouts["modern-compact"] = function ()
|
||||||
|
((user_opts.title_mbtn_left_command == "" and user_opts.title_mbtn_right_command == "") and 25 or 0) +
|
||||||
|
(((user_opts.chapter_title_mbtn_left_command == "" and user_opts.chapter_title_mbtn_right_command == "") or not chapter_index) and 10 or 0)
|
||||||
|
|
||||||
|
+ local posX, layout_width = get_layout_horizontal_bounds()
|
||||||
|
local osc_geo = {
|
||||||
|
- w = osc_param.playresx,
|
||||||
|
+ w = layout_width,
|
||||||
|
h = 145 - osc_height_offset
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -2200,7 +2221,6 @@ layouts["modern-compact"] = function ()
|
||||||
|
osc_param.video_margins.b = math.max(osc_geo.h, user_opts.fade_alpha) / osc_param.playresy
|
||||||
|
|
||||||
|
-- origin of the controllers, left/bottom corner
|
||||||
|
- local posX = 0
|
||||||
|
local posY = osc_param.playresy
|
||||||
|
|
||||||
|
osc_param.areas = {} -- delete areas
|
||||||
|
@@ -2370,8 +2390,9 @@ layouts["modern-compact"] = function ()
|
||||||
|
end
|
||||||
|
|
||||||
|
layouts["modern-image"] = function ()
|
||||||
|
+ local posX, layout_width = get_layout_horizontal_bounds()
|
||||||
|
local osc_geo = {
|
||||||
|
- w = osc_param.playresx,
|
||||||
|
+ w = layout_width,
|
||||||
|
h = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -2379,7 +2400,6 @@ layouts["modern-image"] = function ()
|
||||||
|
osc_param.video_margins.b = math.max(50, user_opts.fade_alpha) / osc_param.playresy
|
||||||
|
|
||||||
|
-- origin of the controllers, left/bottom corner
|
||||||
|
- local posX = 0
|
||||||
|
local posY = osc_param.playresy
|
||||||
|
|
||||||
|
osc_param.areas = {} -- delete areas
|
||||||
|
@@ -3718,6 +3738,14 @@ observe_cached("border", request_init_resize)
|
||||||
|
observe_cached("title-bar", request_init_resize)
|
||||||
|
observe_cached("window-maximized", request_init_resize)
|
||||||
|
observe_cached("idle-active", request_tick)
|
||||||
|
+observe_cached("video-margin-ratio-left", function ()
|
||||||
|
+ state.marginsREQ = true
|
||||||
|
+ request_init_resize()
|
||||||
|
+end)
|
||||||
|
+observe_cached("video-margin-ratio-right", function ()
|
||||||
|
+ state.marginsREQ = true
|
||||||
|
+ request_init_resize()
|
||||||
|
+end)
|
||||||
|
mp.observe_property("user-data/mpv/console/open", "bool", function(_, val)
|
||||||
|
if val and user_opts.visibility == "auto" and not user_opts.showonselect then
|
||||||
|
osc_visible(false)
|
||||||
|
PATCH
|
||||||
|
then
|
||||||
|
echo "patch-modernz: failed to apply patch to $TARGET" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "patch-modernz: patched $TARGET"
|
||||||
76
scripts/patch-modernz.test.ts
Normal file
76
scripts/patch-modernz.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 { spawnSync } from 'node:child_process';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-patch-modernz-test-'));
|
||||||
|
try {
|
||||||
|
return fn(dir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeExecutable(filePath: string, contents: string): void {
|
||||||
|
fs.writeFileSync(filePath, contents, 'utf8');
|
||||||
|
fs.chmodSync(filePath, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('patch-modernz rejects a missing --target value', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const result = spawnSync('bash', ['scripts/patch-modernz.sh', '--target'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: path.join(root, 'home'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||||
|
assert.match(result.stderr, /--target requires a non-empty file path/);
|
||||||
|
assert.match(result.stderr, /Usage: patch-modernz\.sh/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('patch-modernz reports patch failures explicitly', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
const target = path.join(root, 'modernz.lua');
|
||||||
|
const patchLog = path.join(root, 'patch.log');
|
||||||
|
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.writeFileSync(target, 'original', 'utf8');
|
||||||
|
|
||||||
|
writeExecutable(
|
||||||
|
path.join(binDir, 'patch'),
|
||||||
|
`#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cat > "${patchLog}"
|
||||||
|
exit 1
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
'bash',
|
||||||
|
['scripts/patch-modernz.sh', '--target', target],
|
||||||
|
{
|
||||||
|
cwd: process.cwd(),
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: path.join(root, 'home'),
|
||||||
|
PATH: `${binDir}:${process.env.PATH || ''}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||||
|
assert.match(result.stderr, /failed to apply patch to/);
|
||||||
|
assert.equal(fs.readFileSync(patchLog, 'utf8').includes('modernz.lua'), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -344,6 +344,27 @@ local function count_start_calls(async_calls)
|
|||||||
return count
|
return count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function find_texthooker_call(async_calls)
|
||||||
|
for _, call in ipairs(async_calls) do
|
||||||
|
local args = call.args or {}
|
||||||
|
for i = 1, #args do
|
||||||
|
if args[i] == "--texthooker" then
|
||||||
|
return call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_call_index(async_calls, target_call)
|
||||||
|
for index, call in ipairs(async_calls) do
|
||||||
|
if call == target_call then
|
||||||
|
return index
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
local function find_control_call(async_calls, flag)
|
local function find_control_call(async_calls, flag)
|
||||||
for _, call in ipairs(async_calls) do
|
for _, call in ipairs(async_calls) do
|
||||||
local args = call.args or {}
|
local args = call.args or {}
|
||||||
@@ -643,6 +664,8 @@ do
|
|||||||
fire_event(recorded, "file-loaded")
|
fire_event(recorded, "file-loaded")
|
||||||
local start_call = find_start_call(recorded.async_calls)
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
||||||
|
local texthooker_call = find_texthooker_call(recorded.async_calls)
|
||||||
|
assert_true(texthooker_call ~= nil, "auto-start should issue texthooker helper command when enabled")
|
||||||
assert_true(
|
assert_true(
|
||||||
call_has_arg(start_call, "--show-visible-overlay"),
|
call_has_arg(start_call, "--show-visible-overlay"),
|
||||||
"auto-start with visible overlay enabled should include --show-visible-overlay on --start"
|
"auto-start with visible overlay enabled should include --show-visible-overlay on --start"
|
||||||
@@ -655,6 +678,10 @@ do
|
|||||||
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
|
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
|
||||||
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command"
|
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command"
|
||||||
)
|
)
|
||||||
|
assert_true(
|
||||||
|
find_call_index(recorded.async_calls, start_call) < find_call_index(recorded.async_calls, texthooker_call),
|
||||||
|
"auto-start should launch --start before separate --texthooker helper startup"
|
||||||
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
not has_property_set(recorded.property_sets, "pause", true),
|
not has_property_set(recorded.property_sets, "pause", true),
|
||||||
"auto-start visible overlay should not force pause without explicit pause-until-ready option"
|
"auto-start visible overlay should not force pause without explicit pause-until-ready option"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const {
|
|||||||
} = CORE_DEFAULT_CONFIG;
|
} = CORE_DEFAULT_CONFIG;
|
||||||
const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||||
INTEGRATIONS_DEFAULT_CONFIG;
|
INTEGRATIONS_DEFAULT_CONFIG;
|
||||||
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
|
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
|
||||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||||
const { stats } = STATS_DEFAULT_CONFIG;
|
const { stats } = STATS_DEFAULT_CONFIG;
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
subsync,
|
subsync,
|
||||||
startupWarmups,
|
startupWarmups,
|
||||||
subtitleStyle,
|
subtitleStyle,
|
||||||
|
subtitleSidebar,
|
||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
jimaku,
|
jimaku,
|
||||||
anilist,
|
anilist,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ResolvedConfig } from '../../types';
|
import { ResolvedConfig } from '../../types';
|
||||||
|
|
||||||
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
|
||||||
subtitleStyle: {
|
subtitleStyle: {
|
||||||
enableJlpt: false,
|
enableJlpt: false,
|
||||||
preserveLineBreaks: false,
|
preserveLineBreaks: false,
|
||||||
@@ -57,4 +57,22 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
|||||||
fontStyle: 'normal',
|
fontStyle: 'normal',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
subtitleSidebar: {
|
||||||
|
enabled: false,
|
||||||
|
autoOpen: false,
|
||||||
|
layout: 'overlay',
|
||||||
|
toggleKey: 'Backslash',
|
||||||
|
pauseVideoOnHover: false,
|
||||||
|
autoScroll: true,
|
||||||
|
maxWidth: 420,
|
||||||
|
opacity: 0.95,
|
||||||
|
backgroundColor: 'rgba(73, 77, 100, 0.9)',
|
||||||
|
textColor: '#cad3f5',
|
||||||
|
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
|
||||||
|
fontSize: 16,
|
||||||
|
timestampColor: '#a5adcb',
|
||||||
|
activeLineColor: '#f5bde6',
|
||||||
|
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||||
|
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -110,5 +110,102 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Five colors used for rank bands when mode is `banded` (from most common to least within topX).',
|
'Five colors used for rank bands when mode is `banded` (from most common to least within topX).',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.enabled',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.enabled,
|
||||||
|
description: 'Enable the subtitle sidebar feature for parsed subtitle sources.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.autoOpen',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.autoOpen,
|
||||||
|
description: 'Automatically open the subtitle sidebar once during overlay startup.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.layout',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['overlay', 'embedded'],
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.layout,
|
||||||
|
description: 'Render the subtitle sidebar as a floating overlay or reserve space inside mpv.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.toggleKey',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.toggleKey,
|
||||||
|
description: 'KeyboardEvent.code used to toggle the subtitle sidebar open and closed.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.pauseVideoOnHover',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.pauseVideoOnHover,
|
||||||
|
description: 'Pause mpv while hovering the subtitle sidebar, then resume on leave.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.autoScroll',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.autoScroll,
|
||||||
|
description: 'Auto-scroll the active subtitle cue into view while playback advances.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.maxWidth',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.maxWidth,
|
||||||
|
description: 'Maximum sidebar width in CSS pixels.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.opacity',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.opacity,
|
||||||
|
description: 'Base opacity applied to the sidebar shell.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.backgroundColor',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.backgroundColor,
|
||||||
|
description: 'Background color for the subtitle sidebar shell.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.textColor',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.textColor,
|
||||||
|
description: 'Default cue text color in the subtitle sidebar.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.fontFamily',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.fontFamily,
|
||||||
|
description: 'Font family used for subtitle sidebar cue text.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.fontSize',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.fontSize,
|
||||||
|
description: 'Base font size for subtitle sidebar cue text in CSS pixels.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.timestampColor',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.timestampColor,
|
||||||
|
description: 'Timestamp color in the subtitle sidebar.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.activeLineColor',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.activeLineColor,
|
||||||
|
description: 'Text color for the active subtitle cue.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.activeLineBackgroundColor',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.activeLineBackgroundColor,
|
||||||
|
description: 'Background color for the active subtitle cue.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.hoverLineBackgroundColor',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.hoverLineBackgroundColor,
|
||||||
|
description: 'Background color for hovered subtitle cues.',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ const SUBTITLE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'],
|
notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'],
|
||||||
key: 'subtitleStyle',
|
key: 'subtitleStyle',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Subtitle Sidebar',
|
||||||
|
description: ['Parsed-subtitle sidebar cue list styling, behavior, and toggle key.'],
|
||||||
|
notes: ['Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.'],
|
||||||
|
key: 'subtitleSidebar',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||||
|
|||||||
@@ -15,6 +15,22 @@ export function asBoolean(value: unknown): boolean | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||||
|
const cssColorKeywords = new Set([
|
||||||
|
'transparent',
|
||||||
|
'currentcolor',
|
||||||
|
'inherit',
|
||||||
|
'initial',
|
||||||
|
'unset',
|
||||||
|
'revert',
|
||||||
|
'revert-layer',
|
||||||
|
]);
|
||||||
|
const cssColorFunctionPattern = /^(?:rgba?|hsla?)\(\s*[^()]+?\s*\)$/i;
|
||||||
|
|
||||||
|
function supportsCssColor(text: string): boolean {
|
||||||
|
const css = (globalThis as { CSS?: { supports?: (property: string, value: string) => boolean } })
|
||||||
|
.CSS;
|
||||||
|
return css?.supports?.('color', text) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
export function asColor(value: unknown): string | undefined {
|
export function asColor(value: unknown): string | undefined {
|
||||||
if (typeof value !== 'string') return undefined;
|
if (typeof value !== 'string') return undefined;
|
||||||
@@ -22,6 +38,30 @@ export function asColor(value: unknown): string | undefined {
|
|||||||
return hexColorPattern.test(text) ? text : undefined;
|
return hexColorPattern.test(text) ? text : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function asCssColor(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
|
||||||
|
const text = value.trim();
|
||||||
|
if (text.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportsCssColor(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = text.toLowerCase();
|
||||||
|
if (
|
||||||
|
hexColorPattern.test(text) ||
|
||||||
|
cssColorKeywords.has(normalized) ||
|
||||||
|
cssColorFunctionPattern.test(text)
|
||||||
|
) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function asFrequencyBandedColors(
|
export function asFrequencyBandedColors(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): [string, string, string, string, string] | undefined {
|
): [string, string, string, string, string] | undefined {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ResolveContext } from './context';
|
|||||||
import {
|
import {
|
||||||
asBoolean,
|
asBoolean,
|
||||||
asColor,
|
asColor,
|
||||||
|
asCssColor,
|
||||||
asFrequencyBandedColors,
|
asFrequencyBandedColors,
|
||||||
asNumber,
|
asNumber,
|
||||||
asString,
|
asString,
|
||||||
@@ -418,4 +419,180 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isObject(src.subtitleSidebar)) {
|
||||||
|
const fallback = { ...resolved.subtitleSidebar };
|
||||||
|
resolved.subtitleSidebar = {
|
||||||
|
...resolved.subtitleSidebar,
|
||||||
|
...(src.subtitleSidebar as ResolvedConfig['subtitleSidebar']),
|
||||||
|
};
|
||||||
|
|
||||||
|
const enabled = asBoolean((src.subtitleSidebar as { enabled?: unknown }).enabled);
|
||||||
|
if (enabled !== undefined) {
|
||||||
|
resolved.subtitleSidebar.enabled = enabled;
|
||||||
|
} else if ((src.subtitleSidebar as { enabled?: unknown }).enabled !== undefined) {
|
||||||
|
resolved.subtitleSidebar.enabled = fallback.enabled;
|
||||||
|
warn(
|
||||||
|
'subtitleSidebar.enabled',
|
||||||
|
(src.subtitleSidebar as { enabled?: unknown }).enabled,
|
||||||
|
resolved.subtitleSidebar.enabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoOpen = asBoolean((src.subtitleSidebar as { autoOpen?: unknown }).autoOpen);
|
||||||
|
if (autoOpen !== undefined) {
|
||||||
|
resolved.subtitleSidebar.autoOpen = autoOpen;
|
||||||
|
} else if ((src.subtitleSidebar as { autoOpen?: unknown }).autoOpen !== undefined) {
|
||||||
|
resolved.subtitleSidebar.autoOpen = fallback.autoOpen;
|
||||||
|
warn(
|
||||||
|
'subtitleSidebar.autoOpen',
|
||||||
|
(src.subtitleSidebar as { autoOpen?: unknown }).autoOpen,
|
||||||
|
resolved.subtitleSidebar.autoOpen,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = asString((src.subtitleSidebar as { layout?: unknown }).layout);
|
||||||
|
if (layout === 'overlay' || layout === 'embedded') {
|
||||||
|
resolved.subtitleSidebar.layout = layout;
|
||||||
|
} else if ((src.subtitleSidebar as { layout?: unknown }).layout !== undefined) {
|
||||||
|
resolved.subtitleSidebar.layout = fallback.layout;
|
||||||
|
warn(
|
||||||
|
'subtitleSidebar.layout',
|
||||||
|
(src.subtitleSidebar as { layout?: unknown }).layout,
|
||||||
|
resolved.subtitleSidebar.layout,
|
||||||
|
'Expected "overlay" or "embedded".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pauseVideoOnHover = asBoolean(
|
||||||
|
(src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover,
|
||||||
|
);
|
||||||
|
if (pauseVideoOnHover !== undefined) {
|
||||||
|
resolved.subtitleSidebar.pauseVideoOnHover = pauseVideoOnHover;
|
||||||
|
} else if ((src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover !== undefined) {
|
||||||
|
resolved.subtitleSidebar.pauseVideoOnHover = fallback.pauseVideoOnHover;
|
||||||
|
warn(
|
||||||
|
'subtitleSidebar.pauseVideoOnHover',
|
||||||
|
(src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover,
|
||||||
|
resolved.subtitleSidebar.pauseVideoOnHover,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoScroll = asBoolean((src.subtitleSidebar as { autoScroll?: unknown }).autoScroll);
|
||||||
|
if (autoScroll !== undefined) {
|
||||||
|
resolved.subtitleSidebar.autoScroll = autoScroll;
|
||||||
|
} else if ((src.subtitleSidebar as { autoScroll?: unknown }).autoScroll !== undefined) {
|
||||||
|
resolved.subtitleSidebar.autoScroll = fallback.autoScroll;
|
||||||
|
warn(
|
||||||
|
'subtitleSidebar.autoScroll',
|
||||||
|
(src.subtitleSidebar as { autoScroll?: unknown }).autoScroll,
|
||||||
|
resolved.subtitleSidebar.autoScroll,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleKey = asString((src.subtitleSidebar as { toggleKey?: unknown }).toggleKey);
|
||||||
|
if (toggleKey !== undefined) {
|
||||||
|
resolved.subtitleSidebar.toggleKey = toggleKey;
|
||||||
|
} else if ((src.subtitleSidebar as { toggleKey?: unknown }).toggleKey !== undefined) {
|
||||||
|
resolved.subtitleSidebar.toggleKey = fallback.toggleKey;
|
||||||
|
warn(
|
||||||
|
'subtitleSidebar.toggleKey',
|
||||||
|
(src.subtitleSidebar as { toggleKey?: unknown }).toggleKey,
|
||||||
|
resolved.subtitleSidebar.toggleKey,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWidth = asNumber((src.subtitleSidebar as { maxWidth?: unknown }).maxWidth);
|
||||||
|
if (maxWidth !== undefined && maxWidth > 0) {
|
||||||
|
resolved.subtitleSidebar.maxWidth = Math.floor(maxWidth);
|
||||||
|
} else if ((src.subtitleSidebar as { maxWidth?: unknown }).maxWidth !== undefined) {
|
||||||
|
resolved.subtitleSidebar.maxWidth = fallback.maxWidth;
|
||||||
|
warn(
|
||||||
|
'subtitleSidebar.maxWidth',
|
||||||
|
(src.subtitleSidebar as { maxWidth?: unknown }).maxWidth,
|
||||||
|
resolved.subtitleSidebar.maxWidth,
|
||||||
|
'Expected positive number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opacity = asNumber((src.subtitleSidebar as { opacity?: unknown }).opacity);
|
||||||
|
if (opacity !== undefined && opacity >= 0 && opacity <= 1) {
|
||||||
|
resolved.subtitleSidebar.opacity = opacity;
|
||||||
|
} else if ((src.subtitleSidebar as { opacity?: unknown }).opacity !== undefined) {
|
||||||
|
resolved.subtitleSidebar.opacity = fallback.opacity;
|
||||||
|
warn(
|
||||||
|
'subtitleSidebar.opacity',
|
||||||
|
(src.subtitleSidebar as { opacity?: unknown }).opacity,
|
||||||
|
resolved.subtitleSidebar.opacity,
|
||||||
|
'Expected number between 0 and 1.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexColorFields = ['textColor', 'timestampColor', 'activeLineColor'] as const;
|
||||||
|
for (const field of hexColorFields) {
|
||||||
|
const value = asColor((src.subtitleSidebar as Record<string, unknown>)[field]);
|
||||||
|
if (value !== undefined) {
|
||||||
|
resolved.subtitleSidebar[field] = value;
|
||||||
|
} else if ((src.subtitleSidebar as Record<string, unknown>)[field] !== undefined) {
|
||||||
|
resolved.subtitleSidebar[field] = fallback[field];
|
||||||
|
warn(
|
||||||
|
`subtitleSidebar.${field}`,
|
||||||
|
(src.subtitleSidebar as Record<string, unknown>)[field],
|
||||||
|
resolved.subtitleSidebar[field],
|
||||||
|
'Expected hex color.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssColorFields = [
|
||||||
|
'backgroundColor',
|
||||||
|
'activeLineBackgroundColor',
|
||||||
|
'hoverLineBackgroundColor',
|
||||||
|
] as const;
|
||||||
|
for (const field of cssColorFields) {
|
||||||
|
const value = asCssColor((src.subtitleSidebar as Record<string, unknown>)[field]);
|
||||||
|
if (value !== undefined) {
|
||||||
|
resolved.subtitleSidebar[field] = value;
|
||||||
|
} else if ((src.subtitleSidebar as Record<string, unknown>)[field] !== undefined) {
|
||||||
|
resolved.subtitleSidebar[field] = fallback[field];
|
||||||
|
warn(
|
||||||
|
`subtitleSidebar.${field}`,
|
||||||
|
(src.subtitleSidebar as Record<string, unknown>)[field],
|
||||||
|
resolved.subtitleSidebar[field],
|
||||||
|
'Expected valid CSS color.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontFamily = asString((src.subtitleSidebar as { fontFamily?: unknown }).fontFamily);
|
||||||
|
if (fontFamily !== undefined && fontFamily.trim().length > 0) {
|
||||||
|
resolved.subtitleSidebar.fontFamily = fontFamily.trim();
|
||||||
|
} else if ((src.subtitleSidebar as { fontFamily?: unknown }).fontFamily !== undefined) {
|
||||||
|
resolved.subtitleSidebar.fontFamily = fallback.fontFamily;
|
||||||
|
warn(
|
||||||
|
'subtitleSidebar.fontFamily',
|
||||||
|
(src.subtitleSidebar as { fontFamily?: unknown }).fontFamily,
|
||||||
|
resolved.subtitleSidebar.fontFamily,
|
||||||
|
'Expected non-empty string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontSize = asNumber((src.subtitleSidebar as { fontSize?: unknown }).fontSize);
|
||||||
|
if (fontSize !== undefined && fontSize > 0) {
|
||||||
|
resolved.subtitleSidebar.fontSize = fontSize;
|
||||||
|
} else if ((src.subtitleSidebar as { fontSize?: unknown }).fontSize !== undefined) {
|
||||||
|
resolved.subtitleSidebar.fontSize = fallback.fontSize;
|
||||||
|
warn(
|
||||||
|
'subtitleSidebar.fontSize',
|
||||||
|
(src.subtitleSidebar as { fontSize?: unknown }).fontSize,
|
||||||
|
resolved.subtitleSidebar.fontSize,
|
||||||
|
'Expected positive number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
93
src/config/resolve/subtitle-sidebar.test.ts
Normal file
93
src/config/resolve/subtitle-sidebar.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createResolveContext } from './context';
|
||||||
|
import { applySubtitleDomainConfig } from './subtitle-domains';
|
||||||
|
|
||||||
|
test('subtitleSidebar resolves valid values and preserves dedicated defaults', () => {
|
||||||
|
const { context } = createResolveContext({
|
||||||
|
subtitleSidebar: {
|
||||||
|
enabled: true,
|
||||||
|
autoOpen: true,
|
||||||
|
layout: 'embedded',
|
||||||
|
toggleKey: 'KeyB',
|
||||||
|
pauseVideoOnHover: true,
|
||||||
|
autoScroll: false,
|
||||||
|
maxWidth: 540,
|
||||||
|
opacity: 0.72,
|
||||||
|
backgroundColor: 'rgba(36, 39, 58, 0.72)',
|
||||||
|
textColor: '#cad3f5',
|
||||||
|
fontFamily: '"Iosevka Aile", sans-serif',
|
||||||
|
fontSize: 17,
|
||||||
|
timestampColor: '#a5adcb',
|
||||||
|
activeLineColor: '#f5bde6',
|
||||||
|
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||||
|
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applySubtitleDomainConfig(context);
|
||||||
|
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.enabled, true);
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.autoOpen, true);
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.layout, 'embedded');
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.toggleKey, 'KeyB');
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.pauseVideoOnHover, true);
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.autoScroll, false);
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.maxWidth, 540);
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.opacity, 0.72);
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.fontFamily, '"Iosevka Aile", sans-serif');
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.fontSize, 17);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitleSidebar accepts zero opacity', () => {
|
||||||
|
const { context, warnings } = createResolveContext({
|
||||||
|
subtitleSidebar: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applySubtitleDomainConfig(context);
|
||||||
|
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.opacity, 0);
|
||||||
|
assert.equal(warnings.some((warning) => warning.path === 'subtitleSidebar.opacity'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitleSidebar falls back and warns on invalid values', () => {
|
||||||
|
const { context, warnings } = createResolveContext({
|
||||||
|
subtitleSidebar: {
|
||||||
|
enabled: 'yes' as never,
|
||||||
|
autoOpen: 'yes' as never,
|
||||||
|
layout: 'floating' as never,
|
||||||
|
maxWidth: -1,
|
||||||
|
opacity: 5,
|
||||||
|
fontSize: 0,
|
||||||
|
textColor: 'blue',
|
||||||
|
backgroundColor: 'not-a-color',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applySubtitleDomainConfig(context);
|
||||||
|
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.enabled, false);
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.autoOpen, false);
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.layout, 'overlay');
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.maxWidth, 420);
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.opacity, 0.95);
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.fontSize, 16);
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.textColor, '#cad3f5');
|
||||||
|
assert.equal(context.resolved.subtitleSidebar.backgroundColor, 'rgba(73, 77, 100, 0.9)');
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.enabled'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.autoOpen'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.layout'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.maxWidth'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.opacity'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.fontSize'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'subtitleSidebar.textColor'));
|
||||||
|
assert.ok(
|
||||||
|
warnings.some(
|
||||||
|
(warning) =>
|
||||||
|
warning.path === 'subtitleSidebar.backgroundColor' &&
|
||||||
|
warning.message === 'Expected valid CSS color.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -176,6 +176,22 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
|||||||
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
|
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runAppReadyRuntime uses minimal startup for texthooker-only mode', async () => {
|
||||||
|
const { deps, calls } = makeDeps({
|
||||||
|
texthookerOnlyMode: true,
|
||||||
|
reloadConfig: () => calls.push('reloadConfig'),
|
||||||
|
handleInitialArgs: () => calls.push('handleInitialArgs'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await runAppReadyRuntime(deps);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'ensureDefaultConfigBootstrap',
|
||||||
|
'reloadConfig',
|
||||||
|
'handleInitialArgs',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
|
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
|
||||||
const { deps, calls } = makeDeps({
|
const { deps, calls } = makeDeps({
|
||||||
startJellyfinRemoteSession: undefined,
|
startJellyfinRemoteSession: undefined,
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo
|
|||||||
if (!isEqual(prev.shortcuts, next.shortcuts)) {
|
if (!isEqual(prev.shortcuts, next.shortcuts)) {
|
||||||
hotReloadFields.push('shortcuts');
|
hotReloadFields.push('shortcuts');
|
||||||
}
|
}
|
||||||
|
if (!isEqual(prev.subtitleSidebar, next.subtitleSidebar)) {
|
||||||
|
hotReloadFields.push('subtitleSidebar');
|
||||||
|
}
|
||||||
if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) {
|
if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) {
|
||||||
hotReloadFields.push('secondarySub.defaultMode');
|
hotReloadFields.push('secondarySub.defaultMode');
|
||||||
}
|
}
|
||||||
@@ -55,7 +58,7 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts') {
|
if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts' || key === 'subtitleSidebar') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2128,6 +2128,129 @@ test('reassignAnimeAnilist deduplicates cover blobs and getCoverArt remains comp
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reassignAnimeAnilist replaces stale cover blobs when the AniList cover changes', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const initialCoverBlob = Buffer.from([1, 2, 3, 4]);
|
||||||
|
const replacementCoverBlob = Buffer.from([9, 8, 7, 6]);
|
||||||
|
let fetchCallCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
fetchCallCount += 1;
|
||||||
|
const blob = fetchCallCount === 1 ? initialCoverBlob : replacementCoverBlob;
|
||||||
|
return new Response(new Uint8Array(blob), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'image/jpeg' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath });
|
||||||
|
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||||
|
|
||||||
|
privateApi.db.exec(`
|
||||||
|
INSERT INTO imm_anime (
|
||||||
|
anime_id,
|
||||||
|
normalized_title_key,
|
||||||
|
canonical_title,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES (
|
||||||
|
1,
|
||||||
|
'little witch academia',
|
||||||
|
'Little Witch Academia',
|
||||||
|
1000,
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
INSERT INTO imm_videos (
|
||||||
|
video_id,
|
||||||
|
video_key,
|
||||||
|
canonical_title,
|
||||||
|
source_type,
|
||||||
|
duration_ms,
|
||||||
|
anime_id,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
'local:/tmp/lwa-1.mkv',
|
||||||
|
'Little Witch Academia S01E01',
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1000,
|
||||||
|
1000
|
||||||
|
),
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
'local:/tmp/lwa-2.mkv',
|
||||||
|
'Little Witch Academia S01E02',
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1000,
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await tracker.reassignAnimeAnilist(1, {
|
||||||
|
anilistId: 33489,
|
||||||
|
titleRomaji: 'Little Witch Academia',
|
||||||
|
coverUrl: 'https://example.com/lwa-old.jpg',
|
||||||
|
});
|
||||||
|
|
||||||
|
await tracker.reassignAnimeAnilist(1, {
|
||||||
|
anilistId: 100526,
|
||||||
|
titleRomaji: 'Otome Game Sekai wa Mob ni Kibishii Sekai desu',
|
||||||
|
coverUrl: 'https://example.com/mobseka-new.jpg',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaRows = privateApi.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
video_id AS videoId,
|
||||||
|
anilist_id AS anilistId,
|
||||||
|
cover_url AS coverUrl,
|
||||||
|
cover_blob_hash AS coverBlobHash
|
||||||
|
FROM imm_media_art
|
||||||
|
ORDER BY video_id ASC
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all() as Array<{
|
||||||
|
videoId: number;
|
||||||
|
anilistId: number | null;
|
||||||
|
coverUrl: string | null;
|
||||||
|
coverBlobHash: string | null;
|
||||||
|
}>;
|
||||||
|
const blobRows = privateApi.db
|
||||||
|
.prepare('SELECT blob_hash AS blobHash, cover_blob AS coverBlob FROM imm_cover_art_blobs')
|
||||||
|
.all() as Array<{ blobHash: string; coverBlob: Buffer }>;
|
||||||
|
const resolvedCover = await tracker.getAnimeCoverArt(1);
|
||||||
|
|
||||||
|
assert.equal(fetchCallCount, 2);
|
||||||
|
assert.equal(mediaRows.length, 2);
|
||||||
|
assert.equal(mediaRows[0]?.anilistId, 100526);
|
||||||
|
assert.equal(mediaRows[0]?.coverUrl, 'https://example.com/mobseka-new.jpg');
|
||||||
|
assert.equal(mediaRows[0]?.coverBlobHash, mediaRows[1]?.coverBlobHash);
|
||||||
|
assert.equal(blobRows.length, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
new Uint8Array(blobRows[0]?.coverBlob ?? Buffer.alloc(0)),
|
||||||
|
new Uint8Array(replacementCoverBlob),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
new Uint8Array(resolvedCover?.coverBlob ?? Buffer.alloc(0)),
|
||||||
|
new Uint8Array(replacementCoverBlob),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('reassignAnimeAnilist preserves existing description when description is omitted', async () => {
|
test('reassignAnimeAnilist preserves existing description when description is omitted', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|||||||
@@ -207,6 +207,78 @@ test('getAnimeEpisodes prefers the latest session media position when the latest
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getAnimeEpisodes falls back to the latest subtitle segment end when session progress checkpoints are missing', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const stmts = createTrackerPreparedStatements(db);
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/subtitle-progress-fallback.mkv', {
|
||||||
|
canonicalTitle: 'Subtitle Progress Fallback',
|
||||||
|
sourcePath: '/tmp/subtitle-progress-fallback.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'Subtitle Progress Fallback Anime',
|
||||||
|
canonicalTitle: 'Subtitle Progress Fallback Anime',
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: null,
|
||||||
|
});
|
||||||
|
linkVideoToAnimeRecord(db, videoId, {
|
||||||
|
animeId,
|
||||||
|
parsedBasename: 'subtitle-progress-fallback.mkv',
|
||||||
|
parsedTitle: 'Subtitle Progress Fallback Anime',
|
||||||
|
parsedSeason: 1,
|
||||||
|
parsedEpisode: 1,
|
||||||
|
parserSource: 'fallback',
|
||||||
|
parserConfidence: 1,
|
||||||
|
parseMetadataJson: '{"episode":1}',
|
||||||
|
});
|
||||||
|
db.prepare('UPDATE imm_videos SET duration_ms = ? WHERE video_id = ?').run(24_000, videoId);
|
||||||
|
|
||||||
|
const startedAtMs = 1_100_000;
|
||||||
|
const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId;
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE imm_sessions
|
||||||
|
SET
|
||||||
|
ended_at_ms = ?,
|
||||||
|
status = 2,
|
||||||
|
active_watched_ms = ?,
|
||||||
|
LAST_UPDATE_DATE = ?
|
||||||
|
WHERE session_id = ?
|
||||||
|
`,
|
||||||
|
).run(startedAtMs + 10_000, 10_000, startedAtMs + 10_000, sessionId);
|
||||||
|
stmts.eventInsertStmt.run(
|
||||||
|
sessionId,
|
||||||
|
startedAtMs + 9_000,
|
||||||
|
EVENT_SUBTITLE_LINE,
|
||||||
|
1,
|
||||||
|
18_000,
|
||||||
|
21_000,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
'{"line":"progress fallback"}',
|
||||||
|
startedAtMs + 9_000,
|
||||||
|
startedAtMs + 9_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [episode] = getAnimeEpisodes(db, animeId);
|
||||||
|
assert.ok(episode);
|
||||||
|
assert.equal(episode?.endedMediaMs, 21_000);
|
||||||
|
assert.equal(episode?.totalSessions, 1);
|
||||||
|
assert.equal(episode?.totalActiveMs, 10_000);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getSessionTimeline returns the full session when no limit is provided', () => {
|
test('getSessionTimeline returns the full session when no limit is provided', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|||||||
@@ -1745,10 +1745,38 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
|
|||||||
v.parsed_episode AS episode,
|
v.parsed_episode AS episode,
|
||||||
v.duration_ms AS durationMs,
|
v.duration_ms AS durationMs,
|
||||||
(
|
(
|
||||||
SELECT s_recent.ended_media_ms
|
SELECT COALESCE(
|
||||||
|
s_recent.ended_media_ms,
|
||||||
|
(
|
||||||
|
SELECT MAX(line.segment_end_ms)
|
||||||
|
FROM imm_subtitle_lines line
|
||||||
|
WHERE line.session_id = s_recent.session_id
|
||||||
|
AND line.segment_end_ms IS NOT NULL
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SELECT MAX(event.segment_end_ms)
|
||||||
|
FROM imm_session_events event
|
||||||
|
WHERE event.session_id = s_recent.session_id
|
||||||
|
AND event.segment_end_ms IS NOT NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
FROM imm_sessions s_recent
|
FROM imm_sessions s_recent
|
||||||
WHERE s_recent.video_id = v.video_id
|
WHERE s_recent.video_id = v.video_id
|
||||||
AND s_recent.ended_media_ms IS NOT NULL
|
AND (
|
||||||
|
s_recent.ended_media_ms IS NOT NULL
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM imm_subtitle_lines line
|
||||||
|
WHERE line.session_id = s_recent.session_id
|
||||||
|
AND line.segment_end_ms IS NOT NULL
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM imm_session_events event
|
||||||
|
WHERE event.session_id = s_recent.session_id
|
||||||
|
AND event.segment_end_ms IS NOT NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
COALESCE(s_recent.ended_at_ms, s_recent.LAST_UPDATE_DATE, s_recent.started_at_ms) DESC,
|
COALESCE(s_recent.ended_at_ms, s_recent.LAST_UPDATE_DATE, s_recent.started_at_ms) DESC,
|
||||||
s_recent.session_id DESC
|
s_recent.session_id DESC
|
||||||
@@ -2289,10 +2317,13 @@ export function upsertCoverArt(
|
|||||||
const sharedCoverBlobHash = findSharedCoverBlobHash(db, videoId, art.anilistId, art.coverUrl);
|
const sharedCoverBlobHash = findSharedCoverBlobHash(db, videoId, art.anilistId, art.coverUrl);
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
|
const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
|
||||||
let coverBlobHash = sharedCoverBlobHash ?? existing?.coverBlobHash ?? null;
|
let coverBlobHash = sharedCoverBlobHash ?? null;
|
||||||
if (!coverBlobHash && coverBlob && coverBlob.length > 0) {
|
if (!coverBlobHash && coverBlob && coverBlob.length > 0) {
|
||||||
coverBlobHash = createHash('sha256').update(coverBlob).digest('hex');
|
coverBlobHash = createHash('sha256').update(coverBlob).digest('hex');
|
||||||
}
|
}
|
||||||
|
if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) {
|
||||||
|
coverBlobHash = existing?.coverBlobHash ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
if (coverBlobHash && coverBlob && coverBlob.length > 0 && !sharedCoverBlobHash) {
|
if (coverBlobHash && coverBlob && coverBlob.length > 0 && !sharedCoverBlobHash) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
import type { SubtitleSidebarSnapshot } from '../../types';
|
||||||
|
|
||||||
interface FakeIpcRegistrar {
|
interface FakeIpcRegistrar {
|
||||||
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
||||||
@@ -77,6 +78,31 @@ function createControllerConfigFixture() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
||||||
|
return {
|
||||||
|
cues: [],
|
||||||
|
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
autoOpen: false,
|
||||||
|
layout: 'overlay',
|
||||||
|
toggleKey: 'Backslash',
|
||||||
|
pauseVideoOnHover: false,
|
||||||
|
autoScroll: true,
|
||||||
|
maxWidth: 420,
|
||||||
|
opacity: 0.92,
|
||||||
|
backgroundColor: 'rgba(54, 58, 79, 0.88)',
|
||||||
|
textColor: '#cad3f5',
|
||||||
|
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
|
||||||
|
fontSize: 16,
|
||||||
|
timestampColor: '#a5adcb',
|
||||||
|
activeLineColor: '#f5bde6',
|
||||||
|
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||||
|
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServiceDeps {
|
function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServiceDeps {
|
||||||
return {
|
return {
|
||||||
onOverlayModalClosed: () => {},
|
onOverlayModalClosed: () => {},
|
||||||
@@ -88,6 +114,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
|
|||||||
tokenizeCurrentSubtitle: async () => null,
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
getCurrentSubtitleRaw: () => '',
|
getCurrentSubtitleRaw: () => '',
|
||||||
getCurrentSubtitleAss: () => '',
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||||
getPlaybackPaused: () => false,
|
getPlaybackPaused: () => false,
|
||||||
getSubtitlePosition: () => null,
|
getSubtitlePosition: () => null,
|
||||||
getSubtitleStyle: () => null,
|
getSubtitleStyle: () => null,
|
||||||
@@ -173,6 +200,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
tokenizeCurrentSubtitle: async () => null,
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
getCurrentSubtitleRaw: () => '',
|
getCurrentSubtitleRaw: () => '',
|
||||||
getCurrentSubtitleAss: () => '',
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||||
getPlaybackPaused: () => true,
|
getPlaybackPaused: () => true,
|
||||||
getSubtitlePosition: () => null,
|
getSubtitlePosition: () => null,
|
||||||
getSubtitleStyle: () => null,
|
getSubtitleStyle: () => null,
|
||||||
@@ -269,6 +297,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
cycles.push({ id, direction });
|
cycles.push({ id, direction });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
|
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||||
reportOverlayContentBounds: () => {},
|
reportOverlayContentBounds: () => {},
|
||||||
getAnilistStatus: () => ({}),
|
getAnilistStatus: () => ({}),
|
||||||
clearAnilistToken: () => {},
|
clearAnilistToken: () => {},
|
||||||
@@ -320,6 +349,24 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () => {
|
||||||
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
|
const snapshot = createSubtitleSidebarSnapshotFixture();
|
||||||
|
snapshot.cues = [{ startTime: 1, endTime: 2, text: 'line-1' }];
|
||||||
|
snapshot.config.enabled = true;
|
||||||
|
|
||||||
|
registerIpcHandlers(
|
||||||
|
createRegisterIpcDeps({
|
||||||
|
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||||
|
}),
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handler = handlers.handle.get(IPC_CHANNELS.request.getSubtitleSidebarSnapshot);
|
||||||
|
assert.ok(handler);
|
||||||
|
assert.deepEqual(await handler!({}), snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
|
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
@@ -530,6 +577,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
tokenizeCurrentSubtitle: async () => null,
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
getCurrentSubtitleRaw: () => '',
|
getCurrentSubtitleRaw: () => '',
|
||||||
getCurrentSubtitleAss: () => '',
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||||
getPlaybackPaused: () => false,
|
getPlaybackPaused: () => false,
|
||||||
getSubtitlePosition: () => null,
|
getSubtitlePosition: () => null,
|
||||||
getSubtitleStyle: () => null,
|
getSubtitleStyle: () => null,
|
||||||
@@ -596,6 +644,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
|||||||
tokenizeCurrentSubtitle: async () => null,
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
getCurrentSubtitleRaw: () => '',
|
getCurrentSubtitleRaw: () => '',
|
||||||
getCurrentSubtitleAss: () => '',
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||||
getPlaybackPaused: () => false,
|
getPlaybackPaused: () => false,
|
||||||
getSubtitlePosition: () => null,
|
getSubtitlePosition: () => null,
|
||||||
getSubtitleStyle: () => null,
|
getSubtitleStyle: () => null,
|
||||||
@@ -667,6 +716,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
|||||||
tokenizeCurrentSubtitle: async () => null,
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
getCurrentSubtitleRaw: () => '',
|
getCurrentSubtitleRaw: () => '',
|
||||||
getCurrentSubtitleAss: () => '',
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||||
getPlaybackPaused: () => false,
|
getPlaybackPaused: () => false,
|
||||||
getSubtitlePosition: () => null,
|
getSubtitlePosition: () => null,
|
||||||
getSubtitleStyle: () => null,
|
getSubtitleStyle: () => null,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ResolvedControllerConfig,
|
ResolvedControllerConfig,
|
||||||
RuntimeOptionId,
|
RuntimeOptionId,
|
||||||
RuntimeOptionValue,
|
RuntimeOptionValue,
|
||||||
|
SubtitleSidebarSnapshot,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
SubsyncManualRunRequest,
|
SubsyncManualRunRequest,
|
||||||
SubsyncResult,
|
SubsyncResult,
|
||||||
@@ -37,6 +38,7 @@ export interface IpcServiceDeps {
|
|||||||
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
||||||
getCurrentSubtitleRaw: () => string;
|
getCurrentSubtitleRaw: () => string;
|
||||||
getCurrentSubtitleAss: () => string;
|
getCurrentSubtitleAss: () => string;
|
||||||
|
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
|
||||||
getPlaybackPaused: () => boolean | null;
|
getPlaybackPaused: () => boolean | null;
|
||||||
getSubtitlePosition: () => unknown;
|
getSubtitlePosition: () => unknown;
|
||||||
getSubtitleStyle: () => unknown;
|
getSubtitleStyle: () => unknown;
|
||||||
@@ -143,6 +145,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
||||||
getCurrentSubtitleRaw: () => string;
|
getCurrentSubtitleRaw: () => string;
|
||||||
getCurrentSubtitleAss: () => string;
|
getCurrentSubtitleAss: () => string;
|
||||||
|
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
|
||||||
getPlaybackPaused: () => boolean | null;
|
getPlaybackPaused: () => boolean | null;
|
||||||
getSubtitlePosition: () => unknown;
|
getSubtitlePosition: () => unknown;
|
||||||
getSubtitleStyle: () => unknown;
|
getSubtitleStyle: () => unknown;
|
||||||
@@ -190,6 +193,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
|
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
|
||||||
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
|
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
|
||||||
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
|
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
|
||||||
|
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
|
||||||
getPlaybackPaused: options.getPlaybackPaused,
|
getPlaybackPaused: options.getPlaybackPaused,
|
||||||
getSubtitlePosition: options.getSubtitlePosition,
|
getSubtitlePosition: options.getSubtitlePosition,
|
||||||
getSubtitleStyle: options.getSubtitleStyle,
|
getSubtitleStyle: options.getSubtitleStyle,
|
||||||
@@ -321,6 +325,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return deps.getCurrentSubtitleAss();
|
return deps.getCurrentSubtitleAss();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarSnapshot, async () => {
|
||||||
|
if (!deps.getSubtitleSidebarSnapshot) {
|
||||||
|
throw new Error('Subtitle sidebar snapshot is unavailable.');
|
||||||
|
}
|
||||||
|
return await deps.getSubtitleSidebarSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => {
|
ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => {
|
||||||
return deps.getPlaybackPaused();
|
return deps.getPlaybackPaused();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -200,6 +200,12 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deps.texthookerOnlyMode) {
|
||||||
|
deps.reloadConfig();
|
||||||
|
deps.handleInitialArgs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (deps.shouldUseMinimalStartup?.()) {
|
if (deps.shouldUseMinimalStartup?.()) {
|
||||||
deps.reloadConfig();
|
deps.reloadConfig();
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { parseSrtCues, parseAssCues, parseSubtitleCues } from './subtitle-cue-parser';
|
import { parseSrtCues, parseAssCues, parseSubtitleCues } from './subtitle-cue-parser';
|
||||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
import type { SubtitleCue } from '../../types';
|
||||||
|
|
||||||
test('parseSrtCues parses basic SRT content', () => {
|
test('parseSrtCues parses basic SRT content', () => {
|
||||||
const content = [
|
const content = [
|
||||||
|
|||||||
@@ -183,7 +183,13 @@ export function parseSubtitleCues(content: string, filename: string): SubtitleCu
|
|||||||
cues = parseAssCues(content);
|
cues = parseAssCues(content);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return [];
|
cues = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cues.length === 0) {
|
||||||
|
const assCues = parseAssCues(content);
|
||||||
|
const srtCues = parseSrtCues(content);
|
||||||
|
cues = assCues.length >= srtCues.length ? assCues : srtCues;
|
||||||
}
|
}
|
||||||
|
|
||||||
cues.sort((a, b) => a.startTime - b.startTime);
|
cues.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { computePriorityWindow, createSubtitlePrefetchService } from './subtitle-prefetch';
|
import { computePriorityWindow, createSubtitlePrefetchService } from './subtitle-prefetch';
|
||||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
|
||||||
import type { SubtitleData } from '../../types';
|
import type { SubtitleData } from '../../types';
|
||||||
|
import type { SubtitleCue } from '../../types';
|
||||||
|
|
||||||
function makeCues(count: number, startOffset = 0): SubtitleCue[] {
|
function makeCues(count: number, startOffset = 0): SubtitleCue[] {
|
||||||
return Array.from({ length: count }, (_, i) => ({
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
|
||||||
import type { SubtitleData } from '../../types';
|
import type { SubtitleData } from '../../types';
|
||||||
|
import type { SubtitleCue } from '../../types';
|
||||||
|
|
||||||
export interface SubtitlePrefetchServiceDeps {
|
export interface SubtitlePrefetchServiceDeps {
|
||||||
cues: SubtitleCue[];
|
cues: SubtitleCue[];
|
||||||
|
|||||||
@@ -3741,6 +3741,98 @@ test('tokenizeSubtitle clears all annotations for kana-only demonstrative helper
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tokenizeSubtitle clears all annotations for explanatory pondering endings', async () => {
|
||||||
|
const result = await tokenizeSubtitle(
|
||||||
|
'俺どうかしちゃったのかな',
|
||||||
|
makeDepsFromYomitanTokens(
|
||||||
|
[
|
||||||
|
{ surface: '俺', reading: 'おれ', headword: '俺' },
|
||||||
|
{ surface: 'どうかしちゃった', reading: 'どうかしちゃった', headword: 'どうかしちゃう' },
|
||||||
|
{ surface: 'のかな', reading: 'のかな', headword: 'の' },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
getFrequencyDictionaryEnabled: () => true,
|
||||||
|
getFrequencyRank: (text) => (text === '俺' ? 19 : text === 'どうかしちゃう' ? 3200 : 77),
|
||||||
|
getJlptLevel: (text) =>
|
||||||
|
text === '俺' ? 'N5' : text === 'どうかしちゃう' ? 'N3' : text === 'の' ? 'N5' : null,
|
||||||
|
isKnownWord: (text) => text === '俺' || text === 'の',
|
||||||
|
getMinSentenceWordsForNPlusOne: () => 1,
|
||||||
|
tokenizeWithMecab: async () => [
|
||||||
|
{
|
||||||
|
headword: '俺',
|
||||||
|
surface: '俺',
|
||||||
|
reading: 'オレ',
|
||||||
|
startPos: 0,
|
||||||
|
endPos: 1,
|
||||||
|
partOfSpeech: PartOfSpeech.noun,
|
||||||
|
pos1: '名詞',
|
||||||
|
pos2: '代名詞',
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headword: 'どうかしちゃう',
|
||||||
|
surface: 'どうかしちゃった',
|
||||||
|
reading: 'ドウカシチャッタ',
|
||||||
|
startPos: 1,
|
||||||
|
endPos: 8,
|
||||||
|
partOfSpeech: PartOfSpeech.verb,
|
||||||
|
pos1: '動詞',
|
||||||
|
pos2: '自立',
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headword: 'の',
|
||||||
|
surface: 'のかな',
|
||||||
|
reading: 'ノカナ',
|
||||||
|
startPos: 8,
|
||||||
|
endPos: 11,
|
||||||
|
partOfSpeech: PartOfSpeech.other,
|
||||||
|
pos1: '名詞|助動詞',
|
||||||
|
pos2: '非自立',
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
result.tokens?.map((token) => ({
|
||||||
|
surface: token.surface,
|
||||||
|
headword: token.headword,
|
||||||
|
isKnown: token.isKnown,
|
||||||
|
isNPlusOneTarget: token.isNPlusOneTarget,
|
||||||
|
frequencyRank: token.frequencyRank,
|
||||||
|
jlptLevel: token.jlptLevel,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{ surface: '俺', headword: '俺', isKnown: true, isNPlusOneTarget: false, frequencyRank: 19, jlptLevel: 'N5' },
|
||||||
|
{
|
||||||
|
surface: 'どうかしちゃった',
|
||||||
|
headword: 'どうかしちゃう',
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: true,
|
||||||
|
frequencyRank: 3200,
|
||||||
|
jlptLevel: 'N3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
surface: 'のかな',
|
||||||
|
headword: 'の',
|
||||||
|
isKnown: false,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
frequencyRank: undefined,
|
||||||
|
jlptLevel: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => {
|
test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => {
|
||||||
const result = await tokenizeSubtitle(
|
const result = await tokenizeSubtitle(
|
||||||
'張り切ってんじゃ',
|
'張り切ってんじゃ',
|
||||||
|
|||||||
@@ -234,6 +234,18 @@ test('shouldExcludeTokenFromSubtitleAnnotations excludes explanatory ending vari
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shouldExcludeTokenFromSubtitleAnnotations excludes explanatory pondering endings', () => {
|
||||||
|
const token = makeToken({
|
||||||
|
surface: 'のかな',
|
||||||
|
headword: 'の',
|
||||||
|
reading: 'ノカナ',
|
||||||
|
pos1: '名詞|助動詞',
|
||||||
|
pos2: '非自立',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
|
||||||
|
});
|
||||||
|
|
||||||
test('shouldExcludeTokenFromSubtitleAnnotations excludes auxiliary-stem そうだ grammar tails', () => {
|
test('shouldExcludeTokenFromSubtitleAnnotations excludes auxiliary-stem そうだ grammar tails', () => {
|
||||||
const token = makeToken({
|
const token = makeToken({
|
||||||
surface: 'そうだ',
|
surface: 'そうだ',
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES = [
|
|||||||
'かな',
|
'かな',
|
||||||
'かね',
|
'かね',
|
||||||
] as const;
|
] as const;
|
||||||
|
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_THOUGHT_SUFFIXES = ['か', 'かな', 'かね'] as const;
|
||||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS = new Set(
|
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS = new Set(
|
||||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.flatMap((prefix) =>
|
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.flatMap((prefix) =>
|
||||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES.flatMap((core) =>
|
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES.flatMap((core) =>
|
||||||
@@ -258,6 +259,16 @@ function isExcludedByTerm(token: MergedToken): boolean {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.some((prefix) =>
|
||||||
|
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_THOUGHT_SUFFIXES.some(
|
||||||
|
(suffix) => normalized === `${prefix}${suffix}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(trimmed) ||
|
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(trimmed) ||
|
||||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(normalized) ||
|
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(normalized) ||
|
||||||
|
|||||||
380
src/main.ts
380
src/main.ts
@@ -315,6 +315,7 @@ import {
|
|||||||
createFirstRunSetupService,
|
createFirstRunSetupService,
|
||||||
shouldAutoOpenFirstRunSetup,
|
shouldAutoOpenFirstRunSetup,
|
||||||
} from './main/runtime/first-run-setup-service';
|
} from './main/runtime/first-run-setup-service';
|
||||||
|
import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy';
|
||||||
import {
|
import {
|
||||||
buildFirstRunSetupHtml,
|
buildFirstRunSetupHtml,
|
||||||
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
||||||
@@ -437,10 +438,11 @@ import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
|
|||||||
import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
||||||
import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
||||||
import {
|
import {
|
||||||
getActiveExternalSubtitleSource,
|
buildSubtitleSidebarSourceKey,
|
||||||
resolveSubtitleSourcePath,
|
resolveSubtitleSourcePath,
|
||||||
} from './main/runtime/subtitle-prefetch-source';
|
} from './main/runtime/subtitle-prefetch-source';
|
||||||
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
||||||
|
import { codecToExtension } from './subsync/utils';
|
||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
||||||
@@ -1096,8 +1098,11 @@ function maybeSignalPluginAutoplayReady(
|
|||||||
|
|
||||||
// Fallback: repeatedly try to release pause for a short window in case startup
|
// Fallback: repeatedly try to release pause for a short window in case startup
|
||||||
// gate arming and tokenization-ready signal arrive out of order.
|
// gate arming and tokenization-ready signal arrive out of order.
|
||||||
const maxReleaseAttempts = options?.forceWhilePaused === true ? 14 : 3;
|
|
||||||
const releaseRetryDelayMs = 200;
|
const releaseRetryDelayMs = 200;
|
||||||
|
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
||||||
|
forceWhilePaused: options?.forceWhilePaused === true,
|
||||||
|
retryDelayMs: releaseRetryDelayMs,
|
||||||
|
});
|
||||||
const attemptRelease = (attempt: number): void => {
|
const attemptRelease = (attempt: number): void => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (
|
if (
|
||||||
@@ -1138,15 +1143,23 @@ function maybeSignalPluginAutoplayReady(
|
|||||||
|
|
||||||
let appTray: Tray | null = null;
|
let appTray: Tray | null = null;
|
||||||
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
|
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
|
||||||
|
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||||
|
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
function emitSubtitlePayload(payload: SubtitleData): void {
|
function emitSubtitlePayload(payload: SubtitleData): void {
|
||||||
appState.currentSubtitleData = payload;
|
const timedPayload = withCurrentSubtitleTiming(payload);
|
||||||
broadcastToOverlayWindows('subtitle:set', payload);
|
appState.currentSubtitleData = timedPayload;
|
||||||
subtitleWsService.broadcast(payload, {
|
broadcastToOverlayWindows('subtitle:set', timedPayload);
|
||||||
|
subtitleWsService.broadcast(timedPayload, {
|
||||||
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||||
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
||||||
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
||||||
});
|
});
|
||||||
annotationSubtitleWsService.broadcast(payload, {
|
annotationSubtitleWsService.broadcast(timedPayload, {
|
||||||
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||||
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
||||||
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
||||||
@@ -1196,6 +1209,10 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
|||||||
isCacheFull: () => subtitleProcessingController.isCacheFull(),
|
isCacheFull: () => subtitleProcessingController.isCacheFull(),
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
logWarn: (message) => logger.warn(message),
|
logWarn: (message) => logger.warn(message),
|
||||||
|
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
||||||
|
appState.activeParsedSubtitleCues = cues ?? [];
|
||||||
|
appState.activeParsedSubtitleSource = sourceKey;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
||||||
@@ -1205,19 +1222,40 @@ async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [trackListRaw, sidRaw] = await Promise.all([
|
const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] =
|
||||||
|
await Promise.all([
|
||||||
|
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
|
||||||
|
client.requestProperty('current-tracks/sub').catch(() => null),
|
||||||
client.requestProperty('track-list'),
|
client.requestProperty('track-list'),
|
||||||
client.requestProperty('sid'),
|
client.requestProperty('sid'),
|
||||||
]);
|
client.requestProperty('path'),
|
||||||
const externalFilename = getActiveExternalSubtitleSource(trackListRaw, sidRaw);
|
]);
|
||||||
if (!externalFilename) {
|
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
||||||
|
if (!videoPath) {
|
||||||
subtitlePrefetchInitController.cancelPendingInit();
|
subtitlePrefetchInitController.cancelPendingInit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
|
||||||
externalFilename,
|
const resolvedSource = await resolveActiveSubtitleSidebarSource(
|
||||||
lastObservedTimePos,
|
currentExternalFilenameRaw,
|
||||||
|
currentTrackRaw,
|
||||||
|
trackListRaw,
|
||||||
|
sidRaw,
|
||||||
|
videoPath,
|
||||||
);
|
);
|
||||||
|
if (!resolvedSource) {
|
||||||
|
subtitlePrefetchInitController.cancelPendingInit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
||||||
|
resolvedSource.path,
|
||||||
|
lastObservedTimePos,
|
||||||
|
resolvedSource.sourceKey,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await resolvedSource.cleanup?.();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Track list query failed; skip subtitle prefetch refresh.
|
// Track list query failed; skip subtitle prefetch refresh.
|
||||||
}
|
}
|
||||||
@@ -2961,6 +2999,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
? {
|
? {
|
||||||
text: appState.currentSubText,
|
text: appState.currentSubText,
|
||||||
tokens: null,
|
tokens: null,
|
||||||
|
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||||
|
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||||
}
|
}
|
||||||
: null),
|
: null),
|
||||||
() => ({
|
() => ({
|
||||||
@@ -2979,6 +3019,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
? {
|
? {
|
||||||
text: appState.currentSubText,
|
text: appState.currentSubText,
|
||||||
tokens: null,
|
tokens: null,
|
||||||
|
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||||
|
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||||
}
|
}
|
||||||
: null),
|
: null),
|
||||||
() => ({
|
() => ({
|
||||||
@@ -3037,10 +3079,11 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||||
shouldUseMinimalStartup: () =>
|
shouldUseMinimalStartup: () =>
|
||||||
Boolean(
|
Boolean(
|
||||||
appState.initialArgs?.stats &&
|
appState.initialArgs?.texthooker ||
|
||||||
(appState.initialArgs?.statsCleanup ||
|
(appState.initialArgs?.stats &&
|
||||||
appState.initialArgs?.statsBackground ||
|
(appState.initialArgs?.statsCleanup ||
|
||||||
appState.initialArgs?.statsStop),
|
appState.initialArgs?.statsBackground ||
|
||||||
|
appState.initialArgs?.statsStop)),
|
||||||
),
|
),
|
||||||
shouldSkipHeavyStartup: () =>
|
shouldSkipHeavyStartup: () =>
|
||||||
Boolean(
|
Boolean(
|
||||||
@@ -3130,6 +3173,39 @@ void initializeDiscordPresenceService();
|
|||||||
const handleCliCommand = createCliCommandRuntimeHandler({
|
const handleCliCommand = createCliCommandRuntimeHandler({
|
||||||
handleTexthookerOnlyModeTransitionMainDeps: {
|
handleTexthookerOnlyModeTransitionMainDeps: {
|
||||||
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
||||||
|
ensureOverlayStartupPrereqs: () => {
|
||||||
|
if (appState.subtitlePosition === null) {
|
||||||
|
loadSubtitlePosition();
|
||||||
|
}
|
||||||
|
if (appState.keybindings.length === 0) {
|
||||||
|
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
|
||||||
|
}
|
||||||
|
if (!appState.mpvClient) {
|
||||||
|
appState.mpvClient = createMpvClientRuntimeService();
|
||||||
|
}
|
||||||
|
if (!appState.runtimeOptionsManager) {
|
||||||
|
appState.runtimeOptionsManager = new RuntimeOptionsManager(
|
||||||
|
() => configService.getConfig().ankiConnect,
|
||||||
|
{
|
||||||
|
applyAnkiPatch: (patch) => {
|
||||||
|
if (appState.ankiIntegration) {
|
||||||
|
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle,
|
||||||
|
onOptionsChanged: () => {
|
||||||
|
subtitleProcessingController.invalidateTokenizationCache();
|
||||||
|
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||||
|
broadcastRuntimeOptionsChanged();
|
||||||
|
refreshOverlayShortcuts();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!appState.subtitleTimingTracker) {
|
||||||
|
appState.subtitleTimingTracker = new SubtitleTimingTracker();
|
||||||
|
}
|
||||||
|
},
|
||||||
setTexthookerOnlyMode: (enabled) => {
|
setTexthookerOnlyMode: (enabled) => {
|
||||||
appState.texthookerOnlyMode = enabled;
|
appState.texthookerOnlyMode = enabled;
|
||||||
},
|
},
|
||||||
@@ -3219,6 +3295,9 @@ const {
|
|||||||
restoreMpvSubVisibility: () => {
|
restoreMpvSubVisibility: () => {
|
||||||
restoreOverlayMpvSubtitles();
|
restoreOverlayMpvSubtitles();
|
||||||
},
|
},
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => {
|
||||||
|
resetSubtitleSidebarEmbeddedLayoutRuntime();
|
||||||
|
},
|
||||||
getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(),
|
getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(),
|
||||||
resetAnilistMediaTracking: (mediaKey) => {
|
resetAnilistMediaTracking: (mediaKey) => {
|
||||||
resetAnilistMediaTracking(mediaKey);
|
resetAnilistMediaTracking(mediaKey);
|
||||||
@@ -3437,6 +3516,11 @@ function createMpvClientRuntimeService(): MpvIpcClient {
|
|||||||
return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetSubtitleSidebarEmbeddedLayoutRuntime(): void {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-margin-ratio-right', 0]);
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-pan-x', 0]);
|
||||||
|
}
|
||||||
|
|
||||||
function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void {
|
function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void {
|
||||||
updateMpvSubtitleRenderMetricsHandler(patch);
|
updateMpvSubtitleRenderMetricsHandler(patch);
|
||||||
}
|
}
|
||||||
@@ -3881,6 +3965,176 @@ async function loadSubtitleSourceText(source: string): Promise<string> {
|
|||||||
return fs.promises.readFile(filePath, 'utf8');
|
return fs.promises.readFile(filePath, 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MpvSubtitleTrackLike = {
|
||||||
|
type?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
selected?: unknown;
|
||||||
|
external?: unknown;
|
||||||
|
codec?: unknown;
|
||||||
|
'ff-index'?: unknown;
|
||||||
|
'external-filename'?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseTrackId(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveSubtitleTrack(
|
||||||
|
currentTrackRaw: unknown,
|
||||||
|
trackListRaw: unknown,
|
||||||
|
sidRaw: unknown,
|
||||||
|
): MpvSubtitleTrackLike | null {
|
||||||
|
if (currentTrackRaw && typeof currentTrackRaw === 'object') {
|
||||||
|
const track = currentTrackRaw as MpvSubtitleTrackLike;
|
||||||
|
if (track.type === undefined || track.type === 'sub') {
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = parseTrackId(sidRaw);
|
||||||
|
if (!Array.isArray(trackListRaw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bySid =
|
||||||
|
sid === null
|
||||||
|
? null
|
||||||
|
: ((trackListRaw.find((entry: unknown) => {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const track = entry as MpvSubtitleTrackLike;
|
||||||
|
return track.type === 'sub' && parseTrackId(track.id) === sid;
|
||||||
|
}) as MpvSubtitleTrackLike | undefined) ?? null);
|
||||||
|
if (bySid) {
|
||||||
|
return bySid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(trackListRaw.find((entry: unknown) => {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const track = entry as MpvSubtitleTrackLike;
|
||||||
|
return track.type === 'sub' && track.selected === true;
|
||||||
|
}) as MpvSubtitleTrackLike | undefined) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFfmpegSubtitleExtractionArgs(
|
||||||
|
videoPath: string,
|
||||||
|
ffIndex: number,
|
||||||
|
outputPath: string,
|
||||||
|
): string[] {
|
||||||
|
return [
|
||||||
|
'-hide_banner',
|
||||||
|
'-nostdin',
|
||||||
|
'-y',
|
||||||
|
'-loglevel',
|
||||||
|
'error',
|
||||||
|
'-an',
|
||||||
|
'-vn',
|
||||||
|
'-i',
|
||||||
|
videoPath,
|
||||||
|
'-map',
|
||||||
|
`0:${ffIndex}`,
|
||||||
|
'-f',
|
||||||
|
path.extname(outputPath).slice(1),
|
||||||
|
outputPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractInternalSubtitleTrackToTempFile(
|
||||||
|
ffmpegPath: string,
|
||||||
|
videoPath: string,
|
||||||
|
track: MpvSubtitleTrackLike,
|
||||||
|
): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
|
||||||
|
const ffIndex = parseTrackId(track['ff-index']);
|
||||||
|
const codec = typeof track.codec === 'string' ? track.codec : null;
|
||||||
|
const extension = codecToExtension(codec ?? undefined);
|
||||||
|
if (ffIndex === null || extension === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-'));
|
||||||
|
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
ffmpegPath,
|
||||||
|
buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath),
|
||||||
|
);
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: outputPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveActiveSubtitleSidebarSource(
|
||||||
|
currentExternalFilenameRaw: unknown,
|
||||||
|
currentTrackRaw: unknown,
|
||||||
|
trackListRaw: unknown,
|
||||||
|
sidRaw: unknown,
|
||||||
|
videoPath: string,
|
||||||
|
): Promise<{ path: string; sourceKey: string; cleanup?: () => Promise<void> } | null> {
|
||||||
|
const currentExternalFilename =
|
||||||
|
typeof currentExternalFilenameRaw === 'string' ? currentExternalFilenameRaw.trim() : '';
|
||||||
|
if (currentExternalFilename) {
|
||||||
|
return { path: currentExternalFilename, sourceKey: currentExternalFilename };
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = getActiveSubtitleTrack(currentTrackRaw, trackListRaw, sidRaw);
|
||||||
|
if (!track) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalFilename =
|
||||||
|
typeof track['external-filename'] === 'string' ? track['external-filename'].trim() : '';
|
||||||
|
if (externalFilename) {
|
||||||
|
return { path: externalFilename, sourceKey: externalFilename };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffmpegPath = getResolvedConfig().subsync.ffmpeg_path.trim() || 'ffmpeg';
|
||||||
|
const extracted = await extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track);
|
||||||
|
if (!extracted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...extracted,
|
||||||
|
sourceKey: buildSubtitleSidebarSourceKey(videoPath, track, extracted.path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({
|
const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
loadSubtitleSourceText,
|
loadSubtitleSourceText,
|
||||||
@@ -3938,9 +4192,99 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
quitApp: () => requestAppQuit(),
|
quitApp: () => requestAppQuit(),
|
||||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||||
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
|
tokenizeCurrentSubtitle: async () => withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
|
||||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||||
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
||||||
|
getSubtitleSidebarSnapshot: async () => {
|
||||||
|
const currentSubtitle = {
|
||||||
|
text: appState.currentSubText,
|
||||||
|
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||||
|
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||||
|
};
|
||||||
|
const currentTimeSec = appState.mpvClient?.currentTimePos ?? null;
|
||||||
|
const config = getResolvedConfig().subtitleSidebar;
|
||||||
|
const client = appState.mpvClient;
|
||||||
|
if (!client?.connected) {
|
||||||
|
return {
|
||||||
|
cues: appState.activeParsedSubtitleCues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
currentExternalFilenameRaw,
|
||||||
|
currentTrackRaw,
|
||||||
|
trackListRaw,
|
||||||
|
sidRaw,
|
||||||
|
videoPathRaw,
|
||||||
|
] = await Promise.all([
|
||||||
|
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
|
||||||
|
client.requestProperty('current-tracks/sub').catch(() => null),
|
||||||
|
client.requestProperty('track-list'),
|
||||||
|
client.requestProperty('sid'),
|
||||||
|
client.requestProperty('path'),
|
||||||
|
]);
|
||||||
|
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
||||||
|
if (!videoPath) {
|
||||||
|
return {
|
||||||
|
cues: appState.activeParsedSubtitleCues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSource = await resolveActiveSubtitleSidebarSource(
|
||||||
|
currentExternalFilenameRaw,
|
||||||
|
currentTrackRaw,
|
||||||
|
trackListRaw,
|
||||||
|
sidRaw,
|
||||||
|
videoPath,
|
||||||
|
);
|
||||||
|
if (!resolvedSource) {
|
||||||
|
return {
|
||||||
|
cues: appState.activeParsedSubtitleCues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
|
||||||
|
return {
|
||||||
|
cues: appState.activeParsedSubtitleCues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await loadSubtitleSourceText(resolvedSource.path);
|
||||||
|
const cues = parseSubtitleCues(content, resolvedSource.path);
|
||||||
|
appState.activeParsedSubtitleCues = cues;
|
||||||
|
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
|
||||||
|
return {
|
||||||
|
cues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await resolvedSource.cleanup?.();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
cues: appState.activeParsedSubtitleCues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
getPlaybackPaused: () => appState.playbackPaused,
|
getPlaybackPaused: () => appState.playbackPaused,
|
||||||
getSubtitlePosition: () => loadSubtitlePosition(),
|
getSubtitlePosition: () => loadSubtitlePosition(),
|
||||||
getSubtitleStyle: () => {
|
getSubtitleStyle: () => {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
|
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
|
||||||
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
|
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
|
||||||
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
||||||
|
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
|
||||||
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
|
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
|
||||||
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
||||||
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
|
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
|
||||||
@@ -212,6 +213,7 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
|
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
|
||||||
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
|
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
|
||||||
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
||||||
|
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
|
||||||
getPlaybackPaused: params.getPlaybackPaused,
|
getPlaybackPaused: params.getPlaybackPaused,
|
||||||
getSubtitlePosition: params.getSubtitlePosition,
|
getSubtitlePosition: params.getSubtitlePosition,
|
||||||
getSubtitleStyle: params.getSubtitleStyle,
|
getSubtitleStyle: params.getSubtitleStyle,
|
||||||
|
|||||||
@@ -150,6 +150,59 @@ test('auto sync skips rebuild/import on unchanged revisit when merged dictionary
|
|||||||
assert.deepEqual(imports, ['/tmp/merged.zip']);
|
assert.deepEqual(imports, ['/tmp/merged.zip']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('auto sync does not emit updating progress for unchanged revisit when merged dictionary is current', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
let importedRevision: string | null = null;
|
||||||
|
let currentRun: string[] = [];
|
||||||
|
const phaseHistory: string[][] = [];
|
||||||
|
|
||||||
|
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
maxLoaded: 3,
|
||||||
|
profileScope: 'all',
|
||||||
|
}),
|
||||||
|
getOrCreateCurrentSnapshot: async () => ({
|
||||||
|
mediaId: 7,
|
||||||
|
mediaTitle: 'Frieren',
|
||||||
|
entryCount: 100,
|
||||||
|
fromCache: true,
|
||||||
|
updatedAt: 1000,
|
||||||
|
}),
|
||||||
|
buildMergedDictionary: async () => ({
|
||||||
|
zipPath: '/tmp/merged.zip',
|
||||||
|
revision: 'rev-7',
|
||||||
|
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||||
|
entryCount: 100,
|
||||||
|
}),
|
||||||
|
getYomitanDictionaryInfo: async () =>
|
||||||
|
importedRevision
|
||||||
|
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||||
|
: [],
|
||||||
|
importYomitanDictionary: async () => {
|
||||||
|
importedRevision = 'rev-7';
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
deleteYomitanDictionary: async () => true,
|
||||||
|
upsertYomitanDictionarySettings: async () => false,
|
||||||
|
now: () => 1000,
|
||||||
|
onSyncStatus: (event) => {
|
||||||
|
currentRun.push(event.phase);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
currentRun = [];
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
phaseHistory.push([...currentRun]);
|
||||||
|
currentRun = [];
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
phaseHistory.push([...currentRun]);
|
||||||
|
|
||||||
|
assert.deepEqual(phaseHistory[0], ['building', 'importing', 'ready']);
|
||||||
|
assert.deepEqual(phaseHistory[1], ['ready']);
|
||||||
|
});
|
||||||
|
|
||||||
test('auto sync updates MRU order without rebuilding merged dictionary when membership is unchanged', async () => {
|
test('auto sync updates MRU order without rebuilding merged dictionary when membership is unchanged', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const sequence = [1, 2, 1];
|
const sequence = [1, 2, 1];
|
||||||
@@ -217,6 +270,63 @@ test('auto sync updates MRU order without rebuilding merged dictionary when memb
|
|||||||
assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '2 - Title 2']);
|
assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '2 - Title 2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('auto sync reimports existing merged zip without rebuilding on unchanged revisit', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||||
|
fs.mkdirSync(dictionariesDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dictionariesDir, 'merged.zip'), 'cached-zip', 'utf8');
|
||||||
|
const mergedBuilds: number[][] = [];
|
||||||
|
const imports: string[] = [];
|
||||||
|
let importedRevision: string | null = null;
|
||||||
|
|
||||||
|
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
maxLoaded: 3,
|
||||||
|
profileScope: 'all',
|
||||||
|
}),
|
||||||
|
getOrCreateCurrentSnapshot: async () => ({
|
||||||
|
mediaId: 7,
|
||||||
|
mediaTitle: 'Frieren',
|
||||||
|
entryCount: 100,
|
||||||
|
fromCache: true,
|
||||||
|
updatedAt: 1000,
|
||||||
|
}),
|
||||||
|
buildMergedDictionary: async (mediaIds) => {
|
||||||
|
mergedBuilds.push([...mediaIds]);
|
||||||
|
return {
|
||||||
|
zipPath: '/tmp/merged.zip',
|
||||||
|
revision: 'rev-7',
|
||||||
|
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||||
|
entryCount: 100,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getYomitanDictionaryInfo: async () =>
|
||||||
|
importedRevision
|
||||||
|
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||||
|
: [],
|
||||||
|
importYomitanDictionary: async (zipPath) => {
|
||||||
|
imports.push(zipPath);
|
||||||
|
importedRevision = 'rev-7';
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
deleteYomitanDictionary: async () => true,
|
||||||
|
upsertYomitanDictionarySettings: async () => true,
|
||||||
|
now: () => 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
importedRevision = null;
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
|
||||||
|
assert.deepEqual(mergedBuilds, [[7]]);
|
||||||
|
assert.deepEqual(imports, [
|
||||||
|
'/tmp/merged.zip',
|
||||||
|
path.join(userDataPath, 'character-dictionaries', 'merged.zip'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('auto sync evicts least recently used media from merged set', async () => {
|
test('auto sync evicts least recently used media from merged set', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const sequence = [1, 2, 3, 4];
|
const sequence = [1, 2, 3, 4];
|
||||||
@@ -537,12 +647,6 @@ test('auto sync emits progress events for start import and completion', async ()
|
|||||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
message: 'Generating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
message: 'Generating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
phase: 'syncing',
|
|
||||||
mediaId: 101291,
|
|
||||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
|
||||||
message: 'Updating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
phase: 'building',
|
phase: 'building',
|
||||||
mediaId: 101291,
|
mediaId: 101291,
|
||||||
|
|||||||
@@ -275,12 +275,6 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
});
|
});
|
||||||
currentMediaId = snapshot.mediaId;
|
currentMediaId = snapshot.mediaId;
|
||||||
currentMediaTitle = snapshot.mediaTitle;
|
currentMediaTitle = snapshot.mediaTitle;
|
||||||
deps.onSyncStatus?.({
|
|
||||||
phase: 'syncing',
|
|
||||||
mediaId: snapshot.mediaId,
|
|
||||||
mediaTitle: snapshot.mediaTitle,
|
|
||||||
message: buildSyncingMessage(snapshot.mediaTitle),
|
|
||||||
});
|
|
||||||
const state = readAutoSyncState(statePath);
|
const state = readAutoSyncState(statePath);
|
||||||
const nextActiveMediaIds = [
|
const nextActiveMediaIds = [
|
||||||
{
|
{
|
||||||
@@ -360,7 +354,17 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (merged === null) {
|
if (merged === null) {
|
||||||
merged = await deps.buildMergedDictionary(nextActiveMediaIdValues);
|
const existingMergedZipPath = path.join(dictionariesDir, 'merged.zip');
|
||||||
|
if (fs.existsSync(existingMergedZipPath)) {
|
||||||
|
merged = {
|
||||||
|
zipPath: existingMergedZipPath,
|
||||||
|
revision,
|
||||||
|
dictionaryTitle,
|
||||||
|
entryCount: snapshot.entryCount,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
merged = await deps.buildMergedDictionary(nextActiveMediaIdValues);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
|
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
|
||||||
const imported = await withOperationTimeout(
|
const imported = await withOperationTimeout(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ test('cli prechecks main deps builder maps transition handlers', () => {
|
|||||||
isTexthookerOnlyMode: () => true,
|
isTexthookerOnlyMode: () => true,
|
||||||
setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`),
|
setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`),
|
||||||
commandNeedsOverlayRuntime: () => true,
|
commandNeedsOverlayRuntime: () => true,
|
||||||
|
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||||
startBackgroundWarmups: () => calls.push('warmups'),
|
startBackgroundWarmups: () => calls.push('warmups'),
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
})();
|
})();
|
||||||
@@ -15,7 +16,8 @@ test('cli prechecks main deps builder maps transition handlers', () => {
|
|||||||
assert.equal(deps.isTexthookerOnlyMode(), true);
|
assert.equal(deps.isTexthookerOnlyMode(), true);
|
||||||
assert.equal(deps.commandNeedsOverlayRuntime({} as never), true);
|
assert.equal(deps.commandNeedsOverlayRuntime({} as never), true);
|
||||||
deps.setTexthookerOnlyMode(false);
|
deps.setTexthookerOnlyMode(false);
|
||||||
|
deps.ensureOverlayStartupPrereqs();
|
||||||
deps.startBackgroundWarmups();
|
deps.startBackgroundWarmups();
|
||||||
deps.logInfo('x');
|
deps.logInfo('x');
|
||||||
assert.deepEqual(calls, ['set:false', 'warmups', 'info:x']);
|
assert.deepEqual(calls, ['set:false', 'prereqs', 'warmups', 'info:x']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(dep
|
|||||||
isTexthookerOnlyMode: () => boolean;
|
isTexthookerOnlyMode: () => boolean;
|
||||||
setTexthookerOnlyMode: (enabled: boolean) => void;
|
setTexthookerOnlyMode: (enabled: boolean) => void;
|
||||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||||
|
ensureOverlayStartupPrereqs: () => void;
|
||||||
startBackgroundWarmups: () => void;
|
startBackgroundWarmups: () => void;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -11,6 +12,7 @@ export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(dep
|
|||||||
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
||||||
setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled),
|
setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled),
|
||||||
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
|
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
|
||||||
|
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
|
||||||
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
|
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ test('texthooker precheck no-ops when mode is disabled', () => {
|
|||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
setTexthookerOnlyMode: () => {},
|
setTexthookerOnlyMode: () => {},
|
||||||
commandNeedsOverlayRuntime: () => true,
|
commandNeedsOverlayRuntime: () => true,
|
||||||
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
startBackgroundWarmups: () => {
|
startBackgroundWarmups: () => {
|
||||||
warmups += 1;
|
warmups += 1;
|
||||||
},
|
},
|
||||||
@@ -22,12 +23,16 @@ test('texthooker precheck disables mode and warms up on start command', () => {
|
|||||||
let mode = true;
|
let mode = true;
|
||||||
let warmups = 0;
|
let warmups = 0;
|
||||||
let logs = 0;
|
let logs = 0;
|
||||||
|
let prereqs = 0;
|
||||||
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
|
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
|
||||||
isTexthookerOnlyMode: () => mode,
|
isTexthookerOnlyMode: () => mode,
|
||||||
setTexthookerOnlyMode: (enabled) => {
|
setTexthookerOnlyMode: (enabled) => {
|
||||||
mode = enabled;
|
mode = enabled;
|
||||||
},
|
},
|
||||||
commandNeedsOverlayRuntime: () => false,
|
commandNeedsOverlayRuntime: () => false,
|
||||||
|
ensureOverlayStartupPrereqs: () => {
|
||||||
|
prereqs += 1;
|
||||||
|
},
|
||||||
startBackgroundWarmups: () => {
|
startBackgroundWarmups: () => {
|
||||||
warmups += 1;
|
warmups += 1;
|
||||||
},
|
},
|
||||||
@@ -38,6 +43,7 @@ test('texthooker precheck disables mode and warms up on start command', () => {
|
|||||||
|
|
||||||
handlePrecheck({ start: true, texthooker: false } as never);
|
handlePrecheck({ start: true, texthooker: false } as never);
|
||||||
assert.equal(mode, false);
|
assert.equal(mode, false);
|
||||||
|
assert.equal(prereqs, 1);
|
||||||
assert.equal(warmups, 1);
|
assert.equal(warmups, 1);
|
||||||
assert.equal(logs, 1);
|
assert.equal(logs, 1);
|
||||||
});
|
});
|
||||||
@@ -50,6 +56,7 @@ test('texthooker precheck no-ops for texthooker command', () => {
|
|||||||
mode = enabled;
|
mode = enabled;
|
||||||
},
|
},
|
||||||
commandNeedsOverlayRuntime: () => true,
|
commandNeedsOverlayRuntime: () => true,
|
||||||
|
ensureOverlayStartupPrereqs: () => {},
|
||||||
startBackgroundWarmups: () => {},
|
startBackgroundWarmups: () => {},
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
|
|||||||
isTexthookerOnlyMode: () => boolean;
|
isTexthookerOnlyMode: () => boolean;
|
||||||
setTexthookerOnlyMode: (enabled: boolean) => void;
|
setTexthookerOnlyMode: (enabled: boolean) => void;
|
||||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||||
|
ensureOverlayStartupPrereqs: () => void;
|
||||||
startBackgroundWarmups: () => void;
|
startBackgroundWarmups: () => void;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -13,6 +14,7 @@ export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
|
|||||||
!args.texthooker &&
|
!args.texthooker &&
|
||||||
(args.start || deps.commandNeedsOverlayRuntime(args))
|
(args.start || deps.commandNeedsOverlayRuntime(args))
|
||||||
) {
|
) {
|
||||||
|
deps.ensureOverlayStartupPrereqs();
|
||||||
deps.setTexthookerOnlyMode(false);
|
deps.setTexthookerOnlyMode(false);
|
||||||
deps.logInfo('Disabling texthooker-only mode after overlay/start command.');
|
deps.logInfo('Disabling texthooker-only mode after overlay/start command.');
|
||||||
deps.startBackgroundWarmups();
|
deps.startBackgroundWarmups();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ test('cli command runtime handler applies precheck and forwards command with con
|
|||||||
isTexthookerOnlyMode: () => true,
|
isTexthookerOnlyMode: () => true,
|
||||||
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
||||||
commandNeedsOverlayRuntime: () => true,
|
commandNeedsOverlayRuntime: () => true,
|
||||||
|
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
|
||||||
startBackgroundWarmups: () => calls.push('warmups'),
|
startBackgroundWarmups: () => calls.push('warmups'),
|
||||||
logInfo: (message) => calls.push(`log:${message}`),
|
logInfo: (message) => calls.push(`log:${message}`),
|
||||||
},
|
},
|
||||||
@@ -24,6 +25,7 @@ test('cli command runtime handler applies precheck and forwards command with con
|
|||||||
handler({ start: true } as never);
|
handler({ start: true } as never);
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
|
'prereqs',
|
||||||
'set-mode',
|
'set-mode',
|
||||||
'log:Disabling texthooker-only mode after overlay/start command.',
|
'log:Disabling texthooker-only mode after overlay/start command.',
|
||||||
'warmups',
|
'warmups',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
|
|||||||
return {
|
return {
|
||||||
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
|
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
|
||||||
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
||||||
|
subtitleSidebar: config.subtitleSidebar,
|
||||||
secondarySubMode: config.secondarySub.defaultMode,
|
secondarySubMode: config.secondarySub.defaultMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,11 +82,13 @@ test('media path change handler reports stop for empty path and probes media key
|
|||||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||||
getCurrentAnilistMediaKey: () => 'show:1',
|
getCurrentAnilistMediaKey: () => 'show:1',
|
||||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||||
syncImmersionMediaState: () => calls.push('sync'),
|
syncImmersionMediaState: () => calls.push('sync'),
|
||||||
|
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
|
||||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||||
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
||||||
refreshDiscordPresence: () => calls.push('presence'),
|
refreshDiscordPresence: () => calls.push('presence'),
|
||||||
@@ -94,7 +96,9 @@ test('media path change handler reports stop for empty path and probes media key
|
|||||||
|
|
||||||
handler({ path: '' });
|
handler({ path: '' });
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
|
'flush-playback',
|
||||||
'path:',
|
'path:',
|
||||||
|
'reset-sidebar-layout',
|
||||||
'stopped',
|
'stopped',
|
||||||
'restore-mpv-sub',
|
'restore-mpv-sub',
|
||||||
'reset:show:1',
|
'reset:show:1',
|
||||||
@@ -111,11 +115,13 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
|
|||||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||||
getCurrentAnilistMediaKey: () => null,
|
getCurrentAnilistMediaKey: () => null,
|
||||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||||
syncImmersionMediaState: () => calls.push('sync'),
|
syncImmersionMediaState: () => calls.push('sync'),
|
||||||
|
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
|
||||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||||
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
||||||
refreshDiscordPresence: () => calls.push('presence'),
|
refreshDiscordPresence: () => calls.push('presence'),
|
||||||
@@ -125,6 +131,7 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
|
|||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'path:/tmp/video.mkv',
|
'path:/tmp/video.mkv',
|
||||||
|
'reset-sidebar-layout',
|
||||||
'reset:null',
|
'reset:null',
|
||||||
'sync',
|
'sync',
|
||||||
'dict-sync',
|
'dict-sync',
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
|||||||
updateCurrentMediaPath: (path: string) => void;
|
updateCurrentMediaPath: (path: string) => void;
|
||||||
reportJellyfinRemoteStopped: () => void;
|
reportJellyfinRemoteStopped: () => void;
|
||||||
restoreMpvSubVisibility: () => void;
|
restoreMpvSubVisibility: () => void;
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => void;
|
||||||
getCurrentAnilistMediaKey: () => string | null;
|
getCurrentAnilistMediaKey: () => string | null;
|
||||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||||
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
||||||
@@ -53,11 +54,16 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
|||||||
syncImmersionMediaState: () => void;
|
syncImmersionMediaState: () => void;
|
||||||
scheduleCharacterDictionarySync?: () => void;
|
scheduleCharacterDictionarySync?: () => void;
|
||||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||||
|
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
||||||
refreshDiscordPresence: () => void;
|
refreshDiscordPresence: () => void;
|
||||||
}) {
|
}) {
|
||||||
return ({ path }: { path: string | null }): void => {
|
return ({ path }: { path: string | null }): void => {
|
||||||
const normalizedPath = typeof path === 'string' ? path : '';
|
const normalizedPath = typeof path === 'string' ? path : '';
|
||||||
|
if (!normalizedPath) {
|
||||||
|
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
|
||||||
|
}
|
||||||
deps.updateCurrentMediaPath(normalizedPath);
|
deps.updateCurrentMediaPath(normalizedPath);
|
||||||
|
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||||
if (!normalizedPath) {
|
if (!normalizedPath) {
|
||||||
deps.reportJellyfinRemoteStopped();
|
deps.reportJellyfinRemoteStopped();
|
||||||
deps.restoreMpvSubVisibility();
|
deps.restoreMpvSubVisibility();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
const bind = createBindMpvMainEventHandlersHandler({
|
const bind = createBindMpvMainEventHandlersHandler({
|
||||||
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'),
|
||||||
hasInitialJellyfinPlayArg: () => false,
|
hasInitialJellyfinPlayArg: () => false,
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
isQuitOnDisconnectArmed: () => false,
|
isQuitOnDisconnectArmed: () => false,
|
||||||
@@ -44,6 +45,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||||
syncImmersionMediaState: () => calls.push('sync-immersion'),
|
syncImmersionMediaState: () => calls.push('sync-immersion'),
|
||||||
|
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
|
||||||
|
|
||||||
updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`),
|
updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`),
|
||||||
resetAnilistMediaGuessState: () => calls.push('reset-guess-state'),
|
resetAnilistMediaGuessState: () => calls.push('reset-guess-state'),
|
||||||
@@ -66,6 +68,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
handlers.get('connection-change')?.({ connected: true });
|
||||||
handlers.get('subtitle-change')?.({ text: 'line' });
|
handlers.get('subtitle-change')?.({ text: 'line' });
|
||||||
handlers.get('subtitle-track-change')?.({ sid: 3 });
|
handlers.get('subtitle-track-change')?.({ sid: 3 });
|
||||||
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
||||||
@@ -75,6 +78,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
handlers.get('pause-change')?.({ paused: true });
|
handlers.get('pause-change')?.({ paused: true });
|
||||||
|
|
||||||
assert.ok(calls.includes('set-sub:line'));
|
assert.ok(calls.includes('set-sub:line'));
|
||||||
|
assert.ok(calls.includes('reset-sidebar-layout'));
|
||||||
assert.ok(calls.includes('broadcast-sub:line'));
|
assert.ok(calls.includes('broadcast-sub:line'));
|
||||||
assert.ok(calls.includes('subtitle-change:line'));
|
assert.ok(calls.includes('subtitle-change:line'));
|
||||||
assert.ok(calls.includes('subtitle-track-change'));
|
assert.ok(calls.includes('subtitle-track-change'));
|
||||||
@@ -86,4 +90,6 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
assert.ok(calls.includes('progress:normal'));
|
assert.ok(calls.includes('progress:normal'));
|
||||||
assert.ok(calls.includes('progress:force'));
|
assert.ok(calls.includes('progress:force'));
|
||||||
assert.ok(calls.includes('presence-refresh'));
|
assert.ok(calls.includes('presence-refresh'));
|
||||||
|
assert.ok(calls.includes('sync-immersion'));
|
||||||
|
assert.ok(calls.includes('flush-playback'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandl
|
|||||||
export function createBindMpvMainEventHandlersHandler(deps: {
|
export function createBindMpvMainEventHandlersHandler(deps: {
|
||||||
reportJellyfinRemoteStopped: () => void;
|
reportJellyfinRemoteStopped: () => void;
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
syncOverlayMpvSubtitleSuppression: () => void;
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => void;
|
||||||
scheduleCharacterDictionarySync?: () => void;
|
scheduleCharacterDictionarySync?: () => void;
|
||||||
hasInitialJellyfinPlayArg: () => boolean;
|
hasInitialJellyfinPlayArg: () => boolean;
|
||||||
isOverlayRuntimeInitialized: () => boolean;
|
isOverlayRuntimeInitialized: () => boolean;
|
||||||
@@ -56,6 +57,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
||||||
syncImmersionMediaState: () => void;
|
syncImmersionMediaState: () => void;
|
||||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||||
|
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
||||||
|
|
||||||
updateCurrentMediaTitle: (title: string) => void;
|
updateCurrentMediaTitle: (title: string) => void;
|
||||||
resetAnilistMediaGuessState: () => void;
|
resetAnilistMediaGuessState: () => void;
|
||||||
@@ -82,6 +84,12 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
isMpvConnected: () => deps.isMpvConnected(),
|
isMpvConnected: () => deps.isMpvConnected(),
|
||||||
quitApp: () => deps.quitApp(),
|
quitApp: () => deps.quitApp(),
|
||||||
});
|
});
|
||||||
|
const handleMpvConnectionChangeWithSidebarReset = ({ connected }: { connected: boolean }): void => {
|
||||||
|
if (connected) {
|
||||||
|
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||||
|
}
|
||||||
|
handleMpvConnectionChange({ connected });
|
||||||
|
};
|
||||||
const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
|
const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
|
||||||
recordImmersionSubtitleLine: (text, start, end) =>
|
recordImmersionSubtitleLine: (text, start, end) =>
|
||||||
deps.recordImmersionSubtitleLine(text, start, end),
|
deps.recordImmersionSubtitleLine(text, start, end),
|
||||||
@@ -109,11 +117,14 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
|
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
|
||||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||||
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout(),
|
||||||
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
||||||
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
|
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
|
||||||
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
|
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
|
||||||
ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey),
|
ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||||
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
||||||
|
flushPlaybackPositionOnMediaPathClear: (mediaPath) =>
|
||||||
|
deps.flushPlaybackPositionOnMediaPathClear?.(mediaPath),
|
||||||
signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path),
|
signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path),
|
||||||
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
||||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||||
@@ -147,7 +158,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createBindMpvClientEventHandlers({
|
createBindMpvClientEventHandlers({
|
||||||
onConnectionChange: handleMpvConnectionChange,
|
onConnectionChange: handleMpvConnectionChangeWithSidebarReset,
|
||||||
onSubtitleChange: handleMpvSubtitleChange,
|
onSubtitleChange: handleMpvSubtitleChange,
|
||||||
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
||||||
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
const appState = {
|
const appState = {
|
||||||
initialArgs: { jellyfinPlay: true },
|
initialArgs: { jellyfinPlay: true },
|
||||||
overlayRuntimeInitialized: true,
|
overlayRuntimeInitialized: true,
|
||||||
mpvClient: { connected: true },
|
mpvClient: {
|
||||||
|
connected: true,
|
||||||
|
currentTimePos: 12.25,
|
||||||
|
requestProperty: async () => 18.75,
|
||||||
|
},
|
||||||
immersionTracker: {
|
immersionTracker: {
|
||||||
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
|
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
|
||||||
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
|
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
|
||||||
@@ -43,6 +47,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
|
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
|
||||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||||
getCurrentAnilistMediaKey: () => 'media-key',
|
getCurrentAnilistMediaKey: () => 'media-key',
|
||||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
|
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
|
||||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||||
@@ -78,6 +83,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
deps.broadcastSecondarySubtitle('sec');
|
deps.broadcastSecondarySubtitle('sec');
|
||||||
deps.updateCurrentMediaPath('/tmp/video');
|
deps.updateCurrentMediaPath('/tmp/video');
|
||||||
deps.restoreMpvSubVisibility();
|
deps.restoreMpvSubVisibility();
|
||||||
|
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||||
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
|
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
|
||||||
deps.resetAnilistMediaTracking('media-key');
|
deps.resetAnilistMediaTracking('media-key');
|
||||||
deps.maybeProbeAnilistDuration('media-key');
|
deps.maybeProbeAnilistDuration('media-key');
|
||||||
@@ -92,6 +98,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
deps.recordPauseState(true);
|
deps.recordPauseState(true);
|
||||||
deps.updateSubtitleRenderMetrics({});
|
deps.updateSubtitleRenderMetrics({});
|
||||||
deps.setPreviousSecondarySubVisibility(true);
|
deps.setPreviousSecondarySubVisibility(true);
|
||||||
|
deps.flushPlaybackPositionOnMediaPathClear?.('');
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
assert.equal(appState.currentSubText, 'sub');
|
assert.equal(appState.currentSubText, 'sub');
|
||||||
assert.equal(appState.currentSubAssText, 'ass');
|
assert.equal(appState.currentSubAssText, 'ass');
|
||||||
@@ -106,4 +114,5 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
assert.ok(calls.includes('metrics'));
|
assert.ok(calls.includes('metrics'));
|
||||||
assert.ok(calls.includes('presence-refresh'));
|
assert.ok(calls.includes('presence-refresh'));
|
||||||
assert.ok(calls.includes('restore-mpv-sub'));
|
assert.ok(calls.includes('restore-mpv-sub'));
|
||||||
|
assert.ok(calls.includes('reset-sidebar-layout'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
appState: {
|
appState: {
|
||||||
initialArgs?: { jellyfinPlay?: unknown } | null;
|
initialArgs?: { jellyfinPlay?: unknown } | null;
|
||||||
overlayRuntimeInitialized: boolean;
|
overlayRuntimeInitialized: boolean;
|
||||||
mpvClient: { connected?: boolean; currentSecondarySubText?: string } | null;
|
mpvClient:
|
||||||
|
| {
|
||||||
|
connected?: boolean;
|
||||||
|
currentSecondarySubText?: string;
|
||||||
|
currentTimePos?: number;
|
||||||
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
immersionTracker: {
|
immersionTracker: {
|
||||||
recordSubtitleLine?: (
|
recordSubtitleLine?: (
|
||||||
text: string,
|
text: string,
|
||||||
@@ -21,6 +28,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
subtitleTimingTracker: {
|
subtitleTimingTracker: {
|
||||||
recordSubtitle?: (text: string, start: number, end: number) => void;
|
recordSubtitle?: (text: string, start: number, end: number) => void;
|
||||||
} | null;
|
} | null;
|
||||||
|
currentMediaPath?: string | null;
|
||||||
currentSubText: string;
|
currentSubText: string;
|
||||||
currentSubAssText: string;
|
currentSubAssText: string;
|
||||||
currentSubtitleData?: SubtitleData | null;
|
currentSubtitleData?: SubtitleData | null;
|
||||||
@@ -42,6 +50,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||||
updateCurrentMediaPath: (path: string) => void;
|
updateCurrentMediaPath: (path: string) => void;
|
||||||
restoreMpvSubVisibility: () => void;
|
restoreMpvSubVisibility: () => void;
|
||||||
|
resetSubtitleSidebarEmbeddedLayout?: () => void;
|
||||||
getCurrentAnilistMediaKey: () => string | null;
|
getCurrentAnilistMediaKey: () => string | null;
|
||||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||||
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
||||||
@@ -58,6 +67,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
ensureImmersionTrackerInitialized: () => void;
|
ensureImmersionTrackerInitialized: () => void;
|
||||||
tokenizeSubtitleForImmersion?: (text: string) => Promise<SubtitleData | null>;
|
tokenizeSubtitleForImmersion?: (text: string) => Promise<SubtitleData | null>;
|
||||||
}) {
|
}) {
|
||||||
|
const writePlaybackPositionFromMpv = (timeSec: unknown): void => {
|
||||||
|
const normalizedTimeSec = Number(timeSec);
|
||||||
|
if (!Number.isFinite(normalizedTimeSec)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.ensureImmersionTrackerInitialized();
|
||||||
|
deps.appState.immersionTracker?.recordPlaybackPosition?.(normalizedTimeSec);
|
||||||
|
};
|
||||||
|
|
||||||
return () => ({
|
return () => ({
|
||||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||||
@@ -129,6 +147,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
||||||
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
|
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
|
||||||
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout?.(),
|
||||||
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
||||||
resetAnilistMediaTracking: (mediaKey: string | null) =>
|
resetAnilistMediaTracking: (mediaKey: string | null) =>
|
||||||
deps.resetAnilistMediaTracking(mediaKey),
|
deps.resetAnilistMediaTracking(mediaKey),
|
||||||
@@ -161,6 +180,25 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
deps.ensureImmersionTrackerInitialized();
|
deps.ensureImmersionTrackerInitialized();
|
||||||
deps.appState.immersionTracker?.recordPauseState?.(paused);
|
deps.appState.immersionTracker?.recordPauseState?.(paused);
|
||||||
},
|
},
|
||||||
|
flushPlaybackPositionOnMediaPathClear: (mediaPath: string) => {
|
||||||
|
const mpvClient = deps.appState.mpvClient;
|
||||||
|
const currentKnownTime = Number(mpvClient?.currentTimePos);
|
||||||
|
writePlaybackPositionFromMpv(currentKnownTime);
|
||||||
|
if (!mpvClient?.requestProperty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void mpvClient.requestProperty('time-pos').then((timePos) => {
|
||||||
|
const currentPath = (deps.appState.currentMediaPath ?? '').trim();
|
||||||
|
if (currentPath.length > 0 && currentPath !== mediaPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolvedTime = Number(timePos);
|
||||||
|
if (Number.isFinite(currentKnownTime) && Number.isFinite(resolvedTime) && currentKnownTime === resolvedTime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writePlaybackPositionFromMpv(resolvedTime);
|
||||||
|
});
|
||||||
|
},
|
||||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
|
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
|
||||||
deps.updateSubtitleRenderMetrics(patch),
|
deps.updateSubtitleRenderMetrics(patch),
|
||||||
setPreviousSecondarySubVisibility: (visible: boolean) => {
|
setPreviousSecondarySubVisibility: (visible: boolean) => {
|
||||||
|
|||||||
32
src/main/runtime/startup-autoplay-release-policy.test.ts
Normal file
32
src/main/runtime/startup-autoplay-release-policy.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS,
|
||||||
|
resolveAutoplayReadyMaxReleaseAttempts,
|
||||||
|
STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS,
|
||||||
|
} from './startup-autoplay-release-policy';
|
||||||
|
|
||||||
|
test('autoplay release keeps the short retry budget for normal playback signals', () => {
|
||||||
|
assert.equal(resolveAutoplayReadyMaxReleaseAttempts(), 3);
|
||||||
|
assert.equal(resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: false }), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autoplay release uses the full startup timeout window while paused', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: true }),
|
||||||
|
Math.ceil(
|
||||||
|
STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS / DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autoplay release rounds up custom paused retry budgets to cover the timeout window', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveAutoplayReadyMaxReleaseAttempts({
|
||||||
|
forceWhilePaused: true,
|
||||||
|
retryDelayMs: 300,
|
||||||
|
startupTimeoutMs: 1_000,
|
||||||
|
}),
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
});
|
||||||
28
src/main/runtime/startup-autoplay-release-policy.ts
Normal file
28
src/main/runtime/startup-autoplay-release-policy.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS = 200;
|
||||||
|
const STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
export function resolveAutoplayReadyMaxReleaseAttempts(options?: {
|
||||||
|
forceWhilePaused?: boolean;
|
||||||
|
retryDelayMs?: number;
|
||||||
|
startupTimeoutMs?: number;
|
||||||
|
}): number {
|
||||||
|
if (options?.forceWhilePaused !== true) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryDelayMs = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(options.retryDelayMs ?? DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS),
|
||||||
|
);
|
||||||
|
const startupTimeoutMs = Math.max(
|
||||||
|
retryDelayMs,
|
||||||
|
Math.floor(options.startupTimeoutMs ?? STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Math.max(3, Math.ceil(startupTimeoutMs / retryDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS,
|
||||||
|
STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS,
|
||||||
|
};
|
||||||
@@ -62,7 +62,10 @@ test('startup OSD buffers checking behind annotations and replaces it with later
|
|||||||
makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'),
|
makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
|
assert.deepEqual(osdMessages, [
|
||||||
|
'Loading subtitle annotations |',
|
||||||
|
'Generating character dictionary for Frieren...',
|
||||||
|
]);
|
||||||
|
|
||||||
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||||
|
|
||||||
@@ -154,3 +157,30 @@ test('startup OSD reset keeps tokenization ready after first warmup', () => {
|
|||||||
|
|
||||||
assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']);
|
assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('startup OSD shows later dictionary progress immediately once tokenization is ready', () => {
|
||||||
|
const osdMessages: string[] = [];
|
||||||
|
const sequencer = createStartupOsdSequencer({
|
||||||
|
showOsd: (message) => {
|
||||||
|
osdMessages.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
sequencer.showAnnotationLoading('Loading subtitle annotations |');
|
||||||
|
sequencer.markTokenizationReady();
|
||||||
|
sequencer.notifyCharacterDictionaryStatus(
|
||||||
|
makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(osdMessages, [
|
||||||
|
'Loading subtitle annotations |',
|
||||||
|
'Generating character dictionary for Frieren...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||||
|
|
||||||
|
assert.deepEqual(osdMessages, [
|
||||||
|
'Loading subtitle annotations |',
|
||||||
|
'Generating character dictionary for Frieren...',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (pendingDictionaryProgress) {
|
if (pendingDictionaryProgress) {
|
||||||
|
if (dictionaryProgressShown) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
deps.showOsd(pendingDictionaryProgress.message);
|
deps.showOsd(pendingDictionaryProgress.message);
|
||||||
dictionaryProgressShown = true;
|
dictionaryProgressShown = true;
|
||||||
return true;
|
return true;
|
||||||
@@ -84,6 +87,9 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
|||||||
if (canShowDictionaryStatus()) {
|
if (canShowDictionaryStatus()) {
|
||||||
deps.showOsd(event.message);
|
deps.showOsd(event.message);
|
||||||
dictionaryProgressShown = true;
|
dictionaryProgressShown = true;
|
||||||
|
} else if (tokenizationReady) {
|
||||||
|
deps.showOsd(event.message);
|
||||||
|
dictionaryProgressShown = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
|
|
||||||
import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch';
|
import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch';
|
||||||
|
import type { SubtitleCue } from '../../types';
|
||||||
import { createSubtitlePrefetchInitController } from './subtitle-prefetch-init';
|
import { createSubtitlePrefetchInitController } from './subtitle-prefetch-init';
|
||||||
|
|
||||||
function createDeferred<T>(): {
|
function createDeferred<T>(): {
|
||||||
@@ -112,3 +112,125 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic
|
|||||||
assert.equal(currentService, null);
|
assert.equal(currentService, null);
|
||||||
assert.deepEqual(started, []);
|
assert.deepEqual(started, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle prefetch init publishes parsed cues and clears them on cancel', async () => {
|
||||||
|
const deferred = createDeferred<string>();
|
||||||
|
let currentService: SubtitlePrefetchService | null = null;
|
||||||
|
const cueUpdates: Array<SubtitleCue[] | null> = [];
|
||||||
|
|
||||||
|
const controller = createSubtitlePrefetchInitController({
|
||||||
|
getCurrentService: () => currentService,
|
||||||
|
setCurrentService: (service) => {
|
||||||
|
currentService = service;
|
||||||
|
},
|
||||||
|
loadSubtitleSourceText: async () => await deferred.promise,
|
||||||
|
parseSubtitleCues: () => [
|
||||||
|
{ startTime: 1, endTime: 2, text: 'first' },
|
||||||
|
{ startTime: 3, endTime: 4, text: 'second' },
|
||||||
|
],
|
||||||
|
createSubtitlePrefetchService: () => ({
|
||||||
|
start: () => {},
|
||||||
|
stop: () => {},
|
||||||
|
onSeek: () => {},
|
||||||
|
pause: () => {},
|
||||||
|
resume: () => {},
|
||||||
|
}),
|
||||||
|
tokenizeSubtitle: async () => null,
|
||||||
|
preCacheTokenization: () => {},
|
||||||
|
isCacheFull: () => false,
|
||||||
|
logInfo: () => {},
|
||||||
|
logWarn: () => {},
|
||||||
|
onParsedSubtitleCuesChanged: (cues) => {
|
||||||
|
cueUpdates.push(cues);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const initPromise = controller.initSubtitlePrefetch('episode.ass', 12);
|
||||||
|
deferred.resolve('content');
|
||||||
|
await initPromise;
|
||||||
|
|
||||||
|
controller.cancelPendingInit();
|
||||||
|
|
||||||
|
assert.deepEqual(cueUpdates, [
|
||||||
|
[
|
||||||
|
{ startTime: 1, endTime: 2, text: 'first' },
|
||||||
|
{ startTime: 3, endTime: 4, text: 'second' },
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle prefetch init publishes the provided stable source key instead of the load path', async () => {
|
||||||
|
const deferred = createDeferred<string>();
|
||||||
|
let currentService: SubtitlePrefetchService | null = null;
|
||||||
|
const sourceUpdates: Array<string | null> = [];
|
||||||
|
|
||||||
|
const controller = createSubtitlePrefetchInitController({
|
||||||
|
getCurrentService: () => currentService,
|
||||||
|
setCurrentService: (service) => {
|
||||||
|
currentService = service;
|
||||||
|
},
|
||||||
|
loadSubtitleSourceText: async () => await deferred.promise,
|
||||||
|
parseSubtitleCues: () => [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||||
|
createSubtitlePrefetchService: () => ({
|
||||||
|
start: () => {},
|
||||||
|
stop: () => {},
|
||||||
|
onSeek: () => {},
|
||||||
|
pause: () => {},
|
||||||
|
resume: () => {},
|
||||||
|
}),
|
||||||
|
tokenizeSubtitle: async () => null,
|
||||||
|
preCacheTokenization: () => {},
|
||||||
|
isCacheFull: () => false,
|
||||||
|
logInfo: () => {},
|
||||||
|
logWarn: () => {},
|
||||||
|
onParsedSubtitleCuesChanged: (_cues, source) => {
|
||||||
|
sourceUpdates.push(source);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const initPromise = controller.initSubtitlePrefetch(
|
||||||
|
'/tmp/subminer-sidebar-123/track_7.ass',
|
||||||
|
12,
|
||||||
|
'internal:/media/episode01.mkv:track:3:ff:7',
|
||||||
|
);
|
||||||
|
deferred.resolve('content');
|
||||||
|
await initPromise;
|
||||||
|
|
||||||
|
assert.deepEqual(sourceUpdates, ['internal:/media/episode01.mkv:track:3:ff:7']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle prefetch init clears parsed cues when initialization fails', async () => {
|
||||||
|
const cueUpdates: Array<SubtitleCue[] | null> = [];
|
||||||
|
let currentService: SubtitlePrefetchService | null = null;
|
||||||
|
|
||||||
|
const controller = createSubtitlePrefetchInitController({
|
||||||
|
getCurrentService: () => currentService,
|
||||||
|
setCurrentService: (service) => {
|
||||||
|
currentService = service;
|
||||||
|
},
|
||||||
|
loadSubtitleSourceText: async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
},
|
||||||
|
parseSubtitleCues: () => [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||||
|
createSubtitlePrefetchService: () => ({
|
||||||
|
start: () => {},
|
||||||
|
stop: () => {},
|
||||||
|
onSeek: () => {},
|
||||||
|
pause: () => {},
|
||||||
|
resume: () => {},
|
||||||
|
}),
|
||||||
|
tokenizeSubtitle: async () => null,
|
||||||
|
preCacheTokenization: () => {},
|
||||||
|
isCacheFull: () => false,
|
||||||
|
logInfo: () => {},
|
||||||
|
logWarn: () => {},
|
||||||
|
onParsedSubtitleCuesChanged: (cues) => {
|
||||||
|
cueUpdates.push(cues);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.initSubtitlePrefetch('episode.ass', 12);
|
||||||
|
|
||||||
|
assert.deepEqual(cueUpdates, [null]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { SubtitleCue } from '../../core/services/subtitle-cue-parser';
|
|
||||||
import type {
|
import type {
|
||||||
SubtitlePrefetchService,
|
SubtitlePrefetchService,
|
||||||
SubtitlePrefetchServiceDeps,
|
SubtitlePrefetchServiceDeps,
|
||||||
} from '../../core/services/subtitle-prefetch';
|
} from '../../core/services/subtitle-prefetch';
|
||||||
import type { SubtitleData } from '../../types';
|
import type { SubtitleData } from '../../types';
|
||||||
|
import type { SubtitleCue } from '../../types';
|
||||||
|
|
||||||
export interface SubtitlePrefetchInitControllerDeps {
|
export interface SubtitlePrefetchInitControllerDeps {
|
||||||
getCurrentService: () => SubtitlePrefetchService | null;
|
getCurrentService: () => SubtitlePrefetchService | null;
|
||||||
@@ -16,11 +16,16 @@ export interface SubtitlePrefetchInitControllerDeps {
|
|||||||
isCacheFull: () => boolean;
|
isCacheFull: () => boolean;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
|
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubtitlePrefetchInitController {
|
export interface SubtitlePrefetchInitController {
|
||||||
cancelPendingInit: () => void;
|
cancelPendingInit: () => void;
|
||||||
initSubtitlePrefetch: (externalFilename: string, currentTimePos: number) => Promise<void>;
|
initSubtitlePrefetch: (
|
||||||
|
sourcePath: string,
|
||||||
|
currentTimePos: number,
|
||||||
|
sourceKey?: string,
|
||||||
|
) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSubtitlePrefetchInitController(
|
export function createSubtitlePrefetchInitController(
|
||||||
@@ -32,24 +37,29 @@ export function createSubtitlePrefetchInitController(
|
|||||||
initRevision += 1;
|
initRevision += 1;
|
||||||
deps.getCurrentService()?.stop();
|
deps.getCurrentService()?.stop();
|
||||||
deps.setCurrentService(null);
|
deps.setCurrentService(null);
|
||||||
|
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const initSubtitlePrefetch = async (
|
const initSubtitlePrefetch = async (
|
||||||
externalFilename: string,
|
sourcePath: string,
|
||||||
currentTimePos: number,
|
currentTimePos: number,
|
||||||
|
sourceKey = sourcePath,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const revision = ++initRevision;
|
const revision = ++initRevision;
|
||||||
deps.getCurrentService()?.stop();
|
deps.getCurrentService()?.stop();
|
||||||
deps.setCurrentService(null);
|
deps.setCurrentService(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await deps.loadSubtitleSourceText(externalFilename);
|
const content = await deps.loadSubtitleSourceText(sourcePath);
|
||||||
if (revision !== initRevision) {
|
if (revision !== initRevision) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cues = deps.parseSubtitleCues(content, externalFilename);
|
const cues = deps.parseSubtitleCues(content, sourcePath);
|
||||||
if (revision !== initRevision || cues.length === 0) {
|
if (revision !== initRevision || cues.length === 0) {
|
||||||
|
if (revision === initRevision) {
|
||||||
|
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,12 +75,14 @@ export function createSubtitlePrefetchInitController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
deps.setCurrentService(nextService);
|
deps.setCurrentService(nextService);
|
||||||
|
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
|
||||||
nextService.start(currentTimePos);
|
nextService.start(currentTimePos);
|
||||||
deps.logInfo(
|
deps.logInfo(
|
||||||
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`,
|
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${sourcePath}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (revision === initRevision) {
|
if (revision === initRevision) {
|
||||||
|
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||||
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
|
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
buildSubtitleSidebarSourceKey,
|
||||||
getActiveExternalSubtitleSource,
|
getActiveExternalSubtitleSource,
|
||||||
resolveSubtitleSourcePath,
|
resolveSubtitleSourcePath,
|
||||||
} from './subtitle-prefetch-source';
|
} from './subtitle-prefetch-source';
|
||||||
@@ -17,6 +18,15 @@ test('getActiveExternalSubtitleSource returns the active external subtitle path'
|
|||||||
assert.equal(source, 'https://host/subs.ass');
|
assert.equal(source, 'https://host/subs.ass');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getActiveExternalSubtitleSource normalizes integer-like string track ids', () => {
|
||||||
|
const source = getActiveExternalSubtitleSource(
|
||||||
|
[{ type: 'sub', id: '2', external: true, 'external-filename': ' /tmp/subs.ass ' }],
|
||||||
|
'2',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(source, '/tmp/subs.ass');
|
||||||
|
});
|
||||||
|
|
||||||
test('getActiveExternalSubtitleSource returns null when the selected track is not external', () => {
|
test('getActiveExternalSubtitleSource returns null when the selected track is not external', () => {
|
||||||
const source = getActiveExternalSubtitleSource(
|
const source = getActiveExternalSubtitleSource(
|
||||||
[{ type: 'sub', id: 2, external: false, 'external-filename': '/tmp/subs.ass' }],
|
[{ type: 'sub', id: 2, external: false, 'external-filename': '/tmp/subs.ass' }],
|
||||||
@@ -48,3 +58,38 @@ test('resolveSubtitleSourcePath returns the original source for malformed file U
|
|||||||
|
|
||||||
assert.equal(resolveSubtitleSourcePath(source), source);
|
assert.equal(resolveSubtitleSourcePath(source), source);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildSubtitleSidebarSourceKey uses a stable identifier for internal subtitle tracks', () => {
|
||||||
|
const firstKey = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
|
||||||
|
id: 3,
|
||||||
|
'ff-index': 7,
|
||||||
|
title: 'English',
|
||||||
|
lang: 'eng',
|
||||||
|
codec: 'ass',
|
||||||
|
});
|
||||||
|
const secondKey = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
|
||||||
|
id: 3,
|
||||||
|
'ff-index': 7,
|
||||||
|
title: 'English',
|
||||||
|
lang: 'eng',
|
||||||
|
codec: 'ass',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(firstKey, secondKey);
|
||||||
|
assert.equal(firstKey, 'internal:/media/episode01.mkv:track:3:ff:7');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSubtitleSidebarSourceKey normalizes integer-like string track metadata', () => {
|
||||||
|
const key = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
|
||||||
|
id: '3',
|
||||||
|
'ff-index': '7',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(key, 'internal:/media/episode01.mkv:track:3:ff:7');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSubtitleSidebarSourceKey falls back to source path when no track metadata is available', () => {
|
||||||
|
const key = buildSubtitleSidebarSourceKey('/media/episode01.mkv', null, '/tmp/subtitle.ass');
|
||||||
|
|
||||||
|
assert.equal(key, '/tmp/subtitle.ass');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
function parseTrackId(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getActiveExternalSubtitleSource(
|
export function getActiveExternalSubtitleSource(
|
||||||
trackListRaw: unknown,
|
trackListRaw: unknown,
|
||||||
sidRaw: unknown,
|
sidRaw: unknown,
|
||||||
@@ -8,9 +19,8 @@ export function getActiveExternalSubtitleSource(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sid =
|
const sid = parseTrackId(sidRaw);
|
||||||
typeof sidRaw === 'number' ? sidRaw : typeof sidRaw === 'string' ? Number(sidRaw) : null;
|
if (sid === null) {
|
||||||
if (sid == null || !Number.isFinite(sid)) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +29,7 @@ export function getActiveExternalSubtitleSource(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const track = entry as Record<string, unknown>;
|
const track = entry as Record<string, unknown>;
|
||||||
return track.type === 'sub' && track.id === sid && track.external === true;
|
return track.type === 'sub' && parseTrackId(track.id) === sid && track.external === true;
|
||||||
}) as Record<string, unknown> | undefined;
|
}) as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
const externalFilename =
|
const externalFilename =
|
||||||
@@ -40,3 +50,21 @@ export function resolveSubtitleSourcePath(source: string): string {
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildSubtitleSidebarSourceKey(
|
||||||
|
videoPath: string,
|
||||||
|
track: unknown,
|
||||||
|
fallbackSourcePath?: string,
|
||||||
|
): string {
|
||||||
|
const normalizedVideoPath = videoPath.trim();
|
||||||
|
if (track && typeof track === 'object' && normalizedVideoPath) {
|
||||||
|
const subtitleTrack = track as Record<string, unknown>;
|
||||||
|
const trackId = parseTrackId(subtitleTrack.id);
|
||||||
|
const ffIndex = parseTrackId(subtitleTrack['ff-index']);
|
||||||
|
if (trackId !== null || ffIndex !== null) {
|
||||||
|
return `internal:${normalizedVideoPath}:track:${trackId ?? 'unknown'}:ff:${ffIndex ?? 'unknown'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackSourcePath ?? normalizedVideoPath;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
KikuFieldGroupingChoice,
|
KikuFieldGroupingChoice,
|
||||||
JlptLevel,
|
JlptLevel,
|
||||||
FrequencyDictionaryLookup,
|
FrequencyDictionaryLookup,
|
||||||
|
SubtitleCue,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { CliArgs } from '../cli/args';
|
import type { CliArgs } from '../cli/args';
|
||||||
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
@@ -158,6 +159,8 @@ export interface AppState {
|
|||||||
currentSubText: string;
|
currentSubText: string;
|
||||||
currentSubAssText: string;
|
currentSubAssText: string;
|
||||||
currentSubtitleData: SubtitleData | null;
|
currentSubtitleData: SubtitleData | null;
|
||||||
|
activeParsedSubtitleCues: SubtitleCue[];
|
||||||
|
activeParsedSubtitleSource: string | null;
|
||||||
windowTracker: BaseWindowTracker | null;
|
windowTracker: BaseWindowTracker | null;
|
||||||
subtitlePosition: SubtitlePosition | null;
|
subtitlePosition: SubtitlePosition | null;
|
||||||
currentMediaPath: string | null;
|
currentMediaPath: string | null;
|
||||||
@@ -238,6 +241,8 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
|||||||
currentSubText: '',
|
currentSubText: '',
|
||||||
currentSubAssText: '',
|
currentSubAssText: '',
|
||||||
currentSubtitleData: null,
|
currentSubtitleData: null,
|
||||||
|
activeParsedSubtitleCues: [],
|
||||||
|
activeParsedSubtitleSource: null,
|
||||||
windowTracker: null,
|
windowTracker: null,
|
||||||
subtitlePosition: null,
|
subtitlePosition: null,
|
||||||
currentMediaPath: null,
|
currentMediaPath: null,
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
|
||||||
getCurrentSubtitleAss: (): Promise<string> =>
|
getCurrentSubtitleAss: (): Promise<string> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss),
|
||||||
|
getSubtitleSidebarSnapshot: () =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
|
||||||
getPlaybackPaused: (): Promise<boolean | null> =>
|
getPlaybackPaused: (): Promise<boolean | null> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused),
|
||||||
onSubtitleAss: (callback: (assText: string) => void) => {
|
onSubtitleAss: (callback: (assText: string) => void) => {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const makefile = readFileSync(makefilePath, 'utf8');
|
|||||||
const packageJsonPath = resolve(__dirname, '../package.json');
|
const packageJsonPath = resolve(__dirname, '../package.json');
|
||||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||||
scripts: Record<string, string>;
|
scripts: Record<string, string>;
|
||||||
|
build?: {
|
||||||
|
files?: string[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
test('publish release leaves prerelease unset so gh creates a normal release', () => {
|
test('publish release leaves prerelease unset so gh creates a normal release', () => {
|
||||||
@@ -24,6 +27,11 @@ test('release workflow verifies a committed changelog section before publish', (
|
|||||||
assert.match(releaseWorkflow, /bun run changelog:check/);
|
assert.match(releaseWorkflow, /bun run changelog:check/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('release workflow builds changelog artifacts when fragments are pending', () => {
|
||||||
|
assert.match(releaseWorkflow, /Build changelog artifacts for release/);
|
||||||
|
assert.match(releaseWorkflow, /changelog:build --version/);
|
||||||
|
});
|
||||||
|
|
||||||
test('release workflow verifies generated config examples before packaging artifacts', () => {
|
test('release workflow verifies generated config examples before packaging artifacts', () => {
|
||||||
assert.match(releaseWorkflow, /bun run verify:config-example/);
|
assert.match(releaseWorkflow, /bun run verify:config-example/);
|
||||||
});
|
});
|
||||||
@@ -60,6 +68,18 @@ test('release package scripts disable implicit electron-builder publishing', ()
|
|||||||
assert.match(packageJson.scripts['build:win:unsigned'] ?? '', /build-win-unsigned\.mjs/);
|
assert.match(packageJson.scripts['build:win:unsigned'] ?? '', /build-win-unsigned\.mjs/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('release packaging keeps default file inclusion and excludes large source-only trees explicitly', () => {
|
||||||
|
const files = packageJson.build?.files ?? [];
|
||||||
|
assert.ok(files.includes('**/*'));
|
||||||
|
assert.ok(files.includes('!src{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!launcher{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!stats/src{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!.tmp{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!release-*{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!vendor/subminer-yomitan{,/**/*}'));
|
||||||
|
assert.ok(files.includes('!vendor/texthooker-ui/src{,/**/*}'));
|
||||||
|
});
|
||||||
|
|
||||||
test('config example generation runs directly from source without unrelated bundle prerequisites', () => {
|
test('config example generation runs directly from source without unrelated bundle prerequisites', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
packageJson.scripts['generate:config-example'],
|
packageJson.scripts['generate:config-example'],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function createKeyboardHandlers(
|
|||||||
getPlaybackPaused: () => Promise<boolean | null>;
|
getPlaybackPaused: () => Promise<boolean | null>;
|
||||||
openControllerSelectModal: () => void;
|
openControllerSelectModal: () => void;
|
||||||
openControllerDebugModal: () => void;
|
openControllerDebugModal: () => void;
|
||||||
|
toggleSubtitleSidebarModal?: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||||
@@ -181,6 +182,26 @@ export function createKeyboardHandlers(
|
|||||||
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSubtitleSidebarToggle(e: KeyboardEvent): boolean {
|
||||||
|
const toggleKey = ctx.state.subtitleSidebarToggleKey;
|
||||||
|
if (!toggleKey) return false;
|
||||||
|
const isBackslashConfigured = toggleKey === 'Backslash' || toggleKey === '\\';
|
||||||
|
const isBackslashLikeCode = ['Backslash', 'IntlBackslash', 'IntlYen'].includes(e.code);
|
||||||
|
const keyMatches =
|
||||||
|
toggleKey === e.code ||
|
||||||
|
(isBackslashConfigured && isBackslashLikeCode) ||
|
||||||
|
(isBackslashConfigured && e.key === '\\') ||
|
||||||
|
(toggleKey.length === 1 && e.key === toggleKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
keyMatches &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.altKey &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.repeat
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isStatsOverlayToggle(e: KeyboardEvent): boolean {
|
function isStatsOverlayToggle(e: KeyboardEvent): boolean {
|
||||||
return (
|
return (
|
||||||
e.code === ctx.state.statsToggleKey &&
|
e.code === ctx.state.statsToggleKey &&
|
||||||
@@ -838,6 +859,12 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSubtitleSidebarToggle(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
options.toggleSubtitleSidebarModal?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
||||||
!isControllerModalShortcut(e)
|
!isControllerModalShortcut(e)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { SubtitleSidebarConfig } from '../../types';
|
||||||
import { createMouseHandlers } from './mouse.js';
|
import { createMouseHandlers } from './mouse.js';
|
||||||
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
|
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ function createMouseTestContext() {
|
|||||||
const overlayClassList = createClassList();
|
const overlayClassList = createClassList();
|
||||||
const subtitleRootClassList = createClassList();
|
const subtitleRootClassList = createClassList();
|
||||||
const subtitleContainerClassList = createClassList();
|
const subtitleContainerClassList = createClassList();
|
||||||
|
const secondarySubContainerClassList = createClassList();
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
dom: {
|
dom: {
|
||||||
@@ -54,6 +56,7 @@ function createMouseTestContext() {
|
|||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
},
|
},
|
||||||
secondarySubContainer: {
|
secondarySubContainer: {
|
||||||
|
classList: secondarySubContainerClassList,
|
||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -63,6 +66,9 @@ function createMouseTestContext() {
|
|||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
isOverSubtitle: false,
|
isOverSubtitle: false,
|
||||||
|
isOverSubtitleSidebar: false,
|
||||||
|
subtitleSidebarModalOpen: false,
|
||||||
|
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
dragStartY: 0,
|
dragStartY: 0,
|
||||||
startYPercent: 0,
|
startYPercent: 0,
|
||||||
@@ -72,7 +78,7 @@ function createMouseTestContext() {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('auto-pause on subtitle hover pauses on enter and resumes on leave when enabled', async () => {
|
test('secondary hover pauses on enter, reveals secondary subtitle, and resumes on leave when enabled', async () => {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const mpvCommands: Array<(string | number)[]> = [];
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
|
||||||
@@ -92,8 +98,10 @@ test('auto-pause on subtitle hover pauses on enter and resumes on leave when ena
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await handlers.handleMouseEnter();
|
await handlers.handleSecondaryMouseEnter();
|
||||||
await handlers.handleMouseLeave();
|
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true);
|
||||||
|
await handlers.handleSecondaryMouseLeave();
|
||||||
|
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
|
||||||
|
|
||||||
assert.deepEqual(mpvCommands, [
|
assert.deepEqual(mpvCommands, [
|
||||||
['set_property', 'pause', 'yes'],
|
['set_property', 'pause', 'yes'],
|
||||||
@@ -101,6 +109,68 @@ test('auto-pause on subtitle hover pauses on enter and resumes on leave when ena
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('moving between primary and secondary subtitle containers keeps the hover pause active', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handlers.handleSecondaryMouseEnter();
|
||||||
|
await handlers.handleSecondaryMouseLeave({
|
||||||
|
relatedTarget: ctx.dom.subtitleContainer,
|
||||||
|
} as unknown as MouseEvent);
|
||||||
|
await handlers.handlePrimaryMouseEnter({
|
||||||
|
relatedTarget: ctx.dom.secondarySubContainer,
|
||||||
|
} as unknown as MouseEvent);
|
||||||
|
|
||||||
|
assert.equal(ctx.state.isOverSubtitle, true);
|
||||||
|
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('secondary leave toward primary subtitle container clears the secondary hover class', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handlers.handleSecondaryMouseEnter();
|
||||||
|
await handlers.handleSecondaryMouseLeave({
|
||||||
|
relatedTarget: ctx.dom.subtitleContainer,
|
||||||
|
} as unknown as MouseEvent);
|
||||||
|
|
||||||
|
assert.equal(ctx.state.isOverSubtitle, false);
|
||||||
|
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
|
||||||
|
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||||
|
});
|
||||||
|
|
||||||
test('auto-pause on subtitle hover skips when playback is already paused', async () => {
|
test('auto-pause on subtitle hover skips when playback is already paused', async () => {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const mpvCommands: Array<(string | number)[]> = [];
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
@@ -127,6 +197,36 @@ test('auto-pause on subtitle hover skips when playback is already paused', async
|
|||||||
assert.deepEqual(mpvCommands, []);
|
assert.deepEqual(mpvCommands, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('primary hover pauses on enter without revealing secondary subtitle', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handlers.handlePrimaryMouseEnter();
|
||||||
|
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
|
||||||
|
await handlers.handlePrimaryMouseLeave();
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('auto-pause on subtitle hover is skipped when disabled in config', async () => {
|
test('auto-pause on subtitle hover is skipped when disabled in config', async () => {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const mpvCommands: Array<(string | number)[]> = [];
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
@@ -153,6 +253,67 @@ test('auto-pause on subtitle hover is skipped when disabled in config', async ()
|
|||||||
assert.deepEqual(mpvCommands, []);
|
assert.deepEqual(mpvCommands, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle leave restores passthrough while embedded sidebar is open but not hovered', async () => {
|
||||||
|
const ctx = createMouseTestContext();
|
||||||
|
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
|
||||||
|
ctx.platform.shouldToggleMouseIgnore = true;
|
||||||
|
ctx.state.isOverSubtitle = true;
|
||||||
|
ctx.state.subtitleSidebarModalOpen = true;
|
||||||
|
ctx.state.subtitleSidebarConfig = {
|
||||||
|
enabled: true,
|
||||||
|
autoOpen: false,
|
||||||
|
layout: 'embedded',
|
||||||
|
toggleKey: 'Backslash',
|
||||||
|
pauseVideoOnHover: false,
|
||||||
|
autoScroll: true,
|
||||||
|
maxWidth: 360,
|
||||||
|
opacity: 0.92,
|
||||||
|
backgroundColor: 'rgba(54, 58, 79, 0.88)',
|
||||||
|
textColor: '#cad3f5',
|
||||||
|
fontFamily: '"Iosevka Aile", sans-serif',
|
||||||
|
fontSize: 17,
|
||||||
|
timestampColor: '#a5adcb',
|
||||||
|
activeLineColor: '#f5bde6',
|
||||||
|
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||||
|
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreMouseCalls.push([ignore, options]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => true,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handlers.handlePrimaryMouseLeave();
|
||||||
|
|
||||||
|
assert.deepEqual(ignoreMouseCalls.at(-1), [true, { forward: true }]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('pending hover pause check is ignored when mouse leaves before pause state resolves', async () => {
|
test('pending hover pause check is ignored when mouse leaves before pause state resolves', async () => {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const mpvCommands: Array<(string | number)[]> = [];
|
const mpvCommands: Array<(string | number)[]> = [];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ModalStateReader, RendererContext } from '../context';
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
@@ -25,6 +26,19 @@ export function createMouseHandlers(
|
|||||||
let pausedBySubtitleHover = false;
|
let pausedBySubtitleHover = false;
|
||||||
let pausedByYomitanPopup = false;
|
let pausedByYomitanPopup = false;
|
||||||
|
|
||||||
|
function isWithinOtherSubtitleContainer(
|
||||||
|
relatedTarget: EventTarget | null,
|
||||||
|
otherContainer: HTMLElement,
|
||||||
|
): boolean {
|
||||||
|
if (relatedTarget === otherContainer) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof Node !== 'undefined' && relatedTarget instanceof Node) {
|
||||||
|
return otherContainer.contains(relatedTarget);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function maybeResumeHoverPause(): void {
|
function maybeResumeHoverPause(): void {
|
||||||
if (!pausedBySubtitleHover) return;
|
if (!pausedBySubtitleHover) return;
|
||||||
if (pausedByYomitanPopup) return;
|
if (pausedByYomitanPopup) return;
|
||||||
@@ -80,10 +94,7 @@ export function createMouseHandlers(
|
|||||||
function enablePopupInteraction(): void {
|
function enablePopupInteraction(): void {
|
||||||
yomitanPopupVisible = true;
|
yomitanPopupVisible = true;
|
||||||
ctx.state.yomitanPopupVisible = true;
|
ctx.state.yomitanPopupVisible = true;
|
||||||
ctx.dom.overlay.classList.add('interactive');
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
|
||||||
window.electronAPI.setIgnoreMouseEvents(false);
|
|
||||||
}
|
|
||||||
if (ctx.platform.isMacOSPlatform) {
|
if (ctx.platform.isMacOSPlatform) {
|
||||||
window.focus();
|
window.focus();
|
||||||
}
|
}
|
||||||
@@ -101,20 +112,18 @@ export function createMouseHandlers(
|
|||||||
popupPauseRequestId += 1;
|
popupPauseRequestId += 1;
|
||||||
maybeResumeYomitanPopupPause();
|
maybeResumeYomitanPopupPause();
|
||||||
maybeResumeHoverPause();
|
maybeResumeHoverPause();
|
||||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
ctx.dom.overlay.classList.remove('interactive');
|
|
||||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
|
||||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMouseEnter(): Promise<void> {
|
async function handleMouseEnter(
|
||||||
|
_event?: MouseEvent,
|
||||||
|
showSecondaryHover = false,
|
||||||
|
): Promise<void> {
|
||||||
ctx.state.isOverSubtitle = true;
|
ctx.state.isOverSubtitle = true;
|
||||||
ctx.dom.overlay.classList.add('interactive');
|
if (showSecondaryHover) {
|
||||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
|
||||||
window.electronAPI.setIgnoreMouseEvents(false);
|
|
||||||
}
|
}
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
|
||||||
if (yomitanPopupVisible && options.getYomitanPopupAutoPauseEnabled()) {
|
if (yomitanPopupVisible && options.getYomitanPopupAutoPauseEnabled()) {
|
||||||
return;
|
return;
|
||||||
@@ -124,6 +133,10 @@ export function createMouseHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pausedBySubtitleHover) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const requestId = ++hoverPauseRequestId;
|
const requestId = ++hoverPauseRequestId;
|
||||||
let paused: boolean | null = null;
|
let paused: boolean | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -141,8 +154,26 @@ export function createMouseHandlers(
|
|||||||
pausedBySubtitleHover = true;
|
pausedBySubtitleHover = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMouseLeave(): Promise<void> {
|
async function handleMouseLeave(
|
||||||
|
_event?: MouseEvent,
|
||||||
|
hideSecondaryHover = false,
|
||||||
|
): Promise<void> {
|
||||||
|
const relatedTarget = _event?.relatedTarget ?? null;
|
||||||
|
const otherContainer = hideSecondaryHover
|
||||||
|
? ctx.dom.subtitleContainer
|
||||||
|
: ctx.dom.secondarySubContainer;
|
||||||
|
if (relatedTarget && isWithinOtherSubtitleContainer(relatedTarget, otherContainer)) {
|
||||||
|
ctx.state.isOverSubtitle = false;
|
||||||
|
if (hideSecondaryHover) {
|
||||||
|
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.state.isOverSubtitle = false;
|
ctx.state.isOverSubtitle = false;
|
||||||
|
if (hideSecondaryHover) {
|
||||||
|
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
|
||||||
|
}
|
||||||
hoverPauseRequestId += 1;
|
hoverPauseRequestId += 1;
|
||||||
maybeResumeHoverPause();
|
maybeResumeHoverPause();
|
||||||
if (yomitanPopupVisible) return;
|
if (yomitanPopupVisible) return;
|
||||||
@@ -246,6 +277,10 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
handlePrimaryMouseEnter: (event?: MouseEvent) => handleMouseEnter(event, false),
|
||||||
|
handlePrimaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, false),
|
||||||
|
handleSecondaryMouseEnter: (event?: MouseEvent) => handleMouseEnter(event, true),
|
||||||
|
handleSecondaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, true),
|
||||||
handleMouseEnter,
|
handleMouseEnter,
|
||||||
handleMouseLeave,
|
handleMouseLeave,
|
||||||
setupDragging,
|
setupDragging,
|
||||||
|
|||||||
@@ -256,6 +256,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="subtitleSidebarModal" class="modal hidden subtitle-sidebar-modal" aria-hidden="true">
|
||||||
|
<div id="subtitleSidebarContent" class="modal-content subtitle-sidebar-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">Subtitle Sidebar</div>
|
||||||
|
<button id="subtitleSidebarClose" class="modal-close" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body subtitle-sidebar-body">
|
||||||
|
<div id="subtitleSidebarStatus" class="runtime-options-status"></div>
|
||||||
|
<ul id="subtitleSidebarList" class="subtitle-sidebar-list"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-content session-help-content">
|
<div class="modal-content session-help-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
2052
src/renderer/modals/subtitle-sidebar.test.ts
Normal file
2052
src/renderer/modals/subtitle-sidebar.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
599
src/renderer/modals/subtitle-sidebar.ts
Normal file
599
src/renderer/modals/subtitle-sidebar.ts
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
import type {
|
||||||
|
SubtitleCue,
|
||||||
|
SubtitleData,
|
||||||
|
SubtitleSidebarSnapshot,
|
||||||
|
} from '../../types';
|
||||||
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||||
|
|
||||||
|
const MANUAL_SCROLL_HOLD_MS = 1500;
|
||||||
|
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
|
||||||
|
const CLICK_SEEK_OFFSET_SEC = 0.08;
|
||||||
|
const SNAPSHOT_POLL_INTERVAL_MS = 80;
|
||||||
|
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
|
||||||
|
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
|
||||||
|
function subtitleCueListsEqual(a: SubtitleCue[], b: SubtitleCue[]): boolean {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
const left = a[i]!;
|
||||||
|
const right = b[i]!;
|
||||||
|
if (
|
||||||
|
left.startTime !== right.startTime ||
|
||||||
|
left.endTime !== right.endTime ||
|
||||||
|
left.text !== right.text
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCueText(text: string): string {
|
||||||
|
return text.replace(/\r\n/g, '\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCueTimestamp(seconds: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(seconds));
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const mins = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const secs = totalSeconds % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return [
|
||||||
|
String(hours).padStart(2, '0'),
|
||||||
|
String(mins).padStart(2, '0'),
|
||||||
|
String(secs).padStart(2, '0'),
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findActiveSubtitleCueIndex(
|
||||||
|
cues: SubtitleCue[],
|
||||||
|
current: { text: string; startTime?: number | null } | null,
|
||||||
|
currentTimeSec: number | null = null,
|
||||||
|
preferredCueIndex: number = -1,
|
||||||
|
): number {
|
||||||
|
if (cues.length === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof currentTimeSec === 'number' && Number.isFinite(currentTimeSec)) {
|
||||||
|
const activeOrUpcomingCue = cues.findIndex(
|
||||||
|
(cue) =>
|
||||||
|
cue.endTime > currentTimeSec &&
|
||||||
|
cue.startTime <= currentTimeSec + ACTIVE_CUE_LOOKAHEAD_SEC,
|
||||||
|
);
|
||||||
|
if (activeOrUpcomingCue >= 0) {
|
||||||
|
return activeOrUpcomingCue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCue = cues.findIndex((cue) => cue.endTime > currentTimeSec);
|
||||||
|
if (nextCue >= 0) {
|
||||||
|
return nextCue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof current.startTime === 'number' && Number.isFinite(current.startTime)) {
|
||||||
|
const timingMatch = cues.findIndex(
|
||||||
|
(cue) => current.startTime! >= cue.startTime && current.startTime! < cue.endTime,
|
||||||
|
);
|
||||||
|
if (timingMatch >= 0) {
|
||||||
|
return timingMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedText = normalizeCueText(current.text);
|
||||||
|
if (!normalizedText) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingIndices: number[] = [];
|
||||||
|
for (const [index, cue] of cues.entries()) {
|
||||||
|
if (normalizeCueText(cue.text) === normalizedText) {
|
||||||
|
matchingIndices.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingIndices.length === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTiming =
|
||||||
|
typeof current.startTime === 'number' && Number.isFinite(current.startTime);
|
||||||
|
|
||||||
|
if (preferredCueIndex >= 0) {
|
||||||
|
if (!hasTiming && currentTimeSec === null) {
|
||||||
|
const forwardMatches = matchingIndices.filter((index) => index >= preferredCueIndex);
|
||||||
|
if (forwardMatches.length > 0) {
|
||||||
|
return forwardMatches[0]!;
|
||||||
|
}
|
||||||
|
if (matchingIndices.includes(preferredCueIndex)) {
|
||||||
|
return preferredCueIndex;
|
||||||
|
}
|
||||||
|
return matchingIndices[matchingIndices.length - 1] ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nearestIndex = matchingIndices[0]!;
|
||||||
|
let nearestDistance = Math.abs(nearestIndex - preferredCueIndex);
|
||||||
|
for (const matchIndex of matchingIndices) {
|
||||||
|
const distance = Math.abs(matchIndex - preferredCueIndex);
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearestIndex = matchIndex;
|
||||||
|
nearestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearestIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchingIndices[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubtitleSidebarModal(
|
||||||
|
ctx: RendererContext,
|
||||||
|
options: {
|
||||||
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let snapshotPollInterval: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let lastAppliedVideoMarginRatio: number | null = null;
|
||||||
|
let subtitleSidebarHoverRequestId = 0;
|
||||||
|
let disposeDomEvents: (() => void) | null = null;
|
||||||
|
|
||||||
|
function restoreEmbeddedSidebarPassthrough(): void {
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message: string): void {
|
||||||
|
ctx.dom.subtitleSidebarStatus.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReservedSidebarWidthPx(): number {
|
||||||
|
const config = ctx.state.subtitleSidebarConfig;
|
||||||
|
if (!config || config.layout !== 'embedded' || !ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measuredWidth = ctx.dom.subtitleSidebarContent.getBoundingClientRect().width;
|
||||||
|
if (Number.isFinite(measuredWidth) && measuredWidth > 0) {
|
||||||
|
return measuredWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(EMBEDDED_SIDEBAR_MIN_WIDTH_PX, config.maxWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncEmbeddedSidebarLayout(): void {
|
||||||
|
const config = ctx.state.subtitleSidebarConfig;
|
||||||
|
const wantsEmbedded = Boolean(
|
||||||
|
config && config.layout === 'embedded' && ctx.state.subtitleSidebarModalOpen,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wantsEmbedded) {
|
||||||
|
ctx.dom.subtitleSidebarContent.classList.add('subtitle-sidebar-content-embedded');
|
||||||
|
ctx.dom.subtitleSidebarModal.classList.add('subtitle-sidebar-modal-embedded');
|
||||||
|
document.body.classList.add('subtitle-sidebar-embedded-open');
|
||||||
|
} else {
|
||||||
|
ctx.dom.subtitleSidebarContent.classList.remove('subtitle-sidebar-content-embedded');
|
||||||
|
ctx.dom.subtitleSidebarModal.classList.remove('subtitle-sidebar-modal-embedded');
|
||||||
|
document.body.classList.remove('subtitle-sidebar-embedded-open');
|
||||||
|
}
|
||||||
|
const reservedWidthPx = wantsEmbedded ? getReservedSidebarWidthPx() : 0;
|
||||||
|
const embedded = wantsEmbedded && reservedWidthPx > 0;
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
'--subtitle-sidebar-reserved-width',
|
||||||
|
`${Math.max(0, Math.round(reservedWidthPx))}px`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const ratio =
|
||||||
|
embedded && Number.isFinite(viewportWidth) && viewportWidth > 0
|
||||||
|
? Math.min(EMBEDDED_SIDEBAR_MAX_RATIO, reservedWidthPx / viewportWidth)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastAppliedVideoMarginRatio !== null &&
|
||||||
|
Math.abs(ratio - lastAppliedVideoMarginRatio) < 0.0001
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAppliedVideoMarginRatio = ratio;
|
||||||
|
window.electronAPI.sendMpvCommand([
|
||||||
|
'set_property',
|
||||||
|
'video-margin-ratio-right',
|
||||||
|
Number(ratio.toFixed(4)),
|
||||||
|
]);
|
||||||
|
window.electronAPI.sendMpvCommand([
|
||||||
|
'set_property',
|
||||||
|
'osd-align-x',
|
||||||
|
'left',
|
||||||
|
]);
|
||||||
|
window.electronAPI.sendMpvCommand([
|
||||||
|
'set_property',
|
||||||
|
'osd-align-y',
|
||||||
|
'top',
|
||||||
|
]);
|
||||||
|
window.electronAPI.sendMpvCommand([
|
||||||
|
'set_property',
|
||||||
|
'user-data/osc/margins',
|
||||||
|
JSON.stringify({
|
||||||
|
l: 0,
|
||||||
|
r: Number(ratio.toFixed(4)),
|
||||||
|
t: 0,
|
||||||
|
b: 0,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (ratio === 0) {
|
||||||
|
window.electronAPI.sendMpvCommand(['set_property', 'video-pan-x', 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyConfig(snapshot: SubtitleSidebarSnapshot): void {
|
||||||
|
ctx.state.subtitleSidebarConfig = snapshot.config;
|
||||||
|
ctx.state.subtitleSidebarToggleKey = snapshot.config.toggleKey;
|
||||||
|
ctx.state.subtitleSidebarPauseVideoOnHover = snapshot.config.pauseVideoOnHover;
|
||||||
|
ctx.state.subtitleSidebarAutoScroll = snapshot.config.autoScroll;
|
||||||
|
const style = ctx.dom.subtitleSidebarModal.style;
|
||||||
|
style.setProperty('--subtitle-sidebar-max-width', `${snapshot.config.maxWidth}px`);
|
||||||
|
style.setProperty('--subtitle-sidebar-opacity', String(snapshot.config.opacity));
|
||||||
|
style.setProperty('--subtitle-sidebar-background-color', snapshot.config.backgroundColor);
|
||||||
|
style.setProperty('--subtitle-sidebar-text-color', snapshot.config.textColor);
|
||||||
|
style.setProperty('--subtitle-sidebar-font-family', snapshot.config.fontFamily);
|
||||||
|
style.setProperty('--subtitle-sidebar-font-size', `${snapshot.config.fontSize}px`);
|
||||||
|
style.setProperty('--subtitle-sidebar-timestamp-color', snapshot.config.timestampColor);
|
||||||
|
style.setProperty('--subtitle-sidebar-active-line-color', snapshot.config.activeLineColor);
|
||||||
|
style.setProperty(
|
||||||
|
'--subtitle-sidebar-active-background-color',
|
||||||
|
snapshot.config.activeLineBackgroundColor,
|
||||||
|
);
|
||||||
|
style.setProperty(
|
||||||
|
'--subtitle-sidebar-hover-background-color',
|
||||||
|
snapshot.config.hoverLineBackgroundColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekToCue(cue: SubtitleCue): void {
|
||||||
|
const targetTime = Math.min(cue.endTime - 0.01, cue.startTime + CLICK_SEEK_OFFSET_SEC);
|
||||||
|
window.electronAPI.sendMpvCommand([
|
||||||
|
'seek',
|
||||||
|
Math.max(cue.startTime, targetTime),
|
||||||
|
'absolute+exact',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCueRowLabel(cue: SubtitleCue): string {
|
||||||
|
return `Jump to subtitle at ${formatCueTimestamp(cue.startTime)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeSubtitleSidebarHoverPause(): void {
|
||||||
|
subtitleSidebarHoverRequestId += 1;
|
||||||
|
if (!ctx.state.subtitleSidebarPausedByHover) {
|
||||||
|
restoreEmbeddedSidebarPassthrough();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.state.subtitleSidebarPausedByHover = false;
|
||||||
|
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||||
|
restoreEmbeddedSidebarPassthrough();
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeAutoScrollActiveCue(
|
||||||
|
previousActiveCueIndex: number,
|
||||||
|
behavior: ScrollBehavior = 'smooth',
|
||||||
|
force = false,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!ctx.state.subtitleSidebarAutoScroll ||
|
||||||
|
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
|
||||||
|
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
|
||||||
|
Date.now() < ctx.state.subtitleSidebarManualScrollUntilMs
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = ctx.dom.subtitleSidebarList;
|
||||||
|
const active = list.children[ctx.state.subtitleSidebarActiveCueIndex] as HTMLElement | undefined;
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetScrollTop =
|
||||||
|
active.offsetTop - (list.clientHeight - active.clientHeight) / 2;
|
||||||
|
const nextScrollTop = Math.max(0, targetScrollTop);
|
||||||
|
if (previousActiveCueIndex < 0) {
|
||||||
|
list.scrollTop = nextScrollTop;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.scrollTo({
|
||||||
|
top: nextScrollTop,
|
||||||
|
behavior,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCueList(): void {
|
||||||
|
ctx.dom.subtitleSidebarList.innerHTML = '';
|
||||||
|
for (const [index, cue] of ctx.state.subtitleSidebarCues.entries()) {
|
||||||
|
const row = document.createElement('li');
|
||||||
|
row.className = 'subtitle-sidebar-item';
|
||||||
|
row.classList.toggle('active', index === ctx.state.subtitleSidebarActiveCueIndex);
|
||||||
|
row.dataset.index = String(index);
|
||||||
|
row.tabIndex = 0;
|
||||||
|
row.setAttribute('role', 'button');
|
||||||
|
row.setAttribute('aria-label', getCueRowLabel(cue));
|
||||||
|
row.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
seekToCue(cue);
|
||||||
|
});
|
||||||
|
|
||||||
|
const timestamp = document.createElement('div');
|
||||||
|
timestamp.className = 'subtitle-sidebar-timestamp';
|
||||||
|
timestamp.textContent = formatCueTimestamp(cue.startTime);
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'subtitle-sidebar-text';
|
||||||
|
text.textContent = cue.text;
|
||||||
|
|
||||||
|
row.appendChild(timestamp);
|
||||||
|
row.appendChild(text);
|
||||||
|
|
||||||
|
ctx.dom.subtitleSidebarList.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActiveCueClasses(previousActiveCueIndex: number): void {
|
||||||
|
if (previousActiveCueIndex >= 0) {
|
||||||
|
const previous = ctx.dom.subtitleSidebarList.children[previousActiveCueIndex] as
|
||||||
|
| HTMLElement
|
||||||
|
| undefined;
|
||||||
|
previous?.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.state.subtitleSidebarActiveCueIndex >= 0) {
|
||||||
|
const current = ctx.dom.subtitleSidebarList.children[ctx.state.subtitleSidebarActiveCueIndex] as
|
||||||
|
| HTMLElement
|
||||||
|
| undefined;
|
||||||
|
current?.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveCue(
|
||||||
|
current: { text: string; startTime?: number | null } | null,
|
||||||
|
currentTimeSec: number | null = null,
|
||||||
|
): void {
|
||||||
|
const previousActiveCueIndex = ctx.state.subtitleSidebarActiveCueIndex;
|
||||||
|
ctx.state.subtitleSidebarActiveCueIndex = findActiveSubtitleCueIndex(
|
||||||
|
ctx.state.subtitleSidebarCues,
|
||||||
|
current,
|
||||||
|
currentTimeSec,
|
||||||
|
previousActiveCueIndex,
|
||||||
|
);
|
||||||
|
if (ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
syncActiveCueClasses(previousActiveCueIndex);
|
||||||
|
maybeAutoScrollActiveCue(previousActiveCueIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
|
||||||
|
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
|
||||||
|
applyConfig(snapshot);
|
||||||
|
if (!snapshot.config.enabled) {
|
||||||
|
resumeSubtitleSidebarHoverPause();
|
||||||
|
ctx.state.subtitleSidebarCues = [];
|
||||||
|
ctx.state.subtitleSidebarModalOpen = false;
|
||||||
|
ctx.dom.subtitleSidebarModal.classList.add('hidden');
|
||||||
|
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
|
||||||
|
stopSnapshotPolling();
|
||||||
|
updateActiveCue(null, snapshot.currentTimeSec ?? null);
|
||||||
|
setStatus('Subtitle sidebar disabled in config.');
|
||||||
|
syncEmbeddedSidebarLayout();
|
||||||
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||||
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
|
}
|
||||||
|
restoreEmbeddedSidebarPassthrough();
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cuesChanged = !subtitleCueListsEqual(ctx.state.subtitleSidebarCues, snapshot.cues);
|
||||||
|
if (cuesChanged) {
|
||||||
|
ctx.state.subtitleSidebarCues = snapshot.cues;
|
||||||
|
if (ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
renderCueList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec ?? null);
|
||||||
|
syncEmbeddedSidebarLayout();
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSnapshotPolling(): void {
|
||||||
|
stopSnapshotPolling();
|
||||||
|
|
||||||
|
const pollOnce = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await refreshSnapshot();
|
||||||
|
} catch {
|
||||||
|
// Keep polling; a transient IPC failure should not stop updates.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
snapshotPollInterval = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSnapshotPolling(): void {
|
||||||
|
if (!snapshotPollInterval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(snapshotPollInterval);
|
||||||
|
snapshotPollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSubtitleSidebarModal(): Promise<void> {
|
||||||
|
const snapshot = await refreshSnapshot();
|
||||||
|
if (!snapshot.config.enabled) {
|
||||||
|
setStatus('Subtitle sidebar disabled in config.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.dom.subtitleSidebarList.innerHTML = '';
|
||||||
|
if (snapshot.cues.length === 0) {
|
||||||
|
setStatus('No parsed subtitle cues available.');
|
||||||
|
} else {
|
||||||
|
setStatus(`${snapshot.cues.length} parsed subtitle lines`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.state.subtitleSidebarModalOpen = true;
|
||||||
|
ctx.state.isOverSubtitleSidebar = false;
|
||||||
|
ctx.dom.subtitleSidebarModal.classList.remove('hidden');
|
||||||
|
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'false');
|
||||||
|
renderCueList();
|
||||||
|
syncActiveCueClasses(-1);
|
||||||
|
maybeAutoScrollActiveCue(-1, 'auto', true);
|
||||||
|
startSnapshotPolling();
|
||||||
|
syncEmbeddedSidebarLayout();
|
||||||
|
restoreEmbeddedSidebarPassthrough();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoOpenSubtitleSidebarOnStartup(): Promise<void> {
|
||||||
|
const snapshot = await refreshSnapshot();
|
||||||
|
if (!snapshot.config.enabled || !snapshot.config.autoOpen || ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await openSubtitleSidebarModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSubtitleSidebarModal(): void {
|
||||||
|
if (!ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resumeSubtitleSidebarHoverPause();
|
||||||
|
ctx.state.isOverSubtitleSidebar = false;
|
||||||
|
ctx.state.subtitleSidebarModalOpen = false;
|
||||||
|
ctx.dom.subtitleSidebarModal.classList.add('hidden');
|
||||||
|
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
|
||||||
|
stopSnapshotPolling();
|
||||||
|
syncEmbeddedSidebarLayout();
|
||||||
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||||
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
|
}
|
||||||
|
restoreEmbeddedSidebarPassthrough();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSubtitleSidebarModal(): Promise<void> {
|
||||||
|
if (ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
closeSubtitleSidebarModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await openSubtitleSidebarModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubtitleUpdated(data: SubtitleData): void {
|
||||||
|
if (ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActiveCue(
|
||||||
|
{ text: data.text, startTime: data.startTime },
|
||||||
|
data.startTime ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireDomEvents(): void {
|
||||||
|
if (disposeDomEvents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.dom.subtitleSidebarClose.addEventListener('click', () => {
|
||||||
|
closeSubtitleSidebarModal();
|
||||||
|
});
|
||||||
|
ctx.dom.subtitleSidebarList.addEventListener('click', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = target.closest<HTMLElement>('.subtitle-sidebar-item');
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = Number.parseInt(row.dataset.index ?? '', 10);
|
||||||
|
if (!Number.isInteger(index) || index < 0 || index >= ctx.state.subtitleSidebarCues.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cue = ctx.state.subtitleSidebarCues[index];
|
||||||
|
if (!cue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seekToCue(cue);
|
||||||
|
});
|
||||||
|
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
|
||||||
|
ctx.state.subtitleSidebarManualScrollUntilMs = Date.now() + MANUAL_SCROLL_HOLD_MS;
|
||||||
|
});
|
||||||
|
ctx.dom.subtitleSidebarModal.addEventListener('mouseenter', async () => {
|
||||||
|
ctx.state.isOverSubtitleSidebar = true;
|
||||||
|
restoreEmbeddedSidebarPassthrough();
|
||||||
|
if (!ctx.state.subtitleSidebarPauseVideoOnHover || ctx.state.subtitleSidebarPausedByHover) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestId = ++subtitleSidebarHoverRequestId;
|
||||||
|
let paused: boolean | null | undefined;
|
||||||
|
try {
|
||||||
|
paused = await window.electronAPI.getPlaybackPaused();
|
||||||
|
} catch {
|
||||||
|
paused = undefined;
|
||||||
|
}
|
||||||
|
if (requestId !== subtitleSidebarHoverRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (paused === false) {
|
||||||
|
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||||
|
ctx.state.subtitleSidebarPausedByHover = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.dom.subtitleSidebarModal.addEventListener('mouseleave', () => {
|
||||||
|
ctx.state.isOverSubtitleSidebar = false;
|
||||||
|
resumeSubtitleSidebarHoverPause();
|
||||||
|
});
|
||||||
|
const resizeHandler = () => {
|
||||||
|
if (!ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncEmbeddedSidebarLayout();
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', resizeHandler);
|
||||||
|
disposeDomEvents = () => {
|
||||||
|
window.removeEventListener('resize', resizeHandler);
|
||||||
|
disposeDomEvents = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
autoOpenSubtitleSidebarOnStartup,
|
||||||
|
openSubtitleSidebarModal,
|
||||||
|
closeSubtitleSidebarModal,
|
||||||
|
toggleSubtitleSidebarModal,
|
||||||
|
refreshSubtitleSidebarSnapshot: refreshSnapshot,
|
||||||
|
wireDomEvents,
|
||||||
|
disposeDomEvents: () => {
|
||||||
|
disposeDomEvents?.();
|
||||||
|
},
|
||||||
|
handleSubtitleUpdated,
|
||||||
|
seekToCue,
|
||||||
|
};
|
||||||
|
}
|
||||||
42
src/renderer/overlay-mouse-ignore.ts
Normal file
42
src/renderer/overlay-mouse-ignore.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { RendererContext } from './context';
|
||||||
|
import type { RendererState } from './state';
|
||||||
|
|
||||||
|
function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||||
|
const embeddedSidebarOpen =
|
||||||
|
state.subtitleSidebarModalOpen && state.subtitleSidebarConfig?.layout === 'embedded';
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
state.controllerSelectModalOpen ||
|
||||||
|
state.controllerDebugModalOpen ||
|
||||||
|
state.jimakuModalOpen ||
|
||||||
|
state.kikuModalOpen ||
|
||||||
|
state.runtimeOptionsModalOpen ||
|
||||||
|
state.subsyncModalOpen ||
|
||||||
|
state.sessionHelpModalOpen ||
|
||||||
|
(state.subtitleSidebarModalOpen && !embeddedSidebarOpen),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
||||||
|
const shouldStayInteractive =
|
||||||
|
ctx.state.isOverSubtitle ||
|
||||||
|
ctx.state.isOverSubtitleSidebar ||
|
||||||
|
ctx.state.yomitanPopupVisible ||
|
||||||
|
isBlockingOverlayModalOpen(ctx.state);
|
||||||
|
|
||||||
|
if (shouldStayInteractive) {
|
||||||
|
ctx.dom.overlay.classList.add('interactive');
|
||||||
|
} else {
|
||||||
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
|
}
|
||||||
|
if (!ctx.platform?.shouldToggleMouseIgnore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldStayInteractive) {
|
||||||
|
window.electronAPI.setIgnoreMouseEvents(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||||
|
}
|
||||||
@@ -34,10 +34,12 @@ import { createControllerSelectModal } from './modals/controller-select.js';
|
|||||||
import { createJimakuModal } from './modals/jimaku.js';
|
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 { 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 { createPositioningController } from './positioning.js';
|
import { createPositioningController } from './positioning.js';
|
||||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||||
|
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
||||||
import { createRendererState } from './state.js';
|
import { createRendererState } from './state.js';
|
||||||
import { createSubtitleRenderer } from './subtitle-render.js';
|
import { createSubtitleRenderer } from './subtitle-render.js';
|
||||||
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
|
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
|
||||||
@@ -78,7 +80,8 @@ function isAnyModalOpen(): boolean {
|
|||||||
ctx.state.kikuModalOpen ||
|
ctx.state.kikuModalOpen ||
|
||||||
ctx.state.runtimeOptionsModalOpen ||
|
ctx.state.runtimeOptionsModalOpen ||
|
||||||
ctx.state.subsyncModalOpen ||
|
ctx.state.subsyncModalOpen ||
|
||||||
ctx.state.sessionHelpModalOpen
|
ctx.state.sessionHelpModalOpen ||
|
||||||
|
ctx.state.subtitleSidebarModalOpen
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +117,9 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
|
|||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
});
|
});
|
||||||
|
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
|
||||||
|
modalStateReader: { isAnyModalOpen },
|
||||||
|
});
|
||||||
const kikuModal = createKikuModal(ctx, {
|
const kikuModal = createKikuModal(ctx, {
|
||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
@@ -143,6 +149,9 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
controllerDebugModal.openControllerDebugModal();
|
controllerDebugModal.openControllerDebugModal();
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||||
},
|
},
|
||||||
|
toggleSubtitleSidebarModal: () => {
|
||||||
|
void subtitleSidebarModal.toggleSubtitleSidebarModal();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const mouseHandlers = createMouseHandlers(ctx, {
|
const mouseHandlers = createMouseHandlers(ctx, {
|
||||||
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
||||||
@@ -183,6 +192,7 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
|
|||||||
function getActiveModal(): string | null {
|
function getActiveModal(): string | null {
|
||||||
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
|
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
|
||||||
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
|
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
|
||||||
|
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
|
||||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||||
@@ -198,6 +208,9 @@ function dismissActiveUiAfterError(): void {
|
|||||||
if (ctx.state.controllerDebugModalOpen) {
|
if (ctx.state.controllerDebugModalOpen) {
|
||||||
controllerDebugModal.closeControllerDebugModal();
|
controllerDebugModal.closeControllerDebugModal();
|
||||||
}
|
}
|
||||||
|
if (ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
subtitleSidebarModal.closeSubtitleSidebarModal();
|
||||||
|
}
|
||||||
if (ctx.state.jimakuModalOpen) {
|
if (ctx.state.jimakuModalOpen) {
|
||||||
jimakuModal.closeJimakuModal();
|
jimakuModal.closeJimakuModal();
|
||||||
}
|
}
|
||||||
@@ -468,6 +481,7 @@ async function init(): Promise<void> {
|
|||||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
||||||
keyboardHandlers.handleSubtitleContentUpdated();
|
keyboardHandlers.handleSubtitleContentUpdated();
|
||||||
subtitleRenderer.renderSubtitle(data);
|
subtitleRenderer.renderSubtitle(data);
|
||||||
|
subtitleSidebarModal.handleSubtitleUpdated(data);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -508,10 +522,10 @@ async function init(): Promise<void> {
|
|||||||
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
|
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
|
|
||||||
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
|
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);
|
||||||
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
|
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave);
|
||||||
ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleMouseEnter);
|
ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleSecondaryMouseEnter);
|
||||||
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleMouseLeave);
|
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleSecondaryMouseLeave);
|
||||||
|
|
||||||
mouseHandlers.setupResizeHandler();
|
mouseHandlers.setupResizeHandler();
|
||||||
mouseHandlers.setupSelectionObserver();
|
mouseHandlers.setupSelectionObserver();
|
||||||
@@ -528,6 +542,10 @@ async function init(): Promise<void> {
|
|||||||
controllerSelectModal.wireDomEvents();
|
controllerSelectModal.wireDomEvents();
|
||||||
controllerDebugModal.wireDomEvents();
|
controllerDebugModal.wireDomEvents();
|
||||||
sessionHelpModal.wireDomEvents();
|
sessionHelpModal.wireDomEvents();
|
||||||
|
subtitleSidebarModal.wireDomEvents();
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
subtitleSidebarModal.disposeDomEvents();
|
||||||
|
});
|
||||||
|
|
||||||
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
||||||
runGuarded('runtime-options:changed', () => {
|
runGuarded('runtime-options:changed', () => {
|
||||||
@@ -539,6 +557,11 @@ async function init(): Promise<void> {
|
|||||||
keyboardHandlers.updateKeybindings(payload.keybindings);
|
keyboardHandlers.updateKeybindings(payload.keybindings);
|
||||||
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
||||||
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
|
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
|
||||||
|
ctx.state.subtitleSidebarConfig = payload.subtitleSidebar;
|
||||||
|
ctx.state.subtitleSidebarToggleKey = payload.subtitleSidebar.toggleKey;
|
||||||
|
ctx.state.subtitleSidebarPauseVideoOnHover = payload.subtitleSidebar.pauseVideoOnHover;
|
||||||
|
ctx.state.subtitleSidebarAutoScroll = payload.subtitleSidebar.autoScroll;
|
||||||
|
void subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -555,6 +578,8 @@ async function init(): Promise<void> {
|
|||||||
|
|
||||||
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||||
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||||
|
await subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
|
||||||
|
await subtitleSidebarModal.autoOpenSubtitleSidebarOnStartup();
|
||||||
|
|
||||||
positioning.applyStoredSubtitlePosition(
|
positioning.applyStoredSubtitlePosition(
|
||||||
await window.electronAPI.getSubtitlePosition(),
|
await window.electronAPI.getSubtitlePosition(),
|
||||||
@@ -563,7 +588,7 @@ async function init(): Promise<void> {
|
|||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
|
|
||||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
measurementReporter.emitNow();
|
measurementReporter.emitNow();
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
RuntimeOptionState,
|
RuntimeOptionState,
|
||||||
RuntimeOptionValue,
|
RuntimeOptionValue,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
|
SubtitleSidebarConfig,
|
||||||
|
SubtitleCue,
|
||||||
SubsyncSourceTrack,
|
SubsyncSourceTrack,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ export type ChordAction =
|
|||||||
|
|
||||||
export type RendererState = {
|
export type RendererState = {
|
||||||
isOverSubtitle: boolean;
|
isOverSubtitle: boolean;
|
||||||
|
isOverSubtitleSidebar: boolean;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
dragStartY: number;
|
dragStartY: number;
|
||||||
startYPercent: number;
|
startYPercent: number;
|
||||||
@@ -58,6 +61,7 @@ export type RendererState = {
|
|||||||
|
|
||||||
controllerSelectModalOpen: boolean;
|
controllerSelectModalOpen: boolean;
|
||||||
controllerDebugModalOpen: boolean;
|
controllerDebugModalOpen: boolean;
|
||||||
|
subtitleSidebarModalOpen: boolean;
|
||||||
controllerDeviceSelectedIndex: number;
|
controllerDeviceSelectedIndex: number;
|
||||||
controllerConfig: ResolvedControllerConfig | null;
|
controllerConfig: ResolvedControllerConfig | null;
|
||||||
connectedGamepads: ControllerDeviceInfo[];
|
connectedGamepads: ControllerDeviceInfo[];
|
||||||
@@ -67,6 +71,14 @@ export type RendererState = {
|
|||||||
|
|
||||||
sessionHelpModalOpen: boolean;
|
sessionHelpModalOpen: boolean;
|
||||||
sessionHelpSelectedIndex: number;
|
sessionHelpSelectedIndex: number;
|
||||||
|
subtitleSidebarCues: SubtitleCue[];
|
||||||
|
subtitleSidebarActiveCueIndex: number;
|
||||||
|
subtitleSidebarToggleKey: string;
|
||||||
|
subtitleSidebarPauseVideoOnHover: boolean;
|
||||||
|
subtitleSidebarAutoScroll: boolean;
|
||||||
|
subtitleSidebarConfig: Required<SubtitleSidebarConfig> | null;
|
||||||
|
subtitleSidebarManualScrollUntilMs: number;
|
||||||
|
subtitleSidebarPausedByHover: boolean;
|
||||||
|
|
||||||
knownWordColor: string;
|
knownWordColor: string;
|
||||||
nPlusOneColor: string;
|
nPlusOneColor: string;
|
||||||
@@ -104,6 +116,7 @@ export type RendererState = {
|
|||||||
export function createRendererState(): RendererState {
|
export function createRendererState(): RendererState {
|
||||||
return {
|
return {
|
||||||
isOverSubtitle: false,
|
isOverSubtitle: false,
|
||||||
|
isOverSubtitleSidebar: false,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
dragStartY: 0,
|
dragStartY: 0,
|
||||||
startYPercent: 0,
|
startYPercent: 0,
|
||||||
@@ -139,6 +152,7 @@ export function createRendererState(): RendererState {
|
|||||||
|
|
||||||
controllerSelectModalOpen: false,
|
controllerSelectModalOpen: false,
|
||||||
controllerDebugModalOpen: false,
|
controllerDebugModalOpen: false,
|
||||||
|
subtitleSidebarModalOpen: false,
|
||||||
controllerDeviceSelectedIndex: 0,
|
controllerDeviceSelectedIndex: 0,
|
||||||
controllerConfig: null,
|
controllerConfig: null,
|
||||||
connectedGamepads: [],
|
connectedGamepads: [],
|
||||||
@@ -148,6 +162,14 @@ export function createRendererState(): RendererState {
|
|||||||
|
|
||||||
sessionHelpModalOpen: false,
|
sessionHelpModalOpen: false,
|
||||||
sessionHelpSelectedIndex: 0,
|
sessionHelpSelectedIndex: 0,
|
||||||
|
subtitleSidebarCues: [],
|
||||||
|
subtitleSidebarActiveCueIndex: -1,
|
||||||
|
subtitleSidebarToggleKey: 'Backslash',
|
||||||
|
subtitleSidebarPauseVideoOnHover: false,
|
||||||
|
subtitleSidebarAutoScroll: true,
|
||||||
|
subtitleSidebarConfig: null,
|
||||||
|
subtitleSidebarManualScrollUntilMs: 0,
|
||||||
|
subtitleSidebarPausedByHover: false,
|
||||||
|
|
||||||
knownWordColor: '#a6da95',
|
knownWordColor: '#a6da95',
|
||||||
nPlusOneColor: '#c6a0f6',
|
nPlusOneColor: '#c6a0f6',
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ body {
|
|||||||
'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS', Arial, sans-serif;
|
'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--subtitle-sidebar-reserved-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
#overlay {
|
#overlay {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -294,13 +298,19 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.subtitle-sidebar-embedded-open #subtitleContainer {
|
||||||
|
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||||
|
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
|
||||||
|
}
|
||||||
|
|
||||||
#subtitleContainer {
|
#subtitleContainer {
|
||||||
max-width: 80%;
|
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||||
margin-bottom: 60px;
|
margin-bottom: 60px;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
background: rgb(30, 32, 48, 0.88);
|
background: rgb(30, 32, 48, 0.88);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#subtitleRoot {
|
#subtitleRoot {
|
||||||
@@ -705,20 +715,26 @@ body.platform-macos.layer-visible #subtitleRoot {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.subtitle-sidebar-embedded-open #secondarySubContainer {
|
||||||
|
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||||
|
transform: translateX(calc(-50% - (var(--subtitle-sidebar-reserved-width) * 0.5)));
|
||||||
|
}
|
||||||
|
|
||||||
#secondarySubContainer {
|
#secondarySubContainer {
|
||||||
--secondary-sub-background-color: transparent;
|
--secondary-sub-background-color: transparent;
|
||||||
--secondary-sub-backdrop-filter: none;
|
--secondary-sub-backdrop-filter: none;
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 40px;
|
top: 40px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||||
max-width: 80%;
|
|
||||||
padding: 10px 18px;
|
padding: 10px 18px;
|
||||||
background: var(--secondary-sub-background-color, transparent);
|
background: var(--secondary-sub-background-color, transparent);
|
||||||
backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
||||||
-webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
-webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.layer-modal #subtitleContainer,
|
body.layer-modal #subtitleContainer,
|
||||||
@@ -763,6 +779,14 @@ body.settings-modal-open #secondarySubContainer {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
|
||||||
|
left: 0;
|
||||||
|
right: var(--subtitle-sidebar-reserved-width);
|
||||||
|
max-width: none;
|
||||||
|
padding-right: 0;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
#secondarySubContainer.secondary-sub-hover {
|
#secondarySubContainer.secondary-sub-hover {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
@@ -789,11 +813,13 @@ body.settings-modal-open #secondarySubContainer {
|
|||||||
padding: 10px 18px;
|
padding: 10px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#secondarySubContainer.secondary-sub-hover:hover {
|
#secondarySubContainer.secondary-sub-hover:hover,
|
||||||
|
#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot {
|
#secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot,
|
||||||
|
#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active #secondarySubRoot {
|
||||||
background: var(--secondary-sub-background-color, transparent);
|
background: var(--secondary-sub-background-color, transparent);
|
||||||
backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
||||||
-webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
-webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
||||||
@@ -1362,6 +1388,206 @@ iframe[id^='yomitan-popup'] {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-modal {
|
||||||
|
inset: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 14px;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal {
|
||||||
|
padding: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-content {
|
||||||
|
width: min(var(--subtitle-sidebar-max-width, 420px), 92vw);
|
||||||
|
max-height: calc(100vh - 28px);
|
||||||
|
height: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
font-family:
|
||||||
|
var(
|
||||||
|
--subtitle-sidebar-font-family,
|
||||||
|
'M PLUS 1',
|
||||||
|
'Noto Sans CJK JP',
|
||||||
|
'Hiragino Sans',
|
||||||
|
sans-serif
|
||||||
|
);
|
||||||
|
font-size: var(--subtitle-sidebar-font-size, 16px);
|
||||||
|
background: var(--subtitle-sidebar-background-color, rgba(73, 77, 100, 0.9));
|
||||||
|
color: var(--subtitle-sidebar-text-color, #cad3f5);
|
||||||
|
border: 1px solid rgba(110, 115, 141, 0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.2),
|
||||||
|
inset 0 1px 0 rgba(183, 189, 248, 0.06);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.4);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||||
|
opacity: var(--subtitle-sidebar-opacity, 0.95);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-content .modal-header {
|
||||||
|
padding: 10px 14px 8px;
|
||||||
|
border-bottom: 1px solid rgba(110, 115, 141, 0.14);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-content .modal-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #b8c0e0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-content .modal-close {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(73, 77, 100, 0.5);
|
||||||
|
border: 1px solid rgba(110, 115, 141, 0.2);
|
||||||
|
color: #a5adcb;
|
||||||
|
transition: all 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-content .modal-close:hover {
|
||||||
|
background: rgba(91, 96, 120, 0.6);
|
||||||
|
color: #cad3f5;
|
||||||
|
border-color: rgba(110, 115, 141, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||||
|
width: min(var(--subtitle-sidebar-max-width, 420px), 44vw);
|
||||||
|
max-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
border-top: none;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
box-shadow:
|
||||||
|
-12px 0 32px rgba(0, 0, 0, 0.3),
|
||||||
|
-1px 0 0 rgba(110, 115, 141, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-body {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-content .runtime-options-status {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 14px;
|
||||||
|
color: #6e738d;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-list {
|
||||||
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(110, 115, 141, 0.25);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(110, 115, 141, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-bottom: 1px solid rgba(110, 115, 141, 0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 120ms ease,
|
||||||
|
color 120ms ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item:hover {
|
||||||
|
background: var(--subtitle-sidebar-hover-background-color, rgba(54, 58, 79, 0.65));
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item:focus-visible {
|
||||||
|
outline: 2px solid var(--subtitle-sidebar-active-line-color, #f5bde6);
|
||||||
|
outline-offset: -2px;
|
||||||
|
background: var(--subtitle-sidebar-hover-background-color, rgba(54, 58, 79, 0.65));
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item.active {
|
||||||
|
background: var(--subtitle-sidebar-active-background-color, rgba(138, 173, 244, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
background: var(--subtitle-sidebar-active-line-color, #f5bde6);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-timestamp {
|
||||||
|
font-size: calc(var(--subtitle-sidebar-font-size, 16px) * 0.72);
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--subtitle-sidebar-timestamp-color, #6e738d);
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item:hover .subtitle-sidebar-timestamp {
|
||||||
|
color: var(--subtitle-sidebar-timestamp-color, #a5adcb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item.active .subtitle-sidebar-timestamp {
|
||||||
|
color: var(--subtitle-sidebar-active-line-color, #f5bde6);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item.active .subtitle-sidebar-text {
|
||||||
|
color: var(--subtitle-sidebar-active-line-color, #f5bde6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 1em;
|
||||||
|
color: var(--subtitle-sidebar-text-color, #cad3f5);
|
||||||
|
}
|
||||||
|
|
||||||
.session-help-content {
|
.session-help-content {
|
||||||
width: min(760px, 92%);
|
width: min(760px, 92%);
|
||||||
max-height: 84%;
|
max-height: 84%;
|
||||||
|
|||||||
@@ -977,6 +977,30 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
|||||||
);
|
);
|
||||||
assert.match(secondaryHoverBaseBlock, /background:\s*transparent;/);
|
assert.match(secondaryHoverBaseBlock, /background:\s*transparent;/);
|
||||||
|
|
||||||
|
const secondaryEmbeddedHoverBlock = extractClassBlock(
|
||||||
|
cssText,
|
||||||
|
'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
secondaryEmbeddedHoverBlock,
|
||||||
|
/right:\s*var\(--subtitle-sidebar-reserved-width\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
secondaryEmbeddedHoverBlock,
|
||||||
|
/max-width:\s*none;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
secondaryEmbeddedHoverBlock,
|
||||||
|
/transform:\s*none;/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
secondaryEmbeddedHoverBlock,
|
||||||
|
/transform:\s*translateX\(calc\(var\(--subtitle-sidebar-reserved-width\)\s*\*\s*-0\.5\)\);/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list');
|
||||||
|
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
|
||||||
|
|
||||||
const secondaryHoverVisibleBlock = extractClassBlock(
|
const secondaryHoverVisibleBlock = extractClassBlock(
|
||||||
cssText,
|
cssText,
|
||||||
'#secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot',
|
'#secondarySubContainer.secondary-sub-hover:hover #secondarySubRoot',
|
||||||
@@ -990,6 +1014,25 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
|||||||
/backdrop-filter:\s*var\(--secondary-sub-backdrop-filter,\s*none\);/,
|
/backdrop-filter:\s*var\(--secondary-sub-backdrop-filter,\s*none\);/,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const secondaryHoverActiveBlock = extractClassBlock(
|
||||||
|
cssText,
|
||||||
|
'#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active',
|
||||||
|
);
|
||||||
|
assert.match(secondaryHoverActiveBlock, /opacity:\s*1;/);
|
||||||
|
|
||||||
|
const secondaryHoverActiveRootBlock = extractClassBlock(
|
||||||
|
cssText,
|
||||||
|
'#secondarySubContainer.secondary-sub-hover.secondary-sub-hover-active #secondarySubRoot',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
secondaryHoverActiveRootBlock,
|
||||||
|
/background:\s*var\(--secondary-sub-background-color,\s*transparent\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
secondaryHoverActiveRootBlock,
|
||||||
|
/backdrop-filter:\s*var\(--secondary-sub-backdrop-filter,\s*none\);/,
|
||||||
|
);
|
||||||
|
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
cssText,
|
cssText,
|
||||||
/body\.layer-visible\s+#secondarySubContainer\s*\{[^}]*display:\s*none/i,
|
/body\.layer-visible\s+#secondarySubContainer\s*\{[^}]*display:\s*none/i,
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ export type RendererDom = {
|
|||||||
controllerDebugAxes: HTMLPreElement;
|
controllerDebugAxes: HTMLPreElement;
|
||||||
controllerDebugButtons: HTMLPreElement;
|
controllerDebugButtons: HTMLPreElement;
|
||||||
controllerDebugButtonIndices: HTMLPreElement;
|
controllerDebugButtonIndices: HTMLPreElement;
|
||||||
|
subtitleSidebarModal: HTMLDivElement;
|
||||||
|
subtitleSidebarContent: HTMLDivElement;
|
||||||
|
subtitleSidebarClose: HTMLButtonElement;
|
||||||
|
subtitleSidebarStatus: HTMLDivElement;
|
||||||
|
subtitleSidebarList: HTMLUListElement;
|
||||||
|
|
||||||
sessionHelpModal: HTMLDivElement;
|
sessionHelpModal: HTMLDivElement;
|
||||||
sessionHelpClose: HTMLButtonElement;
|
sessionHelpClose: HTMLButtonElement;
|
||||||
@@ -171,6 +176,11 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>(
|
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>(
|
||||||
'controllerDebugButtonIndices',
|
'controllerDebugButtonIndices',
|
||||||
),
|
),
|
||||||
|
subtitleSidebarModal: getRequiredElement<HTMLDivElement>('subtitleSidebarModal'),
|
||||||
|
subtitleSidebarContent: getRequiredElement<HTMLDivElement>('subtitleSidebarContent'),
|
||||||
|
subtitleSidebarClose: getRequiredElement<HTMLButtonElement>('subtitleSidebarClose'),
|
||||||
|
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
|
||||||
|
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),
|
||||||
|
|
||||||
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
|||||||
'kiku',
|
'kiku',
|
||||||
'controller-select',
|
'controller-select',
|
||||||
'controller-debug',
|
'controller-debug',
|
||||||
|
'subtitle-sidebar',
|
||||||
] as const;
|
] as const;
|
||||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export const IPC_CHANNELS = {
|
|||||||
getCurrentSubtitle: 'get-current-subtitle',
|
getCurrentSubtitle: 'get-current-subtitle',
|
||||||
getCurrentSubtitleRaw: 'get-current-subtitle-raw',
|
getCurrentSubtitleRaw: 'get-current-subtitle-raw',
|
||||||
getCurrentSubtitleAss: 'get-current-subtitle-ass',
|
getCurrentSubtitleAss: 'get-current-subtitle-ass',
|
||||||
|
getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot',
|
||||||
getPlaybackPaused: 'get-playback-paused',
|
getPlaybackPaused: 'get-playback-paused',
|
||||||
getSubtitlePosition: 'get-subtitle-position',
|
getSubtitlePosition: 'get-subtitle-position',
|
||||||
getSubtitleStyle: 'get-subtitle-style',
|
getSubtitleStyle: 'get-subtitle-style',
|
||||||
|
|||||||
48
src/types.ts
48
src/types.ts
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { SubtitleCue } from './core/services/subtitle-cue-parser';
|
||||||
|
|
||||||
export enum PartOfSpeech {
|
export enum PartOfSpeech {
|
||||||
noun = 'noun',
|
noun = 'noun',
|
||||||
verb = 'verb',
|
verb = 'verb',
|
||||||
@@ -364,6 +366,29 @@ export interface ResolvedTokenPos2ExclusionConfig {
|
|||||||
|
|
||||||
export type FrequencyDictionaryMode = 'single' | 'banded';
|
export type FrequencyDictionaryMode = 'single' | 'banded';
|
||||||
|
|
||||||
|
export type { SubtitleCue } from './core/services/subtitle-cue-parser';
|
||||||
|
|
||||||
|
export type SubtitleSidebarLayout = 'overlay' | 'embedded';
|
||||||
|
|
||||||
|
export interface SubtitleSidebarConfig {
|
||||||
|
enabled?: boolean;
|
||||||
|
autoOpen?: boolean;
|
||||||
|
layout?: SubtitleSidebarLayout;
|
||||||
|
toggleKey?: string;
|
||||||
|
pauseVideoOnHover?: boolean;
|
||||||
|
autoScroll?: boolean;
|
||||||
|
maxWidth?: number;
|
||||||
|
opacity?: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
textColor?: string;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
timestampColor?: string;
|
||||||
|
activeLineColor?: string;
|
||||||
|
activeLineBackgroundColor?: string;
|
||||||
|
hoverLineBackgroundColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShortcutsConfig {
|
export interface ShortcutsConfig {
|
||||||
toggleVisibleOverlayGlobal?: string | null;
|
toggleVisibleOverlayGlobal?: string | null;
|
||||||
copySubtitle?: string | null;
|
copySubtitle?: string | null;
|
||||||
@@ -675,6 +700,7 @@ export interface Config {
|
|||||||
subsync?: SubsyncConfig;
|
subsync?: SubsyncConfig;
|
||||||
startupWarmups?: StartupWarmupsConfig;
|
startupWarmups?: StartupWarmupsConfig;
|
||||||
subtitleStyle?: SubtitleStyleConfig;
|
subtitleStyle?: SubtitleStyleConfig;
|
||||||
|
subtitleSidebar?: SubtitleSidebarConfig;
|
||||||
auto_start_overlay?: boolean;
|
auto_start_overlay?: boolean;
|
||||||
jimaku?: JimakuConfig;
|
jimaku?: JimakuConfig;
|
||||||
anilist?: AnilistConfig;
|
anilist?: AnilistConfig;
|
||||||
@@ -807,6 +833,7 @@ export interface ResolvedConfig {
|
|||||||
bandedColors: [string, string, string, string, string];
|
bandedColors: [string, string, string, string, string];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
subtitleSidebar: Required<SubtitleSidebarConfig>;
|
||||||
auto_start_overlay: boolean;
|
auto_start_overlay: boolean;
|
||||||
jimaku: JimakuConfig & {
|
jimaku: JimakuConfig & {
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
@@ -939,6 +966,19 @@ export interface ClipboardAppendResult {
|
|||||||
export interface SubtitleData {
|
export interface SubtitleData {
|
||||||
text: string;
|
text: string;
|
||||||
tokens: MergedToken[] | null;
|
tokens: MergedToken[] | null;
|
||||||
|
startTime?: number | null;
|
||||||
|
endTime?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleSidebarSnapshot {
|
||||||
|
cues: SubtitleCue[];
|
||||||
|
currentTimeSec?: number | null;
|
||||||
|
currentSubtitle: {
|
||||||
|
text: string;
|
||||||
|
startTime: number | null;
|
||||||
|
endTime: number | null;
|
||||||
|
};
|
||||||
|
config: Required<SubtitleSidebarConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MpvSubtitleRenderMetrics {
|
export interface MpvSubtitleRenderMetrics {
|
||||||
@@ -1057,6 +1097,7 @@ export type JimakuDownloadResult =
|
|||||||
export interface ConfigHotReloadPayload {
|
export interface ConfigHotReloadPayload {
|
||||||
keybindings: Keybinding[];
|
keybindings: Keybinding[];
|
||||||
subtitleStyle: SubtitleStyleConfig | null;
|
subtitleStyle: SubtitleStyleConfig | null;
|
||||||
|
subtitleSidebar: Required<SubtitleSidebarConfig>;
|
||||||
secondarySubMode: SecondarySubMode;
|
secondarySubMode: SecondarySubMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,6 +1116,7 @@ export interface ElectronAPI {
|
|||||||
getCurrentSubtitle: () => Promise<SubtitleData>;
|
getCurrentSubtitle: () => Promise<SubtitleData>;
|
||||||
getCurrentSubtitleRaw: () => Promise<string>;
|
getCurrentSubtitleRaw: () => Promise<string>;
|
||||||
getCurrentSubtitleAss: () => Promise<string>;
|
getCurrentSubtitleAss: () => Promise<string>;
|
||||||
|
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
|
||||||
getPlaybackPaused: () => Promise<boolean | null>;
|
getPlaybackPaused: () => Promise<boolean | null>;
|
||||||
onSubtitleAss: (callback: (assText: string) => void) => void;
|
onSubtitleAss: (callback: (assText: string) => void) => void;
|
||||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||||
@@ -1134,7 +1176,8 @@ export interface ElectronAPI {
|
|||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'kiku'
|
| 'kiku'
|
||||||
| 'controller-select'
|
| 'controller-select'
|
||||||
| 'controller-debug',
|
| 'controller-debug'
|
||||||
|
| 'subtitle-sidebar',
|
||||||
) => void;
|
) => void;
|
||||||
notifyOverlayModalOpened: (
|
notifyOverlayModalOpened: (
|
||||||
modal:
|
modal:
|
||||||
@@ -1143,7 +1186,8 @@ export interface ElectronAPI {
|
|||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'kiku'
|
| 'kiku'
|
||||||
| 'controller-select'
|
| 'controller-select'
|
||||||
| 'controller-debug',
|
| 'controller-debug'
|
||||||
|
| 'subtitle-sidebar',
|
||||||
) => void;
|
) => void;
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ const summary: OverviewSummary = {
|
|||||||
activeDays: 12,
|
activeDays: 12,
|
||||||
totalSessions: 15,
|
totalSessions: 15,
|
||||||
lookupRate: {
|
lookupRate: {
|
||||||
shortValue: '2.3 / 100 tokens',
|
shortValue: '2.3 / 100 words',
|
||||||
longValue: '2.3 lookups per 100 tokens',
|
longValue: '2.3 lookups per 100 words',
|
||||||
},
|
},
|
||||||
todayTokens: 0,
|
todayTokens: 0,
|
||||||
newWordsToday: 0,
|
newWordsToday: 0,
|
||||||
@@ -33,8 +33,8 @@ test('TrackingSnapshot renders Yomitan lookup rate copy on the homepage card', (
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.match(markup, /Lookup Rate/);
|
assert.match(markup, /Lookup Rate/);
|
||||||
assert.match(markup, /2\.3 \/ 100 tokens/);
|
assert.match(markup, /2\.3 \/ 100 words/);
|
||||||
assert.match(markup, /Lifetime Yomitan lookups normalized by total tokens seen/);
|
assert.match(markup, /Lifetime Yomitan lookups normalized by total words seen/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('TrackingSnapshot labels new words as unique headwords', () => {
|
test('TrackingSnapshot labels new words as unique headwords', () => {
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export function TrendsTab() {
|
|||||||
color={cardsMinedColor}
|
color={cardsMinedColor}
|
||||||
type="bar"
|
type="bar"
|
||||||
/>
|
/>
|
||||||
<TrendChart title="Tokens Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
|
||||||
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
||||||
|
|
||||||
<SectionHeader>Period Trends</SectionHeader>
|
<SectionHeader>Period Trends</SectionHeader>
|
||||||
@@ -194,7 +194,7 @@ export function TrendsTab() {
|
|||||||
type="line"
|
type="line"
|
||||||
/>
|
/>
|
||||||
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
|
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
|
||||||
<TrendChart title="Tokens Seen" data={data.progress.words} color="#8bd5ca" type="line" />
|
<TrendChart title="Words Seen" data={data.progress.words} color="#8bd5ca" type="line" />
|
||||||
<TrendChart
|
<TrendChart
|
||||||
title="New Words Seen"
|
title="New Words Seen"
|
||||||
data={data.progress.newWords}
|
data={data.progress.newWords}
|
||||||
@@ -215,7 +215,7 @@ export function TrendsTab() {
|
|||||||
/>
|
/>
|
||||||
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
|
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
|
||||||
<TrendChart
|
<TrendChart
|
||||||
title="Lookups / 100 Tokens"
|
title="Lookups / 100 Words"
|
||||||
data={data.ratios.lookupsPerHundred}
|
data={data.ratios.lookupsPerHundred}
|
||||||
color="#f5a97f"
|
color="#f5a97f"
|
||||||
type="line"
|
type="line"
|
||||||
@@ -246,7 +246,7 @@ export function TrendsTab() {
|
|||||||
data={filteredCardsPerAnime}
|
data={filteredCardsPerAnime}
|
||||||
colorPalette={cardsMinedStackedColors}
|
colorPalette={cardsMinedStackedColors}
|
||||||
/>
|
/>
|
||||||
<StackedTrendChart title="Tokens Seen per Anime" data={filteredWordsPerAnime} />
|
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} />
|
||||||
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
|
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
|
||||||
<StackedTrendChart
|
<StackedTrendChart
|
||||||
title="Lookups/100w per Anime"
|
title="Lookups/100w per Anime"
|
||||||
@@ -261,7 +261,7 @@ export function TrendsTab() {
|
|||||||
data={filteredCardsProgress}
|
data={filteredCardsProgress}
|
||||||
colorPalette={cardsMinedStackedColors}
|
colorPalette={cardsMinedStackedColors}
|
||||||
/>
|
/>
|
||||||
<StackedTrendChart title="Tokens Seen Progress" data={filteredWordsProgress} />
|
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
|
||||||
|
|
||||||
<SectionHeader>Patterns</SectionHeader>
|
<SectionHeader>Patterns</SectionHeader>
|
||||||
<TrendChart
|
<TrendChart
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
|||||||
assert.equal(summary.activeDays, 2);
|
assert.equal(summary.activeDays, 2);
|
||||||
assert.equal(summary.totalSessions, 15);
|
assert.equal(summary.totalSessions, 15);
|
||||||
assert.deepEqual(summary.lookupRate, {
|
assert.deepEqual(summary.lookupRate, {
|
||||||
shortValue: '2.3 / 100 tokens',
|
shortValue: '2.3 / 100 words',
|
||||||
longValue: '2.3 lookups per 100 tokens',
|
longValue: '2.3 lookups per 100 words',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,6 @@ test('MediaSessionList renders expandable session rows with delete affordance',
|
|||||||
assert.match(markup, /Session History/);
|
assert.match(markup, /Session History/);
|
||||||
assert.match(markup, /aria-expanded="true"/);
|
assert.match(markup, /aria-expanded="true"/);
|
||||||
assert.match(markup, /Delete session Episode 7/);
|
assert.match(markup, /Delete session Episode 7/);
|
||||||
assert.match(markup, /tokens/);
|
assert.match(markup, /words/);
|
||||||
assert.match(markup, /No token data for this session/);
|
assert.match(markup, /No word data for this session/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ test('SessionDetail omits the misleading new words metric', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.match(markup, /No token data/);
|
assert.match(markup, /No word data/);
|
||||||
assert.doesNotMatch(markup, /New words/);
|
assert.doesNotMatch(markup, /New words/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user